From 7ab17054a35ca441d23e9ff539ac10223cedbde7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 08:08:51 -0400 Subject: [PATCH 1/5] Disregard guarantee credit recipients' income and capital in pensioner CTR The England pensioner, Wales and Scotland council tax reduction schemes disregard the whole income and capital of an applicant who, or whose partner, is in receipt of Pension Credit guarantee credit (SI 2012/2885 Sch 1 para 13; WSI 2013/3029 Sch 1 para 7; SSI 2012/319 reg 24). PE-UK tapered them on income after tax and applied the 16,000 capital limit to household savings, so a guarantee credit recipient's CTR rose when their State Pension fell and they lost all CTR with savings over 16,000. In savings-credit-only cases the schemes use the Secretary of State's Pension Credit assessment of income, adjusted for the savings credit payable, and of capital (para 14; para 8; reg 25). - in_receipt_of_guarantee_credit (shared with the HB passport fix) and in_receipt_of_savings_credit_only test receipt: Pension Credit eligibility, take-up and a positive component. guarantee_credit alone is positive for working-age and non-claiming units. - council_tax_reduction_applicable_income is 0 on guarantee credit and pension_credit_income + savings_credit on savings credit only. - New council_tax_reduction_assessable_capital feeds the capital limit: 0 on guarantee credit, pension_credit_assessable_capital on savings credit only, household savings otherwise. - Point the national CTR parameters at the provisions in force. Co-Authored-By: Claude Opus 5.5 --- .../ctr-pension-credit-disregard.fixed.md | 1 + .../pensioners/maximum_support_rate.yaml | 4 +- .../pensioners/means_test/capital_limit.yaml | 4 +- .../means_test/withdrawal_rate.yaml | 4 +- .../maximum_support_rate.yaml | 6 +- .../means_test/capital_limit.yaml | 6 +- .../means_test/withdrawal_rate.yaml | 6 +- .../maximum_support_rate.yaml | 6 +- .../means_test/capital_limit.yaml | 4 +- .../means_test/withdrawal_rate.yaml | 6 +- policyengine_uk/programs.yaml | 2 +- .../council_tax_reduction_pension_credit.yaml | 468 ++++++++++++++++++ ...tax_reduction_pension_credit_properties.py | 348 +++++++++++++ .../in_receipt_of_guarantee_credit.py | 33 ++ .../in_receipt_of_savings_credit_only.py | 33 ++ ...council_tax_reduction_applicable_income.py | 35 +- ...ouncil_tax_reduction_assessable_capital.py | 38 ++ ...simulated_council_tax_reduction_benunit.py | 3 +- 18 files changed, 992 insertions(+), 15 deletions(-) create mode 100644 changelog.d/ctr-pension-credit-disregard.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_credit.yaml create mode 100644 policyengine_uk/tests/test_council_tax_reduction_pension_credit_properties.py create mode 100644 policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/in_receipt_of_guarantee_credit.py create mode 100644 policyengine_uk/variables/gov/dwp/pension_credit/savings_credit/in_receipt_of_savings_credit_only.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_assessable_capital.py diff --git a/changelog.d/ctr-pension-credit-disregard.fixed.md b/changelog.d/ctr-pension-credit-disregard.fixed.md new file mode 100644 index 0000000000..0ee4e06023 --- /dev/null +++ b/changelog.d/ctr-pension-credit-disregard.fixed.md @@ -0,0 +1 @@ +Disregard the whole income and capital of Pension Credit guarantee credit recipients in the England pensioner, Wales and Scotland council tax reduction schemes, so they get the maximum reduction less non-dependant deductions whatever their income or savings, and use the Pension Credit assessment of income (plus the savings credit) and capital for savings-credit-only recipients (SI 2012/2885 Sch 1 paras 13-14; WSI 2013/3029 Sch 1 paras 7-8; SSI 2012/319 regs 24-25). diff --git a/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/maximum_support_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/maximum_support_rate.yaml index bb0c523676..4e8503b317 100644 --- a/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/maximum_support_rate.yaml +++ b/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/maximum_support_rate.yaml @@ -6,5 +6,5 @@ metadata: period: year label: England Council Tax Reduction pensioner maximum support rate reference: - - title: The Council Tax Reduction Schemes (Prescribed Requirements) (England) Regulations 2012 - href: https://www.legislation.gov.uk/uksi/2012/2885/contents/made + - title: The Council Tax Reduction Schemes (Prescribed Requirements) (England) Regulations 2012 Sch. 1 para. 7(1) + href: https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/7 diff --git a/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/means_test/capital_limit.yaml b/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/means_test/capital_limit.yaml index c6543dad07..597a702def 100644 --- a/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/means_test/capital_limit.yaml +++ b/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/means_test/capital_limit.yaml @@ -6,5 +6,5 @@ metadata: period: year label: England Council Tax Reduction pensioner capital limit reference: - - title: The Council Tax Reduction Schemes (Prescribed Requirements) (England) Regulations 2012 - href: https://www.legislation.gov.uk/uksi/2012/2885/contents/made + - title: The Council Tax Reduction Schemes (Prescribed Requirements) (England) Regulations 2012 reg. 11(2) + href: https://www.legislation.gov.uk/uksi/2012/2885/regulation/11 diff --git a/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/means_test/withdrawal_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/means_test/withdrawal_rate.yaml index c8ca6101ad..dc50fd0a2e 100644 --- a/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/means_test/withdrawal_rate.yaml +++ b/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/means_test/withdrawal_rate.yaml @@ -6,5 +6,5 @@ metadata: period: year label: England Council Tax Reduction pensioner withdrawal rate reference: - - title: The Council Tax Reduction Schemes (Prescribed Requirements) (England) Regulations 2012 - href: https://www.legislation.gov.uk/uksi/2012/2885/contents/made + - title: The Council Tax Reduction Schemes (Prescribed Requirements) (England) Regulations 2012 Sch. 1 para. 3(f)(ii) (2 6/7 per cent a day of weekly excess income) + href: https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/3 diff --git a/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/maximum_support_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/maximum_support_rate.yaml index d2042b6106..63af8384b7 100644 --- a/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/maximum_support_rate.yaml +++ b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/maximum_support_rate.yaml @@ -6,5 +6,9 @@ metadata: period: year label: Scotland Council Tax Reduction maximum support rate reference: - - title: The Council Tax Reduction (Scotland) Regulations 2012 + - title: The Council Tax Reduction (State Pension Credit) (Scotland) Regulations 2012 reg. 47(1) (pensioners) + href: https://www.legislation.gov.uk/ssi/2012/319/regulation/47 + - title: The Council Tax Reduction (Scotland) Regulations 2021 (persons under pension age) + href: https://www.legislation.gov.uk/ssi/2021/249/contents + - title: The Council Tax Reduction (Scotland) Regulations 2012 (persons under pension age, to 31 March 2022) href: https://www.legislation.gov.uk/ssi/2012/303/contents/made diff --git a/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/means_test/capital_limit.yaml b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/means_test/capital_limit.yaml index 8a659bb8ac..1600248c9f 100644 --- a/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/means_test/capital_limit.yaml +++ b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/means_test/capital_limit.yaml @@ -6,5 +6,9 @@ metadata: period: year label: Scotland Council Tax Reduction capital limit reference: - - title: The Council Tax Reduction (Scotland) Regulations 2012 + - title: The Council Tax Reduction (State Pension Credit) (Scotland) Regulations 2012 reg. 40 (pensioners) + href: https://www.legislation.gov.uk/ssi/2012/319/regulation/40 + - title: The Council Tax Reduction (Scotland) Regulations 2021 reg. 66 (persons under pension age) + href: https://www.legislation.gov.uk/ssi/2021/249/regulation/66 + - title: The Council Tax Reduction (Scotland) Regulations 2012 (persons under pension age, to 31 March 2022) href: https://www.legislation.gov.uk/ssi/2012/303/contents/made diff --git a/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/means_test/withdrawal_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/means_test/withdrawal_rate.yaml index 21e1648704..0f1b238542 100644 --- a/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/means_test/withdrawal_rate.yaml +++ b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/means_test/withdrawal_rate.yaml @@ -6,5 +6,9 @@ metadata: period: year label: Scotland Council Tax Reduction withdrawal rate reference: - - title: The Council Tax Reduction (Scotland) Regulations 2012 + - title: The Council Tax Reduction (State Pension Credit) (Scotland) Regulations 2012 reg. 14(5)(b) (pensioners; 2 6/7 per cent a day of weekly excess income) + href: https://www.legislation.gov.uk/ssi/2012/319/regulation/14 + - title: The Council Tax Reduction (Scotland) Regulations 2021 reg. 13 (persons under pension age) + href: https://www.legislation.gov.uk/ssi/2021/249/regulation/13 + - title: The Council Tax Reduction (Scotland) Regulations 2012 (persons under pension age, to 31 March 2022) href: https://www.legislation.gov.uk/ssi/2012/303/contents/made diff --git a/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/maximum_support_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/maximum_support_rate.yaml index 377bc0caf7..6419af075e 100644 --- a/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/maximum_support_rate.yaml +++ b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/maximum_support_rate.yaml @@ -6,5 +6,9 @@ metadata: period: year label: Wales Council Tax Reduction maximum support rate reference: - - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2012 + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. 1 para. 2(1) (pensioners) + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/2 + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. 6 para. 4(1) (persons who are not pensioners) + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/4 + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2012 (2013-14 schemes; revoked from 1 April 2014) href: https://www.legislation.gov.uk/wsi/2012/3144/contents/made diff --git a/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/means_test/capital_limit.yaml b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/means_test/capital_limit.yaml index c3e4c1f7cc..db4a41bc50 100644 --- a/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/means_test/capital_limit.yaml +++ b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/means_test/capital_limit.yaml @@ -6,5 +6,7 @@ metadata: period: year label: Wales Council Tax Reduction capital limit reference: - - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2012 + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 reg. 30 + href: https://www.legislation.gov.uk/wsi/2013/3029/regulation/30 + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2012 (2013-14 schemes; revoked from 1 April 2014) href: https://www.legislation.gov.uk/wsi/2012/3144/contents/made diff --git a/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/means_test/withdrawal_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/means_test/withdrawal_rate.yaml index 620e0f32b9..e8b6dc6b9a 100644 --- a/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/means_test/withdrawal_rate.yaml +++ b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/means_test/withdrawal_rate.yaml @@ -6,5 +6,9 @@ metadata: period: year label: Wales Council Tax Reduction withdrawal rate reference: - - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2012 + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 reg. 23(f)(ii) (pensioners; 2 6/7 per cent a day of weekly excess income) + href: https://www.legislation.gov.uk/wsi/2013/3029/regulation/23 + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 reg. 25(f)(ii) (persons who are not pensioners) + href: https://www.legislation.gov.uk/wsi/2013/3029/regulation/25 + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2012 (2013-14 schemes; revoked from 1 April 2014) href: https://www.legislation.gov.uk/wsi/2012/3144/contents/made diff --git a/policyengine_uk/programs.yaml b/policyengine_uk/programs.yaml index f83e4acdd9..46d253f103 100644 --- a/policyengine_uk/programs.yaml +++ b/policyengine_uk/programs.yaml @@ -521,7 +521,7 @@ programs: variable: council_tax_reduction parameter_prefix: gov.local_authorities verified_start_year: 2013 - notes: First-pass national support for Scotland, Wales, and England pensioner schemes, with reported CTB/CTR retained for unsupported English working-age local schemes. + notes: First-pass national support for Scotland, Wales, and England pensioner schemes (including the Pension Credit guarantee credit disregard and savings-credit-only rules), with reported CTB/CTR retained for unsupported English working-age local schemes. # --- Treasury programs --- - id: cost_of_living_support diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_credit.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_credit.yaml new file mode 100644 index 0000000000..a7271143cf --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_credit.yaml @@ -0,0 +1,468 @@ +# Pension Credit recipients under the pensioner council tax reduction schemes. +# Guarantee credit: the whole of the applicant's income and capital is +# disregarded (SI 2012/2885 Sch 1 para 13; WSI 2013/3029 Sch 1 para 7; SSI +# 2012/319 reg 24), so the award is the maximum reduction less non-dependant +# deductions, whatever the income, and the capital limit cannot apply (SI +# 2012/2885 reg 11(3); WSI 2013/3029 reg 30(2)). Savings credit only: the +# Secretary of State's assessment of income and capital is used, adjusted for +# the savings credit payable (SI 2012/2885 Sch 1 para 14; WSI 2013/3029 Sch 1 +# para 8; SSI 2012/319 reg 25). +# +# A minimum_guarantee input of 18,500 stands in for an earnings-linked +# guarantee above the 13,312 CTR applicable amount, as on the uk-triple-lock +# central path in 2039-40, where the State Pension figures below come from. + +- name: England guarantee credit recipient with income above the applicable amount gets maximum CTR + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + state_pension: 18_712 + benunits: + benunit: + members: [pensioner] + claims_all_entitled_benefits: true + minimum_guarantee: 18_500 + households: + household: + members: [pensioner] + country: ENGLAND + local_authority: MAIDSTONE + tenure_type: OWNED_OUTRIGHT + council_tax: 2_000 + savings: 0 + output: + income_tax: 1_228.40 + guarantee_credit: 1_016.40 + in_receipt_of_guarantee_credit: true + council_tax_reduction_applicable_amount: 13_312 + # Before the disregard: 17,483.60 and a tapered CTR of 1,165.68. + council_tax_reduction_applicable_income: 0 + council_tax_reduction_assessable_capital: 0 + simulated_council_tax_reduction_benunit: 2_000 + council_tax_reduction: 2_000 + +- name: England guarantee credit recipient's CTR does not rise when the State Pension falls by 72.70 + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + state_pension: 18_639.30 + benunits: + benunit: + members: [pensioner] + claims_all_entitled_benefits: true + minimum_guarantee: 18_500 + households: + household: + members: [pensioner] + country: ENGLAND + local_authority: MAIDSTONE + tenure_type: OWNED_OUTRIGHT + council_tax: 2_000 + savings: 0 + output: + # 72.70 less State Pension: income tax -14.54, guarantee credit +58.16. + income_tax: 1_213.86 + guarantee_credit: 1_074.56 + # Before the disregard CTR rose by 11.63 to 1,177.31. + council_tax_reduction: 2_000 + +- name: Entitled to guarantee credit but not claiming Pension Credit keeps the taper + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + state_pension: 18_712 + benunits: + benunit: + members: [pensioner] + claims_all_entitled_benefits: true + minimum_guarantee: 18_500 + would_claim_pc: false + households: + household: + members: [pensioner] + country: ENGLAND + local_authority: MAIDSTONE + tenure_type: OWNED_OUTRIGHT + council_tax: 2_000 + savings: 0 + output: + guarantee_credit: 1_016.40 + pension_credit: 0 + in_receipt_of_guarantee_credit: false + council_tax_reduction_applicable_income: 17_483.60 + # 2,000 - 0.2 x (17,483.60 - 13,312) + council_tax_reduction: 1_165.68 + +- name: England pensioner without Pension Credit keeps the taper + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + state_pension: 18_712 + 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: + guarantee_credit: 0 + in_receipt_of_guarantee_credit: false + in_receipt_of_savings_credit_only: false + council_tax_reduction_applicable_income: 17_483.60 + council_tax_reduction: 1_165.68 + +- name: England guarantee credit recipient with capital above 16,000 gets maximum CTR + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + state_pension: 9_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: 20_000 + output: + # 12,376 - 9,000 - 1,040 deemed income from capital. + guarantee_credit: 2_336 + in_receipt_of_guarantee_credit: true + council_tax_reduction_assessable_capital: 0 + # Before the disregard the 16,000 capital limit gave 0. + council_tax_reduction: 2_000 + +- name: Capital limit still applies to a pensioner entitled to guarantee credit who does not claim it + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + state_pension: 9_000 + benunits: + benunit: + members: [pensioner] + claims_all_entitled_benefits: true + would_claim_pc: false + households: + household: + members: [pensioner] + country: ENGLAND + local_authority: MAIDSTONE + tenure_type: OWNED_OUTRIGHT + council_tax: 2_000 + savings: 20_000 + output: + guarantee_credit: 2_336 + in_receipt_of_guarantee_credit: false + council_tax_reduction_assessable_capital: 20_000 + council_tax_reduction: 0 + +- name: Pensioner couple on guarantee credit with 25,000 capital gets maximum CTR + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + state_pension: 6_000 + partner: + age: 68 + state_pension: 6_000 + benunits: + benunit: + members: [claimant, partner] + claims_all_entitled_benefits: true + households: + household: + members: [claimant, partner] + country: ENGLAND + local_authority: MAIDSTONE + tenure_type: OWNED_OUTRIGHT + council_tax: 2_000 + savings: 25_000 + output: + guarantee_credit: 5_329 + in_receipt_of_guarantee_credit: true + council_tax_reduction: 2_000 + +- name: Non-dependant deductions still apply to a guarantee credit recipient + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + state_pension: 9_000 + son: + age: 40 + employment_income: 5_000 + benunits: + benunit: + members: [pensioner] + claims_all_entitled_benefits: true + son_benunit: + members: [son] + would_claim_uc: false + households: + household: + members: [pensioner, son] + country: ENGLAND + local_authority: MAIDSTONE + tenure_type: OWNED_OUTRIGHT + council_tax: 2_000 + savings: 0 + output: + # The son's guarantee_credit is calculated as 7,376, but he is not + # eligible for Pension Credit, so he is not in receipt of it. + guarantee_credit: [3_376, 7_376] + in_receipt_of_guarantee_credit: [true, false] + council_tax_reduction_applicable_income: [0, 5_000] + council_tax_reduction_non_dep_deductions: [1_060.80, 0] + # 2,000 - 1,060.80 + simulated_council_tax_reduction_benunit: [939.20, 0] + council_tax_reduction: 939.20 + +- name: Mixed-age couple is not in receipt of guarantee credit, so the capital limit applies + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + state_pension: 9_000 + partner: + age: 60 + benunits: + benunit: + members: [claimant, partner] + claims_all_entitled_benefits: true + would_claim_uc: false + households: + household: + members: [claimant, partner] + country: ENGLAND + local_authority: MAIDSTONE + tenure_type: OWNED_OUTRIGHT + council_tax: 2_000 + savings: 20_000 + output: + guarantee_credit: 8_849 + is_pension_credit_eligible: false + in_receipt_of_guarantee_credit: false + council_tax_reduction: 0 + +- name: Wales guarantee credit recipient gets maximum CTR above the capital limit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + state_pension: 9_000 + benunits: + benunit: + members: [pensioner] + claims_all_entitled_benefits: true + households: + household: + members: [pensioner] + country: WALES + local_authority: CARDIFF + tenure_type: OWNED_OUTRIGHT + council_tax: 2_000 + savings: 20_000 + output: + guarantee_credit: 2_336 + council_tax_reduction: 2_000 + +- name: Wales guarantee credit recipient with income above the applicable amount gets maximum CTR + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + state_pension: 18_712 + benunits: + benunit: + members: [pensioner] + claims_all_entitled_benefits: true + minimum_guarantee: 18_500 + households: + household: + members: [pensioner] + country: WALES + local_authority: CARDIFF + tenure_type: OWNED_OUTRIGHT + council_tax: 2_000 + savings: 0 + output: + guarantee_credit: 1_016.40 + council_tax_reduction_applicable_income: 0 + council_tax_reduction: 2_000 + +- name: Wales working-age earner with a calculated guarantee credit keeps the taper + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 35 + employment_income: 10_000 + benunits: + benunit: + members: [claimant] + claims_all_entitled_benefits: true + would_claim_uc: false + households: + household: + members: [claimant] + country: WALES + local_authority: CARDIFF + tenure_type: OWNED_OUTRIGHT + council_tax: 2_000 + savings: 0 + output: + guarantee_credit: 2_376 + in_receipt_of_guarantee_credit: false + council_tax_reduction_applicable_income: 10_000 + council_tax_reduction: 993.72 + +- name: Scotland guarantee credit recipient gets maximum CTR above the capital limit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + state_pension: 9_000 + benunits: + benunit: + members: [pensioner] + claims_all_entitled_benefits: true + households: + household: + members: [pensioner] + country: SCOTLAND + local_authority: GLASGOW_CITY + tenure_type: OWNED_OUTRIGHT + council_tax: 2_000 + savings: 20_000 + output: + guarantee_credit: 2_336 + council_tax_reduction: 2_000 + +- name: Scotland guarantee credit recipient with income above the applicable amount gets maximum CTR + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + state_pension: 18_712 + benunits: + benunit: + members: [pensioner] + claims_all_entitled_benefits: true + minimum_guarantee: 18_500 + households: + household: + members: [pensioner] + country: SCOTLAND + local_authority: GLASGOW_CITY + tenure_type: OWNED_OUTRIGHT + council_tax: 2_000 + savings: 0 + output: + guarantee_credit: 976.73 + council_tax_reduction_applicable_income: 0 + council_tax_reduction: 2_000 + +- name: Savings credit only counts the Pension Credit assessment of income plus the savings credit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 90 + state_pension: 12_000 + private_pension_income: 1_500 + 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: + guarantee_credit: 0 + savings_credit: 558.62 + in_receipt_of_savings_credit_only: true + pension_credit_income: 13_314 + # 13,314 + 558.62. Before: 13,314 (the savings credit was not counted). + council_tax_reduction_applicable_income: 13_872.62 + # 2,000 - 0.2 x (13,872.62 - 13,312). Before: 1,999.60. + council_tax_reduction: 1_887.88 + +- name: Savings credit only tests the Pension Credit assessment of capital against the limit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 90 + state_pension: 12_000 + private_pension_income: 1_500 + 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: 10_000 + other_residential_property_value: 7_000 + output: + savings_credit: 267.42 + in_receipt_of_savings_credit_only: true + # Pension Credit counts the second property as capital; household + # savings alone (10,000) are under the limit. Before: CTR 1,999.60. + pension_credit_assessable_capital: 17_000 + council_tax_reduction_assessable_capital: 17_000 + council_tax_reduction: 0 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 new file mode 100644 index 0000000000..f5157d01e3 --- /dev/null +++ b/policyengine_uk/tests/test_council_tax_reduction_pension_credit_properties.py @@ -0,0 +1,348 @@ +"""Property-based tests for Pension Credit recipients under the national +pensioner council tax reduction schemes (England pensioners, Wales, Scotland). + +Law: +- Guarantee credit: "the whole of his capital and income must be disregarded" + (SI 2012/2885 Sch 1 para 13; WSI 2013/3029 Sch 1 para 7; SSI 2012/319 reg + 24). Capital for the 16,000 limit is calculated under the same Part (SI + 2012/2885 reg 11(3); WSI 2013/3029 reg 30(2)), so the limit cannot apply. + The award is the maximum reduction less non-dependant deductions. +- Savings credit only: the Secretary of State's assessment of income and + capital is used, adjusted for the savings credit payable (SI 2012/2885 Sch 1 + para 14; WSI 2013/3029 Sch 1 para 8; SSI 2012/319 reg 25). + +Invariants, for any generated population of families, where L is the eligible +liability, ND the non-dependant deductions and A the applicable amount: + +1. Structural: 0 <= CTR <= L; receipt of guarantee credit and receipt of + savings credit only are mutually exclusive, and each requires Pension Credit + eligibility and take-up. +2. Guarantee credit recipients get exactly max(0, L - ND). +3. Savings-credit-only recipients get max(0, L - 0.2 x max(0, PC income + SC - + A) - ND) when the Pension Credit assessment of capital is within 16,000, and + nothing otherwise. +4. Differential: everyone else keeps the previous formula, max(0, L - 0.2 x + max(0, CTR income - A) - ND) when household savings are within 16,000. +5. Metamorphic: a guarantee credit recipient's CTR does not change when the + State Pension changes. +6. Metamorphic: nor when its savings change. +7. Metamorphic: not claiming Pension Credit never raises a guarantee credit + recipient's CTR. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +YEAR = 2026 +PROPERTY_SETTINGS = settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +# Local authorities without a modelled local scheme, so each country's +# national scheme (England: pensioners only) applies. +COUNTRIES = { + "ENGLAND": "MAIDSTONE", + "WALES": "CARDIFF", + "SCOTLAND": "GLASGOW_CITY", +} +# 67 and over is unambiguously over State Pension age in 2026-27. Savings +# credit needs State Pension age reached before April 2016, so half the draws +# are 80 or over. +PENSION_AGE = st.one_of(st.integers(67, 79), st.integers(80, 100)) +WORKING_AGE = st.integers(18, 60) +SHAPES = { + "single_pension": [PENSION_AGE], + "couple_pension": [PENSION_AGE, PENSION_AGE], + "mixed_age": [PENSION_AGE, WORKING_AGE], + "single_working": [WORKING_AGE], +} +# Half the State Pension draws fall around the minimum guarantee, where +# guarantee credit, savings credit and the CTR taper all meet. +state_pension = st.one_of( + st.floats(0, 25_000, allow_nan=False, allow_infinity=False), + st.floats(10_000, 16_000, allow_nan=False, allow_infinity=False), +) +CAPITAL_LIMIT = 16_000 +WITHDRAWAL_RATE = 0.2 + + +@st.composite +def families(draw): + shape = draw(st.sampled_from(sorted(SHAPES))) + pension_shape = shape != "single_working" + return dict( + shape=shape, + ages=[draw(age) for age in SHAPES[shape]], + country=draw(st.sampled_from(sorted(COUNTRIES))), + council_tax=draw(st.floats(500, 4_000)), + savings=draw(st.one_of(st.just(0.0), st.floats(0, 40_000))), + other_property=draw(st.one_of(st.just(0.0), st.floats(0, 10_000))), + state_pension=draw(state_pension), + private_pension=draw(st.one_of(st.just(0.0), st.floats(0, 5_000))), + employment_income=draw(st.one_of(st.just(0.0), st.floats(0, 20_000))), + would_claim_pc=draw(st.sampled_from([True, True, False])), + # A guarantee above the CTR applicable amount, as an earnings-linked + # guarantee path produces, so guarantee credit recipients can have + # income above the applicable amount. + minimum_guarantee=draw(st.one_of(st.none(), st.floats(12_000, 30_000))), + # A working-age non-dependant (a separate benefit unit) living with a + # pension-age family; younger than the claimant, so never the head. + non_dependant_income=( + draw(st.one_of(st.none(), st.floats(0, 15_000))) if pension_shape else None + ), + ) + + +@st.composite +def savings_credit_families(draw): + """Single pensioners who reached State Pension age before April 2016 with + income just above the minimum guarantee, the savings credit range.""" + return dict( + shape="single_pension", + ages=[draw(st.integers(80, 100))], + country=draw(st.sampled_from(sorted(COUNTRIES))), + council_tax=draw(st.floats(500, 4_000)), + savings=draw(st.one_of(st.just(0.0), st.floats(0, 25_000))), + other_property=draw(st.one_of(st.just(0.0), st.floats(0, 10_000))), + state_pension=draw(st.floats(12_400, 15_000)), + private_pension=draw(st.one_of(st.just(0.0), st.floats(0, 1_500))), + employment_income=0.0, + would_claim_pc=True, + minimum_guarantee=None, + non_dependant_income=draw(st.one_of(st.none(), st.floats(0, 15_000))), + ) + + +def situation( + units, + state_pension_change=0.0, + savings_change=0.0, + would_claim_pc=None, +): + people, benunits, households = {}, {}, {} + claimant_benunits = [] + benunit_index = 0 + for i, unit in enumerate(units): + names = [] + for j, age in enumerate(unit["ages"]): + name = f"p{i}_{j}" + person = {"age": {YEAR: age}} + if age >= 67: + person["state_pension"] = { + YEAR: max(0.0, unit["state_pension"] + state_pension_change) + } + person["private_pension_income"] = {YEAR: unit["private_pension"]} + else: + person["employment_income"] = {YEAR: unit["employment_income"]} + people[name] = person + names.append(name) + claim_pc = unit["would_claim_pc"] if would_claim_pc is None else would_claim_pc + benunit = { + "members": names, + # claims_all_entitled_benefits sums reported benefits across the + # whole simulation, so set it for every family. + "claims_all_entitled_benefits": {YEAR: True}, + "would_claim_pc": {YEAR: claim_pc}, + "would_claim_uc": {YEAR: False}, + } + if unit["minimum_guarantee"] is not None: + benunit["minimum_guarantee"] = {YEAR: unit["minimum_guarantee"]} + benunits[f"b{i}"] = benunit + claimant_benunits.append(benunit_index) + benunit_index += 1 + household_members = list(names) + if unit["non_dependant_income"] is not None: + name = f"n{i}" + people[name] = { + "age": {YEAR: 40}, + "employment_income": {YEAR: unit["non_dependant_income"]}, + } + benunits[f"n{i}"] = { + "members": [name], + "claims_all_entitled_benefits": {YEAR: True}, + "would_claim_uc": {YEAR: False}, + } + benunit_index += 1 + household_members.append(name) + households[f"h{i}"] = { + "members": household_members, + "country": {YEAR: unit["country"]}, + "local_authority": {YEAR: COUNTRIES[unit["country"]]}, + "tenure_type": {YEAR: "OWNED_OUTRIGHT"}, + "council_tax": {YEAR: unit["council_tax"]}, + "savings": {YEAR: max(0.0, unit["savings"] + savings_change)}, + "other_residential_property_value": {YEAR: unit["other_property"]}, + } + return ( + {"people": people, "benunits": benunits, "households": households}, + np.array(claimant_benunits), + ) + + +BENUNIT_VARIABLES = [ + "simulated_council_tax_reduction_benunit", + "council_tax_reduction_applicable_amount", + "council_tax_reduction_applicable_income", + "council_tax_reduction_non_dep_deductions", + "in_receipt_of_guarantee_credit", + "in_receipt_of_savings_credit_only", + "is_pension_credit_eligible", + "would_claim_pc", + "guarantee_credit", + "savings_credit", + "pension_credit_income", + "pension_credit_assessable_capital", +] +HOUSEHOLD_VARIABLES = [ + "council_tax_reduction_maximum_eligible_liability", + "council_tax_reduction_household_has_pensioner", + "savings", +] + + +def calculate(units, **kwargs): + data, claimants = situation(units, **kwargs) + sim = Simulation(situation=data) + values = { + v: np.asarray(sim.calculate(v, YEAR))[claimants] for v in BENUNIT_VARIABLES + } + # One household per family, in the same order as the claimant benefit units. + for v in HOUSEHOLD_VARIABLES: + values[v] = np.asarray(sim.calculate(v, YEAR)) + values["national"] = np.array( + [ + unit["country"] != "ENGLAND" or has_pensioner + for unit, has_pensioner in zip( + units, values["council_tax_reduction_household_has_pensioner"] + ) + ] + ) + return values + + +def tapered(liability, income, applicable_amount, non_dep): + excess = max(0.0, income - applicable_amount) + return max(0.0, liability - WITHDRAWAL_RATE * excess - non_dep) + + +def check_structural(values): + ctr = values["simulated_council_tax_reduction_benunit"] + liability = values["council_tax_reduction_maximum_eligible_liability"] + gc = values["in_receipt_of_guarantee_credit"].astype(bool) + sc_only = values["in_receipt_of_savings_credit_only"].astype(bool) + received = gc | sc_only + assert np.all(ctr >= 0) + assert np.all(ctr <= liability + 0.01) + assert not np.any(gc & sc_only) + assert np.all(values["is_pension_credit_eligible"][received]) + assert np.all(values["would_claim_pc"][received]) + assert np.all(values["guarantee_credit"][gc] > 0) + assert np.all(values["savings_credit"][sc_only] > 0) + + +@PROPERTY_SETTINGS +@given(st.lists(families(), min_size=20, max_size=40)) +def test_awards_follow_the_pension_credit_routes(units): + check_routes(units, calculate(units)) + + +@PROPERTY_SETTINGS +@given(st.lists(savings_credit_families(), min_size=10, max_size=20)) +def test_savings_credit_only_awards_use_the_pension_credit_assessment(units): + values = calculate(units) + assert values["in_receipt_of_savings_credit_only"].any() + check_routes(units, values) + + +def check_routes(units, values): + check_structural(values) + for i, unit in enumerate(units): + if not values["national"][i]: + continue + ctr = values["simulated_council_tax_reduction_benunit"][i] + liability = values["council_tax_reduction_maximum_eligible_liability"][i] + non_dep = values["council_tax_reduction_non_dep_deductions"][i] + applicable_amount = values["council_tax_reduction_applicable_amount"][i] + if values["in_receipt_of_guarantee_credit"][i]: + expected = max(0.0, liability - non_dep) + assert values["council_tax_reduction_applicable_income"][i] == 0, unit + elif values["in_receipt_of_savings_credit_only"][i]: + income = values["pension_credit_income"][i] + values["savings_credit"][i] + assert ( + abs(values["council_tax_reduction_applicable_income"][i] - income) + < 0.01 + ), unit + expected = tapered(liability, income, applicable_amount, non_dep) * ( + values["pension_credit_assessable_capital"][i] <= CAPITAL_LIMIT + ) + else: + expected = tapered( + liability, + values["council_tax_reduction_applicable_income"][i], + applicable_amount, + non_dep, + ) * (values["savings"][i] <= CAPITAL_LIMIT) + assert abs(ctr - expected) < 0.01, (unit, ctr, expected) + + +@PROPERTY_SETTINGS +@given( + st.lists(families(), min_size=20, max_size=40), + st.floats(-3_000, 3_000, allow_nan=False, allow_infinity=False), +) +def test_guarantee_credit_recipients_ctr_is_invariant_to_state_pension(units, change): + before = calculate(units) + after = calculate(units, state_pension_change=change) + for i, unit in enumerate(units): + if ( + before["in_receipt_of_guarantee_credit"][i] + and after["in_receipt_of_guarantee_credit"][i] + ): + assert ( + abs( + before["simulated_council_tax_reduction_benunit"][i] + - after["simulated_council_tax_reduction_benunit"][i] + ) + < 0.01 + ), (unit, change) + + +@PROPERTY_SETTINGS +@given( + st.lists(families(), min_size=20, max_size=40), + st.floats(0, 30_000, allow_nan=False, allow_infinity=False), +) +def test_guarantee_credit_recipients_ctr_is_invariant_to_savings(units, change): + before = calculate(units) + after = calculate(units, savings_change=change) + for i, unit in enumerate(units): + if ( + before["in_receipt_of_guarantee_credit"][i] + and after["in_receipt_of_guarantee_credit"][i] + ): + assert ( + abs( + before["simulated_council_tax_reduction_benunit"][i] + - after["simulated_council_tax_reduction_benunit"][i] + ) + < 0.01 + ), (unit, change) + + +@PROPERTY_SETTINGS +@given(st.lists(families(), min_size=20, max_size=40)) +def test_not_claiming_pension_credit_never_raises_a_recipients_ctr(units): + claiming = calculate(units, would_claim_pc=True) + not_claiming = calculate(units, would_claim_pc=False) + assert not np.any(not_claiming["in_receipt_of_guarantee_credit"]) + for i, unit in enumerate(units): + if claiming["in_receipt_of_guarantee_credit"][i]: + assert ( + not_claiming["simulated_council_tax_reduction_benunit"][i] + <= claiming["simulated_council_tax_reduction_benunit"][i] + 0.01 + ), unit diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/in_receipt_of_guarantee_credit.py b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/in_receipt_of_guarantee_credit.py new file mode 100644 index 0000000000..69b5a7e090 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/in_receipt_of_guarantee_credit.py @@ -0,0 +1,33 @@ +from policyengine_uk.model_api import * + + +class in_receipt_of_guarantee_credit(Variable): + label = "in receipt of Guarantee Credit" + documentation = ( + "Whether the claimant or partner in this benefit unit is in receipt of " + "a guarantee credit: the benefit unit is eligible for Pension Credit, " + "claims it, and its Guarantee Credit is positive. A Guarantee Credit " + "computed for a family that does not claim Pension Credit, or is not " + "eligible for it (for example a working-age or mixed-age family), is " + "not received. Under the Pension Credit freeze the baseline receipt is " + "kept, because the frozen award is the baseline award." + ) + entity = BenUnit + definition_period = YEAR + value_type = bool + reference = ( + "https://www.legislation.gov.uk/ukpga/2002/16/section/1", + "https://www.legislation.gov.uk/ukpga/2002/16/section/2", + ) + + def formula(benunit, period, parameters): + freeze = parameters(period).gov.contrib.freeze_pension_credit + baseline = benunit.simulation.baseline + if freeze and baseline is not None: + return baseline.populations["benunit"]( + "in_receipt_of_guarantee_credit", period + ) + eligible = benunit("is_pension_credit_eligible", period) + would_claim = benunit("would_claim_pc", period) + guarantee_credit = benunit("guarantee_credit", period) + return eligible & would_claim & (guarantee_credit > 0) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/savings_credit/in_receipt_of_savings_credit_only.py b/policyengine_uk/variables/gov/dwp/pension_credit/savings_credit/in_receipt_of_savings_credit_only.py new file mode 100644 index 0000000000..6f178fca7b --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/pension_credit/savings_credit/in_receipt_of_savings_credit_only.py @@ -0,0 +1,33 @@ +from policyengine_uk.model_api import * + + +class in_receipt_of_savings_credit_only(Variable): + label = "in receipt of Savings Credit only" + documentation = ( + "Whether the claimant or partner in this benefit unit has an award of " + "Pension Credit comprising only the savings credit: the benefit unit is " + "eligible for Pension Credit, claims it, its Savings Credit is positive " + "and it has no Guarantee Credit. Under the Pension Credit freeze the " + "baseline receipt is kept, because the frozen award is the baseline " + "award." + ) + entity = BenUnit + definition_period = YEAR + value_type = bool + reference = ( + "https://www.legislation.gov.uk/ukpga/2002/16/section/1", + "https://www.legislation.gov.uk/ukpga/2002/16/section/3", + ) + + def formula(benunit, period, parameters): + freeze = parameters(period).gov.contrib.freeze_pension_credit + baseline = benunit.simulation.baseline + if freeze and baseline is not None: + return baseline.populations["benunit"]( + "in_receipt_of_savings_credit_only", period + ) + eligible = benunit("is_pension_credit_eligible", period) + would_claim = benunit("would_claim_pc", period) + guarantee_credit = benunit("guarantee_credit", period) + savings_credit = benunit("savings_credit", period) + return eligible & would_claim & (guarantee_credit <= 0) & (savings_credit > 0) 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 8cf5f764b8..b0d58a47b7 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 @@ -5,8 +5,25 @@ class council_tax_reduction_applicable_income(Variable): value_type = float entity = BenUnit label = "relevant income for Council Tax Reduction means test" + documentation = ( + "Income taken into account in the Council Tax Reduction means test. The " + "pensioner schemes disregard the whole income of an applicant who, or " + "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." + ) definition_period = YEAR unit = GBP + reference = [ + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/13", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/14", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/7", + "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", + ] def formula(benunit, period, parameters): benunit_means_tested_benefits = [ @@ -44,4 +61,20 @@ def formula(benunit, period, parameters): pension_contributions = add(benunit, period, ["pension_contributions"]) * 0.5 tax = add(benunit, period, ["income_tax", "national_insurance"]) - return max_(0, increased_income - tax - pension_contributions) + income = max_(0, increased_income - tax - pension_contributions) + + # SI 2012/2885 Sch 1 para 13, WSI 2013/3029 Sch 1 para 7 and SSI + # 2012/319 reg 24: a guarantee credit recipient's whole income is + # disregarded. Para 14, para 8 and reg 25: in savings-credit-only cases + # the Secretary of State's assessment of net income is used, adjusted + # to take account of the savings credit payable. + guarantee_credit = benunit("in_receipt_of_guarantee_credit", period) + savings_credit_only = benunit("in_receipt_of_savings_credit_only", period) + pension_credit_assessed_income = benunit( + "pension_credit_income", period + ) + benunit("savings_credit", period) + return select( + [guarantee_credit, savings_credit_only], + [0, pension_credit_assessed_income], + default=income, + ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_assessable_capital.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_assessable_capital.py new file mode 100644 index 0000000000..829615ae01 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_assessable_capital.py @@ -0,0 +1,38 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_assessable_capital(Variable): + value_type = float + entity = BenUnit + label = "capital for the Council Tax Reduction capital limit" + documentation = ( + "Capital tested against the Council Tax Reduction capital limit. The " + "pensioner schemes disregard the whole capital of an applicant who, or " + "whose partner, is in receipt of Pension Credit guarantee credit, and " + "use the Secretary of State's Pension Credit assessment of capital where " + "the award is savings credit only. Otherwise household savings stand in " + "for the applicant's capital. The recalculation when capital rises above " + "the limit during an assessed income period is not modelled." + ) + definition_period = YEAR + unit = GBP + quantity_type = STOCK + reference = [ + "https://www.legislation.gov.uk/uksi/2012/2885/regulation/11", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/13", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/14", + "https://www.legislation.gov.uk/wsi/2013/3029/regulation/30", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/7", + "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", + ] + + def formula(benunit, period, parameters): + guarantee_credit = benunit("in_receipt_of_guarantee_credit", period) + savings_credit_only = benunit("in_receipt_of_savings_credit_only", period) + return select( + [guarantee_credit, savings_credit_only], + [0, benunit("pension_credit_assessable_capital", period)], + default=benunit.household("savings", period), + ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py index ce3eeb7ee9..8eb33274c5 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py @@ -82,7 +82,8 @@ def formula(benunit, period, parameters): - excess_income * withdrawal_rate - non_dep_deductions, ) - capital_eligible = benunit.household("savings", period) <= capital_limit + capital = benunit("council_tax_reduction_assessable_capital", period) + capital_eligible = capital <= capital_limit national_ctr = ( national_scheme * is_household_head_benunit From 8e32d3ff9ff667d8e641b0d7e98654fbd8ac149a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 08:58:51 -0400 Subject: [PATCH 2/5] Address review: frozen savings credit, independent differential, test harness - Savings-credit-only CTR income adds the Pension Credit actually paid (the savings credit payable), so under the Pension Credit freeze it counts the frozen amount, consistent with the receipt flags. Test added. - The property differential recomputes main's CTR income definition from its components instead of reading the variable under test. - Apply the raised minimum guarantee as a population-wide reform: a minimum_guarantee input on some benefit units set it to zero on every other one, which hollowed out guarantee credit coverage. - Metamorphic tests always include guarantee credit recipients and assert they are present; the take-up test also checks non-claimants against the differential. - Pin that a non-claimant with the same assets is tested on household savings only (a proxy gap, documented); note the non-dependant test pins the deduction's presence, not its Housing Benefit-rate size. - Legal local names; changelog says savings-credit-only CTR falls. Co-Authored-By: Claude Opus 5.5 --- .../ctr-pension-credit-disregard.fixed.md | 2 +- .../council_tax_reduction_pension_credit.yaml | 37 ++++ ...tax_reduction_pension_credit_properties.py | 207 +++++++++++++++--- ...council_tax_reduction_applicable_income.py | 28 ++- ...ouncil_tax_reduction_assessable_capital.py | 16 +- 5 files changed, 242 insertions(+), 48 deletions(-) diff --git a/changelog.d/ctr-pension-credit-disregard.fixed.md b/changelog.d/ctr-pension-credit-disregard.fixed.md index 0ee4e06023..d1285ca1bb 100644 --- a/changelog.d/ctr-pension-credit-disregard.fixed.md +++ b/changelog.d/ctr-pension-credit-disregard.fixed.md @@ -1 +1 @@ -Disregard the whole income and capital of Pension Credit guarantee credit recipients in the England pensioner, Wales and Scotland council tax reduction schemes, so they get the maximum reduction less non-dependant deductions whatever their income or savings, and use the Pension Credit assessment of income (plus the savings credit) and capital for savings-credit-only recipients (SI 2012/2885 Sch 1 paras 13-14; WSI 2013/3029 Sch 1 paras 7-8; SSI 2012/319 regs 24-25). +Disregard the whole income and capital of Pension Credit guarantee credit recipients in the England pensioner, Wales and Scotland council tax reduction schemes, so they get the maximum reduction less non-dependant deductions whatever their income or savings (SI 2012/2885 Sch 1 para 13; WSI 2013/3029 Sch 1 para 7; SSI 2012/319 reg 24). For savings-credit-only recipients, use the Pension Credit assessment of income plus the savings credit paid, and of capital (para 14; para 8; reg 25): their reduction now falls, because the savings credit counts as income and the capital limit applies to all the capital Pension Credit counts, not only savings. diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_credit.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_credit.yaml index a7271143cf..d3abc115fa 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_credit.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_credit.yaml @@ -244,6 +244,10 @@ guarantee_credit: [3_376, 7_376] in_receipt_of_guarantee_credit: [true, false] council_tax_reduction_applicable_income: [0, 5_000] + # The national schemes' non-dependant deduction currently uses Housing + # Benefit amounts (20.40 a week here), not the CTR schedules' own amounts + # and exemptions (SI 2012/2885 Sch 1 para 8); this pins that the deduction + # still applies on guarantee credit, not its size. council_tax_reduction_non_dep_deductions: [1_060.80, 0] # 2,000 - 1,060.80 simulated_council_tax_reduction_benunit: [939.20, 0] @@ -466,3 +470,36 @@ pension_credit_assessable_capital: 17_000 council_tax_reduction_assessable_capital: 17_000 council_tax_reduction: 0 + +- name: The same pensioner not claiming Pension Credit is tested on household savings only + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 90 + state_pension: 12_000 + private_pension_income: 1_500 + benunits: + benunit: + members: [pensioner] + claims_all_entitled_benefits: true + would_claim_pc: false + households: + household: + members: [pensioner] + country: ENGLAND + local_authority: MAIDSTONE + tenure_type: OWNED_OUTRIGHT + council_tax: 2_000 + savings: 10_000 + other_residential_property_value: 7_000 + output: + in_receipt_of_savings_credit_only: false + # Pensioners without Pension Credit are tested on household savings, a + # proxy that leaves out the second property the schemes' own capital rules + # count (SI 2012/2885 Sch 1 Part 6 Chapter 3). So, unlike the claimant + # above, this pensioner keeps CTR: the gap is in the proxy, not the law. + council_tax_reduction_assessable_capital: 10_000 + council_tax_reduction_applicable_income: 13_314 + council_tax_reduction: 1_999.60 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 f5157d01e3..51207b47f9 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 @@ -22,12 +22,16 @@ A) - ND) when the Pension Credit assessment of capital is within 16,000, and nothing otherwise. 4. Differential: everyone else keeps the previous formula, max(0, L - 0.2 x - max(0, CTR income - A) - ND) when household savings are within 16,000. + 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. 5. Metamorphic: a guarantee credit recipient's CTR does not change when the State Pension changes. 6. Metamorphic: nor when its savings change. 7. Metamorphic: not claiming Pension Credit never raises a guarantee credit - recipient's CTR. + recipient's CTR, and a non-claimant is assessed under invariant 4. +8. Under the Pension Credit freeze, a savings-credit-only award counts the + frozen savings credit actually paid. """ import numpy as np @@ -67,6 +71,13 @@ st.floats(0, 25_000, allow_nan=False, allow_infinity=False), st.floats(10_000, 16_000, allow_nan=False, allow_infinity=False), ) +# Half the populations get a standard minimum guarantee above the CTR +# applicable amount (256.00 single, 383.35 couple a week), as an +# earnings-linked guarantee path produces, so guarantee credit recipients can +# have income above the applicable amount. Applied as a reform to the whole +# population: a minimum_guarantee input on some benefit units would set it to +# zero on every other one. +guarantees = st.one_of(st.none(), st.floats(240, 450)) CAPITAL_LIMIT = 16_000 WITHDRAWAL_RATE = 0.2 @@ -86,10 +97,6 @@ def families(draw): private_pension=draw(st.one_of(st.just(0.0), st.floats(0, 5_000))), employment_income=draw(st.one_of(st.just(0.0), st.floats(0, 20_000))), would_claim_pc=draw(st.sampled_from([True, True, False])), - # A guarantee above the CTR applicable amount, as an earnings-linked - # guarantee path produces, so guarantee credit recipients can have - # income above the applicable amount. - minimum_guarantee=draw(st.one_of(st.none(), st.floats(12_000, 30_000))), # A working-age non-dependant (a separate benefit unit) living with a # pension-age family; younger than the claimant, so never the head. non_dependant_income=( @@ -113,11 +120,40 @@ def savings_credit_families(draw): private_pension=draw(st.one_of(st.just(0.0), st.floats(0, 1_500))), employment_income=0.0, would_claim_pc=True, - minimum_guarantee=None, non_dependant_income=draw(st.one_of(st.none(), st.floats(0, 15_000))), ) +@st.composite +def guarantee_credit_families(draw): + """Pensioners claiming Pension Credit with income low enough to keep a + guarantee credit under the State Pension and savings changes the + metamorphic tests make.""" + shape = draw(st.sampled_from(["single_pension", "couple_pension"])) + return dict( + shape=shape, + ages=[draw(PENSION_AGE) for _ in SHAPES[shape]], + country=draw(st.sampled_from(sorted(COUNTRIES))), + council_tax=draw(st.floats(500, 4_000)), + savings=draw(st.one_of(st.just(0.0), st.floats(0, 40_000))), + other_property=0.0, + state_pension=draw(st.floats(0, 5_000)), + private_pension=0.0, + employment_income=0.0, + would_claim_pc=True, + non_dependant_income=draw(st.one_of(st.none(), st.floats(0, 15_000))), + ) + + +# Random families, plus a few that are sure to receive guarantee credit, so +# the metamorphic tests always have recipients to check. +populations = st.builds( + lambda random, recipients: random + recipients, + st.lists(families(), min_size=15, max_size=30), + st.lists(guarantee_credit_families(), min_size=3, max_size=6), +) + + def situation( units, state_pension_change=0.0, @@ -150,8 +186,6 @@ def situation( "would_claim_pc": {YEAR: claim_pc}, "would_claim_uc": {YEAR: False}, } - if unit["minimum_guarantee"] is not None: - benunit["minimum_guarantee"] = {YEAR: unit["minimum_guarantee"]} benunits[f"b{i}"] = benunit claimant_benunits.append(benunit_index) benunit_index += 1 @@ -198,6 +232,30 @@ def situation( "pension_credit_income", "pension_credit_assessable_capital", ] +# 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. +MAIN_INCOME_COMPONENTS = [ + "employment_income", + "self_employment_income", + "property_income", + "private_pension_income", + "carers_allowance", + "esa_contrib", + "jsa_contrib", + "state_pension", + "maternity_allowance", + "statutory_sick_pay", + "statutory_maternity_pay", + "ssmg", + "tax_credits", + "child_benefit", + "income_support", + "jsa_income", + "esa_income", + "universal_credit", +] +MAIN_DEDUCTIONS = ["income_tax", "national_insurance"] HOUSEHOLD_VARIABLES = [ "council_tax_reduction_maximum_eligible_liability", "council_tax_reduction_household_has_pensioner", @@ -205,15 +263,39 @@ def situation( ] -def calculate(units, **kwargs): +def guarantee_reform(single_weekly): + if single_weekly is None: + return None + period = f"{YEAR}-01-01.{YEAR}-12-31" + minimum_guarantee = "gov.dwp.pension_credit.guarantee_credit.minimum_guarantee" + return { + f"{minimum_guarantee}.SINGLE": {period: single_weekly}, + f"{minimum_guarantee}.COUPLE": {period: single_weekly * 1.53}, + } + + +def calculate(units, guarantee=None, **kwargs): data, claimants = situation(units, **kwargs) - sim = Simulation(situation=data) + sim = Simulation(situation=data, reform=guarantee_reform(guarantee)) values = { v: np.asarray(sim.calculate(v, YEAR))[claimants] for v in BENUNIT_VARIABLES } # One household per family, in the same order as the claimant benefit units. for v in HOUSEHOLD_VARIABLES: values[v] = np.asarray(sim.calculate(v, YEAR)) + + def benunit_total(names): + return sum( + np.asarray(sim.calculate(v, YEAR, map_to="benunit"))[claimants] + for v in names + ) + + values["income_under_main_definition"] = np.maximum( + 0, + benunit_total(MAIN_INCOME_COMPONENTS) + - benunit_total(MAIN_DEDUCTIONS) + - 0.5 * benunit_total(["pension_contributions"]), + ) values["national"] = np.array( [ unit["country"] != "ENGLAND" or has_pensioner @@ -246,9 +328,9 @@ def check_structural(values): @PROPERTY_SETTINGS -@given(st.lists(families(), min_size=20, max_size=40)) -def test_awards_follow_the_pension_credit_routes(units): - check_routes(units, calculate(units)) +@given(st.lists(families(), min_size=20, max_size=40), guarantees) +def test_awards_follow_the_pension_credit_routes(units, guarantee): + check_routes(units, calculate(units, guarantee)) @PROPERTY_SETTINGS @@ -281,23 +363,29 @@ def check_routes(units, values): values["pension_credit_assessable_capital"][i] <= CAPITAL_LIMIT ) else: - expected = tapered( - liability, - values["council_tax_reduction_applicable_income"][i], - applicable_amount, - non_dep, - ) * (values["savings"][i] <= CAPITAL_LIMIT) + income = values["income_under_main_definition"][i] + assert ( + abs(values["council_tax_reduction_applicable_income"][i] - income) + < 0.01 + ), unit + expected = tapered(liability, income, applicable_amount, non_dep) * ( + values["savings"][i] <= CAPITAL_LIMIT + ) assert abs(ctr - expected) < 0.01, (unit, ctr, expected) @PROPERTY_SETTINGS @given( - st.lists(families(), min_size=20, max_size=40), + populations, + guarantees, st.floats(-3_000, 3_000, allow_nan=False, allow_infinity=False), ) -def test_guarantee_credit_recipients_ctr_is_invariant_to_state_pension(units, change): - before = calculate(units) - after = calculate(units, state_pension_change=change) +def test_guarantee_credit_recipients_ctr_is_invariant_to_state_pension( + units, guarantee, change +): + before = calculate(units, guarantee) + after = calculate(units, guarantee, state_pension_change=change) + assert before["in_receipt_of_guarantee_credit"].any() for i, unit in enumerate(units): if ( before["in_receipt_of_guarantee_credit"][i] @@ -314,12 +402,16 @@ def test_guarantee_credit_recipients_ctr_is_invariant_to_state_pension(units, ch @PROPERTY_SETTINGS @given( - st.lists(families(), min_size=20, max_size=40), + populations, + guarantees, st.floats(0, 30_000, allow_nan=False, allow_infinity=False), ) -def test_guarantee_credit_recipients_ctr_is_invariant_to_savings(units, change): - before = calculate(units) - after = calculate(units, savings_change=change) +def test_guarantee_credit_recipients_ctr_is_invariant_to_savings( + units, guarantee, change +): + before = calculate(units, guarantee) + after = calculate(units, guarantee, savings_change=change) + assert before["in_receipt_of_guarantee_credit"].any() for i, unit in enumerate(units): if ( before["in_receipt_of_guarantee_credit"][i] @@ -335,14 +427,65 @@ def test_guarantee_credit_recipients_ctr_is_invariant_to_savings(units, change): @PROPERTY_SETTINGS -@given(st.lists(families(), min_size=20, max_size=40)) -def test_not_claiming_pension_credit_never_raises_a_recipients_ctr(units): - claiming = calculate(units, would_claim_pc=True) - not_claiming = calculate(units, would_claim_pc=False) +@given(populations, guarantees) +def test_not_claiming_pension_credit_never_raises_a_recipients_ctr(units, guarantee): + claiming = calculate(units, guarantee, would_claim_pc=True) + not_claiming = calculate(units, guarantee, would_claim_pc=False) + assert claiming["in_receipt_of_guarantee_credit"].any() assert not np.any(not_claiming["in_receipt_of_guarantee_credit"]) + check_routes(units, not_claiming) for i, unit in enumerate(units): if claiming["in_receipt_of_guarantee_credit"][i]: assert ( not_claiming["simulated_council_tax_reduction_benunit"][i] <= claiming["simulated_council_tax_reduction_benunit"][i] + 0.01 ), unit + + +def test_savings_credit_only_award_counts_the_frozen_savings_credit(): + # A savings credit rate rise raises the reform's savings credit from + # 558.62 to 869.89, but the freeze keeps paying the baseline 558.62, and + # that is the savings credit payable. + person = { + "age": {YEAR: 90}, + "state_pension": {YEAR: 12_000}, + "private_pension_income": {YEAR: 1_500}, + } + data = { + "people": {"pensioner": person}, + "benunits": { + "benunit": { + "members": ["pensioner"], + "claims_all_entitled_benefits": {YEAR: True}, + } + }, + "households": { + "household": { + "members": ["pensioner"], + "country": {YEAR: "ENGLAND"}, + "local_authority": {YEAR: "MAIDSTONE"}, + "tenure_type": {YEAR: "OWNED_OUTRIGHT"}, + "council_tax": {YEAR: 2_000}, + "savings": {YEAR: 0}, + } + }, + } + period = f"{YEAR}-01-01.{YEAR}-12-31" + rate_rise = {"gov.dwp.pension_credit.savings_credit.rate.phase_in": {period: 0.8}} + frozen = Simulation( + situation=data, + reform={**rate_rise, "gov.contrib.freeze_pension_credit": {period: True}}, + ) + unfrozen = Simulation(situation=data, reform=rate_rise) + assert abs(frozen.calculate("savings_credit", YEAR)[0] - 869.89) < 0.01 + assert abs(frozen.calculate("pension_credit", YEAR)[0] - 558.62) < 0.01 + assert frozen.calculate("in_receipt_of_savings_credit_only", YEAR)[0] + # 13,314 Pension Credit income + 558.62 paid; 2,000 - 0.2 x 560.62. + frozen_income = frozen.calculate("council_tax_reduction_applicable_income", YEAR) + assert abs(frozen_income[0] - 13_872.62) < 0.01 + assert abs(frozen.calculate("council_tax_reduction", YEAR)[0] - 1_887.88) < 0.01 + # Without the freeze the higher savings credit is paid and counted. + unfrozen_income = unfrozen.calculate( + "council_tax_reduction_applicable_income", YEAR + ) + assert abs(unfrozen_income[0] - 14_183.89) < 0.01 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 b0d58a47b7..ea624d139d 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 @@ -61,20 +61,28 @@ def formula(benunit, period, parameters): pension_contributions = add(benunit, period, ["pension_contributions"]) * 0.5 tax = add(benunit, period, ["income_tax", "national_insurance"]) - income = max_(0, increased_income - tax - pension_contributions) + income_under_general_rules = max_( + 0, increased_income - tax - pension_contributions + ) # SI 2012/2885 Sch 1 para 13, WSI 2013/3029 Sch 1 para 7 and SSI # 2012/319 reg 24: a guarantee credit recipient's whole income is # disregarded. Para 14, para 8 and reg 25: in savings-credit-only cases # the Secretary of State's assessment of net income is used, adjusted - # to take account of the savings credit payable. - guarantee_credit = benunit("in_receipt_of_guarantee_credit", period) - savings_credit_only = benunit("in_receipt_of_savings_credit_only", period) - pension_credit_assessed_income = benunit( - "pension_credit_income", period - ) + benunit("savings_credit", period) + # to take account of the savings credit payable. The Pension Credit + # paid on a savings-credit-only award is that savings credit (under the + # Pension Credit freeze, the frozen amount). + in_receipt_of_guarantee_credit = benunit( + "in_receipt_of_guarantee_credit", period + ) + has_savings_credit_only_award = benunit( + "in_receipt_of_savings_credit_only", period + ) + savings_credit_only_income = benunit("pension_credit_income", period) + benunit( + "pension_credit", period + ) return select( - [guarantee_credit, savings_credit_only], - [0, pension_credit_assessed_income], - default=income, + [in_receipt_of_guarantee_credit, has_savings_credit_only_award], + [0, savings_credit_only_income], + default=income_under_general_rules, ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_assessable_capital.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_assessable_capital.py index 829615ae01..16b63ab2a9 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_assessable_capital.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_assessable_capital.py @@ -11,8 +11,10 @@ class council_tax_reduction_assessable_capital(Variable): "whose partner, is in receipt of Pension Credit guarantee credit, and " "use the Secretary of State's Pension Credit assessment of capital where " "the award is savings credit only. Otherwise household savings stand in " - "for the applicant's capital. The recalculation when capital rises above " - "the limit during an assessed income period is not modelled." + "for the applicant's capital; the schemes' own capital rules also count " + "other property and investments, so this understates capital for " + "pensioners without Pension Credit. The recalculation when capital rises " + "above the limit during an assessed income period is not modelled." ) definition_period = YEAR unit = GBP @@ -29,10 +31,14 @@ class council_tax_reduction_assessable_capital(Variable): ] def formula(benunit, period, parameters): - guarantee_credit = benunit("in_receipt_of_guarantee_credit", period) - savings_credit_only = benunit("in_receipt_of_savings_credit_only", period) + in_receipt_of_guarantee_credit = benunit( + "in_receipt_of_guarantee_credit", period + ) + has_savings_credit_only_award = benunit( + "in_receipt_of_savings_credit_only", period + ) return select( - [guarantee_credit, savings_credit_only], + [in_receipt_of_guarantee_credit, has_savings_credit_only_award], [0, benunit("pension_credit_assessable_capital", period)], default=benunit.household("savings", period), ) From be5317610f1cd4adf6bedc9d95fce1a0d5a72ee7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 11:15:57 -0400 Subject: [PATCH 3/5] Pin the capital limit boundary, freeze flips and main's other income Round 2 review follow-ups (all tests, no model change): - YAML cases at exactly 16,000 and 16,000.01 of capital for a pensioner without Pension Credit and for a savings-credit-only recipient; the limit excludes capital that exceeds 16,000. - Freeze tests where the reform alone would change which credit is received (savings credit only to guarantee credit, and guarantee credit to none); receipt follows the frozen award. - The generator adds children, UC take-up, self-employment, property income and Carer's Allowance, so the differential against main's income definition covers Child Benefit, UC and the other components. - The changelog says savings-credit-only CTR can fall. Co-Authored-By: Claude Opus 5.5 --- .../ctr-pension-credit-disregard.fixed.md | 2 +- .../council_tax_reduction_pension_credit.yaml | 105 ++++++++++++++++++ ...tax_reduction_pension_credit_properties.py | 94 +++++++++++++++- 3 files changed, 197 insertions(+), 4 deletions(-) diff --git a/changelog.d/ctr-pension-credit-disregard.fixed.md b/changelog.d/ctr-pension-credit-disregard.fixed.md index d1285ca1bb..790fa13606 100644 --- a/changelog.d/ctr-pension-credit-disregard.fixed.md +++ b/changelog.d/ctr-pension-credit-disregard.fixed.md @@ -1 +1 @@ -Disregard the whole income and capital of Pension Credit guarantee credit recipients in the England pensioner, Wales and Scotland council tax reduction schemes, so they get the maximum reduction less non-dependant deductions whatever their income or savings (SI 2012/2885 Sch 1 para 13; WSI 2013/3029 Sch 1 para 7; SSI 2012/319 reg 24). For savings-credit-only recipients, use the Pension Credit assessment of income plus the savings credit paid, and of capital (para 14; para 8; reg 25): their reduction now falls, because the savings credit counts as income and the capital limit applies to all the capital Pension Credit counts, not only savings. +Disregard the whole income and capital of Pension Credit guarantee credit recipients in the England pensioner, Wales and Scotland council tax reduction schemes, so they get the maximum reduction less non-dependant deductions whatever their income or savings (SI 2012/2885 Sch 1 para 13; WSI 2013/3029 Sch 1 para 7; SSI 2012/319 reg 24). For savings-credit-only recipients, use the Pension Credit assessment of income plus the savings credit paid, and of capital (para 14; para 8; reg 25). Their reduction can fall, because the savings credit counts as income and the capital limit applies to all the capital Pension Credit counts, not only savings. diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_credit.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_credit.yaml index d3abc115fa..1a3b4c684b 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_credit.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_credit.yaml @@ -503,3 +503,108 @@ council_tax_reduction_assessable_capital: 10_000 council_tax_reduction_applicable_income: 13_314 council_tax_reduction: 1_999.60 + +- name: Capital of exactly 16,000 is within the limit for a pensioner without Pension Credit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + state_pension: 13_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: 16_000 + output: + # The limit excludes capital that "exceeds" 16,000 (SI 2012/2885 reg 11(2)). + in_receipt_of_guarantee_credit: false + council_tax_reduction_assessable_capital: 16_000 + council_tax_reduction: 2_000 + +- name: Capital just over 16,000 excludes a pensioner without Pension Credit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + state_pension: 13_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: 16_000.01 + output: + council_tax_reduction_assessable_capital: 16_000.01 + council_tax_reduction: 0 + +- name: Savings-credit-only Pension Credit capital of exactly 16,000 is within the limit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 90 + state_pension: 12_000 + private_pension_income: 1_500 + 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: 16_000 + output: + # 2,000 - 0.2 x (14,247.02 - 13,312); SSI 2012/319 reg 25(4) excludes only more than 16,000. + in_receipt_of_savings_credit_only: true + savings_credit: 309.02 + council_tax_reduction_assessable_capital: 16_000 + council_tax_reduction_applicable_income: 14_247.02 + council_tax_reduction: 1_813 + +- name: Savings-credit-only Pension Credit capital just over 16,000 is excluded + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 90 + state_pension: 12_000 + private_pension_income: 1_500 + 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: 16_000.01 + output: + in_receipt_of_savings_credit_only: true + council_tax_reduction_assessable_capital: 16_000.01 + council_tax_reduction: 0 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 51207b47f9..717ee595ce 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 @@ -30,8 +30,9 @@ 6. Metamorphic: nor when its savings change. 7. Metamorphic: not claiming Pension Credit never raises a guarantee credit recipient's CTR, and a non-claimant is assessed under invariant 4. -8. Under the Pension Credit freeze, a savings-credit-only award counts the - frozen savings credit actually paid. +8. Under the Pension Credit freeze, receipt and the savings credit payable + follow the frozen (baseline) award, even where the reform alone would + change which credit is received. """ import numpy as np @@ -96,6 +97,13 @@ def families(draw): state_pension=draw(state_pension), private_pension=draw(st.one_of(st.just(0.0), st.floats(0, 5_000))), employment_income=draw(st.one_of(st.just(0.0), st.floats(0, 20_000))), + # Other income main's CTR definition counts, so the differential + # (invariant 4) sees more than pensions and earnings. + self_employment_income=draw(st.one_of(st.just(0.0), st.floats(0, 15_000))), + property_income=draw(st.one_of(st.just(0.0), st.floats(0, 5_000))), + carers_allowance=draw(st.one_of(st.just(0.0), st.floats(0, 4_500))), + child_ages=draw(st.lists(st.integers(1, 15), max_size=2)), + would_claim_uc=draw(st.booleans()), would_claim_pc=draw(st.sampled_from([True, True, False])), # A working-age non-dependant (a separate benefit unit) living with a # pension-age family; younger than the claimant, so never the head. @@ -175,8 +183,18 @@ def situation( person["private_pension_income"] = {YEAR: unit["private_pension"]} else: person["employment_income"] = {YEAR: unit["employment_income"]} + person["self_employment_income"] = { + YEAR: unit.get("self_employment_income", 0.0) + } + if j == 0: + person["property_income"] = {YEAR: unit.get("property_income", 0.0)} + person["carers_allowance"] = {YEAR: unit.get("carers_allowance", 0.0)} people[name] = person names.append(name) + for k, child_age in enumerate(unit.get("child_ages", [])): + name = f"c{i}_{k}" + people[name] = {"age": {YEAR: child_age}} + names.append(name) claim_pc = unit["would_claim_pc"] if would_claim_pc is None else would_claim_pc benunit = { "members": names, @@ -184,7 +202,7 @@ def situation( # whole simulation, so set it for every family. "claims_all_entitled_benefits": {YEAR: True}, "would_claim_pc": {YEAR: claim_pc}, - "would_claim_uc": {YEAR: False}, + "would_claim_uc": {YEAR: unit.get("would_claim_uc", False)}, } benunits[f"b{i}"] = benunit claimant_benunits.append(benunit_index) @@ -489,3 +507,73 @@ def test_savings_credit_only_award_counts_the_frozen_savings_credit(): "council_tax_reduction_applicable_income", YEAR ) assert abs(unfrozen_income[0] - 14_183.89) < 0.01 + + +def freeze_case(age, state_pension, private_pension, savings, reform): + person = { + "age": {YEAR: age}, + "state_pension": {YEAR: state_pension}, + "private_pension_income": {YEAR: private_pension}, + } + data = { + "people": {"pensioner": person}, + "benunits": { + "benunit": { + "members": ["pensioner"], + "claims_all_entitled_benefits": {YEAR: True}, + } + }, + "households": { + "household": { + "members": ["pensioner"], + "country": {YEAR: "ENGLAND"}, + "local_authority": {YEAR: "MAIDSTONE"}, + "tenure_type": {YEAR: "OWNED_OUTRIGHT"}, + "council_tax": {YEAR: 2_000}, + "savings": {YEAR: savings}, + } + }, + } + period = f"{YEAR}-01-01.{YEAR}-12-31" + reform = {key: {period: value} for key, value in reform.items()} + frozen = Simulation( + situation=data, + reform={**reform, "gov.contrib.freeze_pension_credit": {period: True}}, + ) + unfrozen = Simulation(situation=data, reform=reform) + return frozen, unfrozen + + +def test_freeze_keeps_savings_credit_only_receipt_when_the_reform_adds_guarantee_credit(): + # A higher guarantee gives the reform a guarantee credit, but the frozen + # award is the baseline's savings credit of 558.62. + frozen, unfrozen = freeze_case( + 90, + 12_000, + 1_500, + 0, + {"gov.dwp.pension_credit.guarantee_credit.minimum_guarantee.SINGLE": 300}, + ) + assert unfrozen.calculate("in_receipt_of_guarantee_credit", YEAR)[0] + assert abs(unfrozen.calculate("council_tax_reduction", YEAR)[0] - 2_000) < 0.01 + assert not frozen.calculate("in_receipt_of_guarantee_credit", YEAR)[0] + assert frozen.calculate("in_receipt_of_savings_credit_only", YEAR)[0] + # 2,000 - 0.2 x (13,314 + 558.62 - 13,312) + assert abs(frozen.calculate("council_tax_reduction", YEAR)[0] - 1_887.88) < 0.01 + + +def test_freeze_keeps_guarantee_credit_receipt_when_the_reform_removes_it(): + # With no minimum guarantee the reform pays no guarantee credit, so the + # 20,000 savings exceed the capital limit; the frozen award is still the + # baseline guarantee credit, so capital is disregarded. + frozen, unfrozen = freeze_case( + 70, + 9_000, + 0, + 20_000, + {"gov.dwp.pension_credit.guarantee_credit.minimum_guarantee.SINGLE": 0}, + ) + assert not unfrozen.calculate("in_receipt_of_guarantee_credit", YEAR)[0] + assert unfrozen.calculate("council_tax_reduction", YEAR)[0] == 0 + assert frozen.calculate("in_receipt_of_guarantee_credit", YEAR)[0] + assert abs(frozen.calculate("council_tax_reduction", YEAR)[0] - 2_000) < 0.01 From 2936fecd23bc668a71b1b67b7f7d4c741415bf86 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 11:31:14 -0400 Subject: [PATCH 4/5] Note the tariff-income gap in the boundary case and cite England law Co-Authored-By: Claude Opus 5.5 --- .../council_tax_reduction_pension_credit.yaml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_credit.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_credit.yaml index 1a3b4c684b..ce21f7d8a4 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_credit.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_credit.yaml @@ -526,6 +526,9 @@ savings: 16_000 output: # The limit excludes capital that "exceeds" 16,000 (SI 2012/2885 reg 11(2)). + # The award pins the model's current value: it has no pensioner tariff + # income from capital (Sch 1 para 37, 624 a year here), a known gap + # (#1933), so this is not the full legal amount. in_receipt_of_guarantee_credit: false council_tax_reduction_assessable_capital: 16_000 council_tax_reduction: 2_000 @@ -576,7 +579,9 @@ council_tax: 2_000 savings: 16_000 output: - # 2,000 - 0.2 x (14,247.02 - 13,312); SSI 2012/319 reg 25(4) excludes only more than 16,000. + # 2,000 - 0.2 x (14,247.02 - 13,312). The Secretary of State's capital is + # tested against the limit, which excludes only capital that exceeds + # 16,000 (SI 2012/2885 Sch 1 para 14(1) and reg 11(2)). in_receipt_of_savings_credit_only: true savings_credit: 309.02 council_tax_reduction_assessable_capital: 16_000 From 6469786523c3450df2b6a578aa9165d4b08852c6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 11:44:35 -0400 Subject: [PATCH 5/5] Adopt the shared receipt variable's documentation v2 (SI 2019/37 art 4 saving) Doc-only; the formula is unchanged. Keeps the file byte-identical with #1927. Co-Authored-By: Claude Opus 5.5 --- .../in_receipt_of_guarantee_credit.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/in_receipt_of_guarantee_credit.py b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/in_receipt_of_guarantee_credit.py index 69b5a7e090..877ab3606b 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/in_receipt_of_guarantee_credit.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/in_receipt_of_guarantee_credit.py @@ -7,10 +7,14 @@ class in_receipt_of_guarantee_credit(Variable): "Whether the claimant or partner in this benefit unit is in receipt of " "a guarantee credit: the benefit unit is eligible for Pension Credit, " "claims it, and its Guarantee Credit is positive. A Guarantee Credit " - "computed for a family that does not claim Pension Credit, or is not " - "eligible for it (for example a working-age or mixed-age family), is " - "not received. Under the Pension Credit freeze the baseline receipt is " - "kept, because the frozen award is the baseline award." + "computed for a family that does not claim Pension Credit, or that the " + "model does not treat as eligible for it (for example a working-age " + "family), is not received. The model's Pension Credit eligibility " + "omits the SI 2019/37 article 4 saving, so a mixed-age couple it saves " + "(entitled to Pension Credit or Housing Benefit as that couple on 14 " + "May 2019, and to either ever since) is not treated as in receipt. Under " + "the Pension Credit freeze the baseline receipt is kept, because the " + "frozen award is the baseline award." ) entity = BenUnit definition_period = YEAR @@ -18,6 +22,8 @@ class in_receipt_of_guarantee_credit(Variable): reference = ( "https://www.legislation.gov.uk/ukpga/2002/16/section/1", "https://www.legislation.gov.uk/ukpga/2002/16/section/2", + "https://www.legislation.gov.uk/ukpga/1992/5/section/1", + "https://www.legislation.gov.uk/uksi/2019/37/article/4", ) def formula(benunit, period, parameters):