From 11406ad805e1e07c7532e6cad841595873cefee2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Sat, 17 Oct 2026 05:04:39 +0100 Subject: [PATCH] Exclude Child Benefit from GB pensioner CTR income --- changelog.d/2111.fixed.md | 1 + ...tax_reduction_pensioner_child_benefit.yaml | 208 +++++++++++++++++ ..._tax_reduction_child_benefit_properties.py | 216 ++++++++++++++++++ ...tax_reduction_pension_credit_properties.py | 12 +- ...council_tax_reduction_applicable_income.py | 23 +- 5 files changed, 456 insertions(+), 4 deletions(-) create mode 100644 changelog.d/2111.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_child_benefit.yaml create mode 100644 policyengine_uk/tests/test_council_tax_reduction_child_benefit_properties.py diff --git a/changelog.d/2111.fixed.md b/changelog.d/2111.fixed.md new file mode 100644 index 0000000000..b696059d2b --- /dev/null +++ b/changelog.d/2111.fixed.md @@ -0,0 +1 @@ +- Excluded Child Benefit from Great Britain's pensioner Council Tax Reduction general-rule income, preserving working-age and statutory-pay treatment. diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_child_benefit.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_child_benefit.yaml new file mode 100644 index 0000000000..0eaf16432d --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_child_benefit.yaml @@ -0,0 +1,208 @@ +# Current GB pensioner income exclusions: England Sch1 para16(1)(j)(vi), +# Wales Sch1 para10(1)(j)(vi), Scotland reg27(1)(j)(vii). +# Model2026: eldest Child Benefit27.05*52=1406.60; aged allowance256*52=13312. +# These genuine claim/takeup inputs use the general rules, not PC overrides. +- name: England pensioner Child Benefit is excluded before the CTR taper + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: {age: 70, is_parent: true, state_pension: 16000} + child: {age: 9} + benunits: + family: + members: [parent, child] + would_claim_pc: false + would_claim_uc: false + households: + home: + members: [parent, child] + country: ENGLAND + local_authority: MAIDSTONE + tenure_type: OWNED_OUTRIGHT + council_tax: 3000 + savings: 0 + output: + child_benefit: 1406.60 + income_tax: [686, 0] + council_tax_reduction_pensioner: true + in_receipt_of_guarantee_credit: false + in_receipt_of_savings_credit_only: false + council_tax_reduction_applicable_income: 15314 + council_tax_reduction_applicable_amount: 13312 + # 3000 - .2*(16000 - 686 - 13312) = 2599.60. + council_tax_benefit: 2599.60 + council_tax_reduction: 2599.60 + +- name: Scottish pensioner Child Benefit does not reduce the final CTR award + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: {age: 70, is_parent: true, state_pension: 12000} + child: {age: 9} + benunits: + family: {members: [parent, child], would_claim_pc: false, would_claim_uc: false} + households: + home: + members: [parent, child] + region: SCOTLAND + local_authority: GLASGOW_CITY + tenure_type: OWNED_OUTRIGHT + council_tax: 3000 + savings: 0 + output: + child_benefit: 1406.60 + council_tax_reduction_pensioner: true + council_tax_reduction_applicable_income: 12000 + council_tax_benefit: 3000 + council_tax_reduction: 3000 + +- name: Welsh pensioner Child Benefit does not reduce the final CTR award + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: {age: 70, is_parent: true, state_pension: 12000} + child: {age: 9} + benunits: + family: {members: [parent, child], would_claim_pc: false, would_claim_uc: false} + households: + home: + members: [parent, child] + region: WALES + local_authority: CARDIFF + tenure_type: OWNED_OUTRIGHT + council_tax: 3000 + savings: 0 + output: + child_benefit: 1406.60 + council_tax_reduction_pensioner: true + council_tax_reduction_applicable_income: 12000 + council_tax_benefit: 3000 + council_tax_reduction: 3000 + +- name: Not claiming Child Benefit preserves the pensioner general-rule income + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: {age: 70, is_parent: true, state_pension: 16000} + child: {age: 9} + benunits: + family: + members: [parent, child] + would_claim_pc: false + would_claim_uc: false + would_claim_child_benefit: false + households: + home: + members: [parent, child] + country: ENGLAND + local_authority: MAIDSTONE + tenure_type: OWNED_OUTRIGHT + council_tax: 3000 + savings: 0 + output: + child_benefit: 0 + council_tax_reduction_applicable_income: 15314 + council_tax_reduction: 2599.60 + +- name: Excluding pensioner Child Benefit retains the zero floor and liability cap + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: {age: 70, is_parent: true, personal_pension_contributions: 4000} + child: {age: 9} + benunits: + family: {members: [parent, child], would_claim_pc: false, would_claim_uc: false} + households: + home: + members: [parent, child] + country: ENGLAND + local_authority: MAIDSTONE + tenure_type: OWNED_OUTRIGHT + council_tax: 3000 + savings: 5000 + output: + child_benefit: 1406.60 + council_tax_reduction_pensioner: true + council_tax_reduction_applicable_income: 0 + council_tax_reduction: 3000 + +- name: A Guarantee Credit family still uses the whole-income disregard + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: {age: 80, is_parent: true} + child: {age: 9} + benunits: + family: {members: [parent, child], would_claim_pc: true, would_claim_uc: false} + households: + home: + members: [parent, child] + country: ENGLAND + local_authority: MAIDSTONE + tenure_type: OWNED_OUTRIGHT + council_tax: 3000 + savings: 0 + output: + child_benefit: 1406.60 + in_receipt_of_guarantee_credit: true + council_tax_reduction_applicable_income: 0 + council_tax_reduction: 3000 + +- name: Northern Ireland intermediate income is preserved outside this GB change + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: {age: 70, is_parent: true, state_pension: 12000} + child: {age: 9} + benunits: + family: {members: [parent, child], would_claim_pc: false, would_claim_uc: false} + households: + home: + members: [parent, child] + region: NORTHERN_IRELAND + tenure_type: OWNED_OUTRIGHT + council_tax: 3000 + savings: 0 + output: + child_benefit: 1406.60 + council_tax_reduction_applicable_income: 13406.60 + council_tax_reduction_scheme_supported: false + council_tax_reduction: 0 + +- name: Pensioner Child Benefit exclusion keeps SSP and SMP counted once as earnings + period: 2026 + absolute_error_margin: 0.01 + input: + people: + older: {age: 70, is_parent: true, state_pension: 12000, statutory_sick_pay: 1000} + partner: {age: 30, is_parent: true, gender: FEMALE, statutory_maternity_pay: 2000} + baby: {age: 0} + benunits: + family: + members: [older, partner, baby] + would_claim_pc: false + would_claim_uc: false + households: + home: + members: [older, partner, baby] + region: LONDON + local_authority: MAIDSTONE + tenure_type: OWNED_OUTRIGHT + council_tax: 3000 + savings: 0 + output: + child_benefit: 1406.60 + council_tax_reduction_pensioner: true + income_tax: [0, 0, 0] + council_tax_reduction_pensioner_earnings_disregard: 520 + # Default marriage election removes the otherwise86tax. + # 12000 + 1000 + 2000 - 0 tax - 10*52 disregard = 14480. + council_tax_reduction_applicable_income: 14480 + council_tax_reduction: 3000 diff --git a/policyengine_uk/tests/test_council_tax_reduction_child_benefit_properties.py b/policyengine_uk/tests/test_council_tax_reduction_child_benefit_properties.py new file mode 100644 index 0000000000..b041c55dca --- /dev/null +++ b/policyengine_uk/tests/test_council_tax_reduction_child_benefit_properties.py @@ -0,0 +1,216 @@ +"""Pensioner Child Benefit exclusion and unchanged adjacent CTR contracts. + +These model2026 tests use actual Child Benefit claims and family incomes, not +forced applicable income, pensioner status or reduction awards. The 20% cash +relation is conditional on an interior award; the existing floor/cap still apply. +""" + +from copy import deepcopy + +import numpy as np +from hypothesis import given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +YEAR = 2026 +COUNTRIES = { + "ENGLAND": ("LONDON", "MAIDSTONE"), + "SCOTLAND": ("SCOTLAND", "GLASGOW_CITY"), + "WALES": ("WALES", "CARDIFF"), +} + + +def family(country="ENGLAND", pension=16000, age=70, children=1, council_tax=4000): + region, authority = COUNTRIES.get(country, ("NORTHERN_IRELAND", "BELFAST")) + people = {"parent": {"age": {YEAR: age}, "is_parent": {YEAR: True}}} + people["parent"][ + "state_pension" if age >= 67 else "employment_income_before_lsr" + ] = {YEAR: pension} + for index in range(children): + people[f"child{index}"] = {"age": {YEAR: 9 + index}} + return { + "people": people, + "benunits": { + "family": { + "members": list(people), + "would_claim_pc": {YEAR: False}, + "would_claim_uc": {YEAR: False}, + } + }, + "households": { + "home": { + "members": list(people), + "region": {YEAR: region}, + "local_authority": {YEAR: authority}, + "tenure_type": {YEAR: "OWNED_OUTRIGHT"}, + "council_tax": {YEAR: council_tax}, + "savings": {YEAR: 0}, + } + }, + } + + +def calculate(data, claim, status_first=False): + data = deepcopy(data) + for unit in data["benunits"].values(): + unit["would_claim_child_benefit"] = {YEAR: claim} + sim = Simulation(situation=data) + first = ( + "council_tax_reduction_pensioner" + if status_first + else "council_tax_reduction_applicable_income" + ) + sim.calculate(first, YEAR) + names = [ + "child_benefit", + "council_tax_reduction_pensioner", + "council_tax_reduction_applicable_income", + "council_tax_reduction_applicable_amount", + "council_tax_benefit", + "council_tax_reduction", + "household_net_income", + "in_receipt_of_guarantee_credit", + "in_receipt_of_savings_credit_only", + ] + return {name: np.asarray(sim.calculate(name, YEAR)) for name in names} + + +@settings(max_examples=4, deadline=None, derandomize=True) +@given( + st.sampled_from(sorted(COUNTRIES)), + st.integers(15000, 19000), + st.integers(1, 2), + st.booleans(), +) +def test_pensioner_ctr_is_invariant_to_actual_child_benefit_claim( + country, pension, children, status_first +): + data = family(country, pension, children=children) + paid = calculate(data, True, status_first) + unpaid = calculate(data, False, not status_first) + assert paid["child_benefit"][0] > 0 + assert unpaid["child_benefit"][0] == 0 + assert paid["council_tax_reduction_pensioner"][0] + assert not paid["in_receipt_of_guarantee_credit"][0] + assert not paid["in_receipt_of_savings_credit_only"][0] + np.testing.assert_allclose( + paid["council_tax_reduction_applicable_income"], + unpaid["council_tax_reduction_applicable_income"], + atol=0.01, + rtol=0, + ) + np.testing.assert_allclose( + paid["council_tax_reduction"], + unpaid["council_tax_reduction"], + atol=0.01, + rtol=0, + ) + # The payable award reaches the household consumer. No other benefit/tax + # changes in these owner-occupier, no-PC/no-UC, below-HICBC families. + np.testing.assert_allclose( + paid["household_net_income"] - unpaid["household_net_income"], + paid["child_benefit"], + atol=0.02, + rtol=0, + ) + # Named old-law counterfactual: before the exclusion, an interior award + # counted this cash and lost exactly 20% of it. The new award is not clipped. + income = unpaid["council_tax_reduction_applicable_income"][0] + amount = unpaid["council_tax_reduction_applicable_amount"][0] + liability = 4000 + old_award = max( + 0, liability - 0.2 * max(0, income + paid["child_benefit"][0] - amount) + ) + assert income > amount and old_award > 0 + assert ( + abs( + paid["council_tax_reduction"][0] + - old_award + - 0.2 * paid["child_benefit"][0] + ) + < 0.02 + ) + + +def test_working_age_and_northern_ireland_keep_counting_child_benefit(): + for country, authority in [ + ("SCOTLAND", "GLASGOW_CITY"), + ("WALES", "CARDIFF"), + ("ENGLAND", "MERTON"), + ("ENGLAND", "KINGSTON_UPON_THAMES"), + ("ENGLAND", "NEWHAM"), + ("ENGLAND", "WESTMINSTER"), + ("ENGLAND", "OXFORD"), + ("NORTHERN_IRELAND", "BELFAST"), + ]: + age = 70 if country == "NORTHERN_IRELAND" else 40 + data = family(country, 18000, age=age) + data["households"]["home"]["local_authority"] = {YEAR: authority} + paid, unpaid = calculate(data, True), calculate(data, False, True) + assert paid["child_benefit"][0] > 0 + if age == 40: + assert not paid["council_tax_reduction_pensioner"][0] + np.testing.assert_allclose( + paid["council_tax_reduction_applicable_income"] + - unpaid["council_tax_reduction_applicable_income"], + paid["child_benefit"], + atol=0.02, + rtol=0, + ) + assert ( + paid["council_tax_reduction"][0] + <= unpaid["council_tax_reduction"][0] + 0.01 + ) + if authority == "OXFORD": + # Oxford already removes CB before its own band selection. + np.testing.assert_allclose( + paid["council_tax_reduction"], + unpaid["council_tax_reduction"], + atol=0.01, + rtol=0, + ) + + +def test_own_family_pensioner_mask_and_query_order_ignore_outside_family(): + data = family() + data["people"].update( + { + "other": {"age": {YEAR: 40}, "employment_income_before_lsr": {YEAR: 18000}}, + "other_child": {"age": {YEAR: 7}}, + } + ) + data["benunits"]["other_family"] = { + "members": ["other", "other_child"], + "would_claim_pc": {YEAR: False}, + "would_claim_uc": {YEAR: False}, + } + data["households"]["home"]["members"] += ["other", "other_child"] + paid, unpaid = calculate(data, True), calculate(data, False, True) + np.testing.assert_array_equal( + paid["council_tax_reduction_pensioner"], [True, False] + ) + delta = ( + paid["council_tax_reduction_applicable_income"] + - unpaid["council_tax_reduction_applicable_income"] + ) + np.testing.assert_allclose(delta, [0, paid["child_benefit"][1]], atol=0.02, rtol=0) + reversed_data = deepcopy(data) + reversed_data["people"] = dict(reversed(list(data["people"].items()))) + reversed_data["benunits"] = dict(reversed(list(data["benunits"].items()))) + for unit in reversed_data["benunits"].values(): + unit["members"] = list(reversed(unit["members"])) + reverse = calculate(reversed_data, True, True) + for name in [ + "child_benefit", + "council_tax_reduction_pensioner", + "council_tax_reduction_applicable_income", + "council_tax_benefit", + ]: + np.testing.assert_allclose(reverse[name][::-1], paid[name], atol=0.02, rtol=0) + np.testing.assert_allclose( + reverse["council_tax_reduction"], + paid["council_tax_reduction"], + atol=0.02, + rtol=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 e6d1de97c7..811373c441 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,8 @@ 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 less the schemes' pensioner - earnings disregard, both recomputed here (the income from its components, + income definition on main before this change less pensioners' Child Benefit + and the schemes' pensioner earnings disregard, recomputed here (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 @@ -264,7 +264,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. Since #2033, a pensioner family's -# earnings disregard also comes off before the floor. +# earnings disregard also comes off before the floor. These GB families now +# exclude Child Benefit on the pensioner general-rules route as well. MAIN_INCOME_COMPONENTS = [ "employment_income", "self_employment_income", @@ -362,6 +363,11 @@ def benunit_total(names): values["income_under_main_definition"] = np.maximum( 0, benunit_total(MAIN_INCOME_COMPONENTS) + - np.where( + values["council_tax_reduction_pensioner"], + benunit_total(["child_benefit"]), + 0, + ) - benunit_total(MAIN_DEDUCTIONS) - 0.5 * benunit_total(["pension_contributions"]) - pensioner_earnings_disregard(sim, claimants), 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 55575e1987..5f09f4c4a8 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 @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.household.demographic.country import Country class council_tax_reduction_applicable_income(Variable): @@ -12,7 +13,9 @@ class council_tax_reduction_applicable_income(Variable): "use the Secretary of State's Pension Credit assessment of income, plus " "the savings credit payable, where the award is savings credit only. " "Otherwise a pension-age family's net earnings are reduced by the " - "pensioner earnings disregards. The other adjustments those " + "pensioner earnings disregards. Great Britain's pensioner schemes " + "also exclude Child Benefit from general-rule income; statutory sick " + "and maternity pay remain counted as earnings. The other adjustments those " "provisions allow (childcare charges, the £20 earnings disregards, " "maintenance disregards and the rest) are not modelled." ) @@ -21,8 +24,10 @@ class council_tax_reduction_applicable_income(Variable): 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/uksi/2012/2885/schedule/1/paragraph/16", "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/wsi/2013/3029/schedule/1/paragraph/10", "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/ssi/2012/319/regulation/27", @@ -100,6 +105,22 @@ def formula(benunit, period, parameters): benefits = add_for_members( benunit, period, benunit_means_tested_benefits, members ) + country = benunit.household("country", period) + in_great_britain = ( + (country == Country.ENGLAND) + | (country == Country.SCOTLAND) + | (country == Country.WALES) + ) + pensioner = benunit("council_tax_reduction_pensioner", period) + # England Sch 1 para 16(1)(j)(vi), Wales Sch 1 para 10(1)(j)(vi) + # and Scotland reg 27(1)(j)(vii) exclude this exact BenUnit award. + # Remove it before the general-rules floor. SSP/SMP remain counted + # in personal_benefits as earnings; working-age/NI income is unchanged. + benefits -= where( + pensioner & in_great_britain, + add_for_members(benunit, period, ["child_benefit"], members), + 0, + ) income = add_for_members(benunit, period, income_components, members) personal_benefit_income = add_for_members( benunit, period, personal_benefits, members