From e34b753581dd54660da09eea1b0fbec47c4c8881 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 10:33:00 -0400 Subject: [PATCH 1/8] Move families onto Universal Credit when a legacy benefit they receive closes Tax credits closed on 6 April 2025 and Income Support and income-based JSA on 1 April 2026, but the model kept paying a family's other legacy awards and paid Universal Credit only through would_claim_uc, which datasets draw without regard to legacy receipt. A family that drew it false was left with nothing once its tax credits stopped. - would_claim_uc_at_legacy_closure: dataset input (default true) saying whether the family claims Universal Credit when its legacy benefits close. - legacy_benefits_closed: a working-age family reports a legacy benefit that has closed (tax credits from 2025, IS and income-based JSA from 2026). - claims_universal_credit: would_claim_uc, or the closure and the flag. It replaces would_claim_uc in universal_credit, universal_credit_pre_benefit_cap, uc_has_deduction, is_CTC_eligible and housing_benefit_eligible. - Once a family's legacy benefits have closed, its Housing Benefit continuing award, Income Support, income-related ESA and income-based JSA end, whether or not it claims (SI 2014/1230 regs 8 and 46). Years before any closure are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../uc-claim-at-legacy-closure.added.md | 1 + .../claims_universal_credit.yaml | 299 ++++++++++++++++++ .../test_uc_legacy_closure_properties.py | 215 +++++++++++++ .../variables/gov/dwp/esa_income_eligible.py | 9 +- .../housing_benefit_eligible.py | 15 +- .../variables/gov/dwp/income_support.py | 6 +- .../variables/gov/dwp/is_CTC_eligible.py | 6 +- .../variables/gov/dwp/jsa_income_eligible.py | 9 +- .../claims_universal_credit.py | 25 ++ .../deductions/uc_has_deduction.py | 2 +- .../legacy_benefits_closed.py | 49 +++ .../dwp/universal_credit/universal_credit.py | 2 +- .../universal_credit_pre_benefit_cap.py | 2 +- .../would_claim_uc_at_legacy_closure.py | 17 + 14 files changed, 644 insertions(+), 13 deletions(-) create mode 100644 changelog.d/uc-claim-at-legacy-closure.added.md create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/claims_universal_credit.yaml create mode 100644 policyengine_uk/tests/test_uc_legacy_closure_properties.py create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/claims_universal_credit.py create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/legacy_benefits_closed.py create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/would_claim_uc_at_legacy_closure.py diff --git a/changelog.d/uc-claim-at-legacy-closure.added.md b/changelog.d/uc-claim-at-legacy-closure.added.md new file mode 100644 index 0000000000..477e81ba64 --- /dev/null +++ b/changelog.d/uc-claim-at-legacy-closure.added.md @@ -0,0 +1 @@ +- Move working-age families onto Universal Credit when a legacy benefit they receive closes (tax credits from 2025-26, Income Support and income-based JSA from 2026-27). A new dataset input, `would_claim_uc_at_legacy_closure` (default true), says whether the family claims; `claims_universal_credit` replaces `would_claim_uc` wherever the model asks whether a family claims Universal Credit; and the family's other legacy awards (Housing Benefit, Income Support, income-related ESA, income-based JSA) end at the closure whether or not it claims (SI 2014/1230 regs 8 and 46). diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/claims_universal_credit.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/claims_universal_credit.yaml new file mode 100644 index 0000000000..fb158ebfdb --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/claims_universal_credit.yaml @@ -0,0 +1,299 @@ +# Universal Credit claims when a legacy benefit closes (managed migration). +# Tax credits closed from 6 April 2025 (PE year 2025) and Income Support and +# income-based JSA from 1 April 2026 (PE year 2026). Once one of a working-age +# family's reported legacy benefits has closed, all its legacy awards end +# (SI 2014/1230 regs 8 and 46) and it claims Universal Credit when +# would_claim_uc_at_legacy_closure is true. + +- name: Before tax credits close, a tax credit and Housing Benefit family keeps both awards + period: 2024 + input: + people: + parent: + age: 30 + child_tax_credit_reported: 3_000 + housing_benefit_reported: 4_000 + child: + age: 4 + benunits: + benunit: + members: [parent, child] + would_claim_uc: false + would_claim_uc_at_legacy_closure: true + households: + household: + members: [parent, child] + tenure_type: RENT_FROM_COUNCIL + output: + legacy_benefits_closed: false + claims_universal_credit: false + housing_benefit_eligible: true + universal_credit: 0 + +- name: Once tax credits close, the family claims Universal Credit and its Housing Benefit ends + period: 2025 + input: + people: + parent: + age: 30 + child_tax_credit_reported: 3_000 + housing_benefit_reported: 4_000 + child: + age: 4 + benunits: + benunit: + members: [parent, child] + would_claim_uc: false + would_claim_uc_at_legacy_closure: true + households: + household: + members: [parent, child] + tenure_type: RENT_FROM_COUNCIL + output: + legacy_benefits_closed: true + claims_universal_credit: true + housing_benefit_eligible: false + housing_benefit: 0 + +- name: A family that does not claim at the closure loses its Housing Benefit and gets no Universal Credit + period: 2025 + input: + people: + parent: + age: 30 + child_tax_credit_reported: 3_000 + housing_benefit_reported: 4_000 + child: + age: 4 + benunits: + benunit: + members: [parent, child] + would_claim_uc: false + would_claim_uc_at_legacy_closure: false + households: + household: + members: [parent, child] + tenure_type: RENT_FROM_COUNCIL + output: + legacy_benefits_closed: true + claims_universal_credit: false + housing_benefit_eligible: false + housing_benefit: 0 + universal_credit: 0 + +- name: Housing Benefit alone has not closed, so a Housing Benefit family keeps its award + period: 2027 + input: + people: + adult: + age: 40 + housing_benefit_reported: 4_000 + benunits: + benunit: + members: [adult] + would_claim_uc: false + would_claim_uc_at_legacy_closure: true + households: + household: + members: [adult] + tenure_type: RENT_FROM_COUNCIL + output: + legacy_benefits_closed: false + claims_universal_credit: false + housing_benefit_eligible: true + universal_credit: 0 + +- name: Income-related ESA continues before tax credits close + period: 2024 + input: + people: + parent: + age: 35 + child_tax_credit_reported: 3_000 + esa_income_reported: 5_000 + child: + age: 8 + benunits: + benunit: + members: [parent, child] + would_claim_uc: false + households: + household: + members: [parent, child] + output: + legacy_benefits_closed: false + esa_income_eligible: true + +- name: Income-related ESA ends with the tax credit closure + period: 2025 + input: + people: + parent: + age: 35 + child_tax_credit_reported: 3_000 + esa_income_reported: 5_000 + child: + age: 8 + benunits: + benunit: + members: [parent, child] + would_claim_uc: false + households: + household: + members: [parent, child] + output: + legacy_benefits_closed: true + claims_universal_credit: true + esa_income_eligible: false + esa_income: 0 + +- name: Income Support and Housing Benefit continue in 2025-26 + period: 2025 + input: + people: + parent: + age: 25 + income_support_reported: 5_000 + housing_benefit_reported: 4_000 + child: + age: 2 + benunits: + benunit: + members: [parent, child] + would_claim_uc: false + would_claim_uc_at_legacy_closure: true + households: + household: + members: [parent, child] + tenure_type: RENT_FROM_COUNCIL + output: + legacy_benefits_closed: false + claims_universal_credit: false + housing_benefit_eligible: true + +- name: Once Income Support closes, the family claims Universal Credit and its Housing Benefit ends + period: 2026 + input: + people: + parent: + age: 25 + income_support_reported: 5_000 + housing_benefit_reported: 4_000 + child: + age: 2 + benunits: + benunit: + members: [parent, child] + would_claim_uc: false + would_claim_uc_at_legacy_closure: true + households: + household: + members: [parent, child] + tenure_type: RENT_FROM_COUNCIL + output: + legacy_benefits_closed: true + claims_universal_credit: true + housing_benefit_eligible: false + income_support: 0 + +- name: Income Support ends with the tax credit closure for a family on both + period: 2025 + input: + people: + parent: + age: 25 + income_support_reported: 5_000 + child_tax_credit_reported: 3_000 + child: + age: 2 + benunits: + benunit: + members: [parent, child] + would_claim_uc: false + households: + household: + members: [parent, child] + output: + legacy_benefits_closed: true + income_support: 0 + +- name: Income-based JSA continues before its closure + period: 2025 + input: + people: + adult: + age: 40 + jsa_income_reported: 4_000 + benunits: + benunit: + members: [adult] + would_claim_uc: false + households: + household: + members: [adult] + output: + legacy_benefits_closed: false + jsa_income_eligible: true + +- name: Income-based JSA closure moves the family + period: 2026 + input: + people: + adult: + age: 40 + jsa_income_reported: 4_000 + benunits: + benunit: + members: [adult] + would_claim_uc: false + would_claim_uc_at_legacy_closure: false + households: + household: + members: [adult] + output: + legacy_benefits_closed: true + claims_universal_credit: false + jsa_income_eligible: false + universal_credit: 0 + +- name: A pension-age family is not moved to Universal Credit when tax credits close + period: 2025 + input: + people: + grandparent: + age: 70 + child_tax_credit_reported: 3_000 + housing_benefit_reported: 4_000 + child: + age: 10 + benunits: + benunit: + members: [grandparent, child] + would_claim_uc: false + would_claim_uc_at_legacy_closure: true + households: + household: + members: [grandparent, child] + tenure_type: RENT_FROM_COUNCIL + output: + legacy_benefits_closed: false + claims_universal_credit: false + housing_benefit_eligible: true + +- name: A family that already claims Universal Credit claims it in every year + period: 2024 + input: + people: + adult: + age: 40 + benunits: + benunit: + members: [adult] + would_claim_uc: true + would_claim_uc_at_legacy_closure: false + households: + household: + members: [adult] + output: + legacy_benefits_closed: false + claims_universal_credit: true diff --git a/policyengine_uk/tests/test_uc_legacy_closure_properties.py b/policyengine_uk/tests/test_uc_legacy_closure_properties.py new file mode 100644 index 0000000000..e8f49ac5d3 --- /dev/null +++ b/policyengine_uk/tests/test_uc_legacy_closure_properties.py @@ -0,0 +1,215 @@ +"""Property-based tests for Universal Credit claims when legacy benefits close. + +Tax credits closed from 6 April 2025 and Income Support and income-based JSA +from 1 April 2026. DWP moved each working-age family off its legacy benefits +with a migration notice covering all of them: the awards end on a Universal +Credit claim (SI 2014/1230 reg 8) or at the notice's deadline if the family +does not claim (reg 46). Datasets say which families claim through +would_claim_uc_at_legacy_closure. + +Invariants, for any generated population of families in 2024 to 2027: + +1. Closure: legacy_benefits_closed holds exactly for a working-age family + (not on the pension-age route) that reports tax credits from 2025, or + Income Support or income-based JSA from 2026. +2. Claim: claims_universal_credit is would_claim_uc, or the closure with the + closure claim flag; Universal Credit is paid only to claiming families. +3. Migration ends every legacy award: a family whose legacy benefits have + closed gets no Housing Benefit, tax credits, Income Support, + income-related ESA or income-based JSA. +4. Metamorphic, the flag is inert unless it decides a claim: negating + would_claim_uc_at_legacy_closure changes no benefit for a family whose + legacy benefits have not closed or that would claim Universal Credit + anyway. In particular nothing changes in 2024, before any closure. +5. Differential: a family claiming at the closure gets the Universal Credit + it would get if would_claim_uc were true. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +YEARS = (2024, 2025, 2026, 2027) +PROPERTY_SETTINGS = settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +# 67 and over is pension age in every year here; 60 and under working age. +PENSION_AGE = st.integers(67, 90) +WORKING_AGE = st.integers(18, 60) +SHAPES = { + "single": [("claimant", WORKING_AGE)], + "couple": [("claimant", WORKING_AGE), ("partner", WORKING_AGE)], + "lone_parent": [("claimant", WORKING_AGE), ("child", st.integers(0, 15))], + "couple_parents": [ + ("claimant", WORKING_AGE), + ("partner", WORKING_AGE), + ("child", st.integers(0, 15)), + ], + "pensioner": [("claimant", PENSION_AGE)], + "pensioner_with_child": [("claimant", PENSION_AGE), ("child", st.integers(5, 15))], +} +LEGACY = ( + "child_tax_credit_reported", + "working_tax_credit_reported", + "housing_benefit_reported", + "esa_income_reported", + "income_support_reported", + "jsa_income_reported", +) +TENURES = ["RENT_FROM_COUNCIL", "RENT_PRIVATELY", "OWNED_OUTRIGHT"] +BENEFITS = [ + "universal_credit", + "housing_benefit", + "tax_credits", + "income_support", + "esa_income", + "jsa_income", +] +OUTPUTS = BENEFITS + ["legacy_benefits_closed", "claims_universal_credit"] +award = st.sampled_from([0.0, 0.0, 2_000.0, 6_000.0]) + + +@st.composite +def families(draw): + shape = draw(st.sampled_from(sorted(SHAPES))) + return dict( + shape=shape, + members=[(role, draw(age)) for role, age in SHAPES[shape]], + reported={name: draw(award) for name in LEGACY}, + tenure=draw(st.sampled_from(TENURES)), + rent=draw(st.floats(0, 12_000)), + earnings=draw(st.sampled_from([0.0, 8_000.0, 25_000.0])), + savings=draw(st.sampled_from([0.0, 5_000.0, 20_000.0])), + would_claim_uc=draw(st.booleans()), + claims_at_closure=draw(st.booleans()), + ) + + +def every_year(value): + return {year: value for year in YEARS} + + +def situation(units, negate_flag=False, would_claim_uc=None): + people, benunits, households = {}, {}, {} + for i, unit in enumerate(units): + names = [] + for j, (role, age) in enumerate(unit["members"]): + name = f"p{i}_{j}" + # Set every input for every member, or it stops being an input + # for the rest of the simulation. + person = { + "age": every_year(age), + "is_claimant_or_partner": every_year(role != "child"), + "employment_income": every_year( + unit["earnings"] if role == "claimant" else 0.0 + ), + } + for name_reported in LEGACY: + amount = unit["reported"][name_reported] if role == "claimant" else 0 + person[name_reported] = every_year(amount) + people[name] = person + names.append(name) + claims_anyway = ( + unit["would_claim_uc"] if would_claim_uc is None else would_claim_uc + ) + benunits[f"b{i}"] = { + "members": names, + "would_claim_uc": every_year(claims_anyway), + "would_claim_uc_at_legacy_closure": every_year( + unit["claims_at_closure"] ^ negate_flag + ), + "claims_all_entitled_benefits": every_year(False), + } + households[f"h{i}"] = { + "members": names, + "rent": every_year(unit["rent"]), + "tenure_type": every_year(unit["tenure"]), + "savings": every_year(unit["savings"]), + } + return {"people": people, "benunits": benunits, "households": households} + + +def calculate(units, **kwargs): + sim = Simulation(situation=situation(units, **kwargs)) + values = { + year: {v: np.asarray(sim.calculate(v, year)) for v in OUTPUTS} for year in YEARS + } + for year in YEARS: + values[year]["pension_route"] = np.asarray( + sim.calculate("meets_pension_credit_age_conditions", year) + ) + return values + + +def expected_closure(unit, year, pension_route): + reported = unit["reported"] + tax_credits = ( + reported["child_tax_credit_reported"] + reported["working_tax_credit_reported"] + ) + is_or_jsa = reported["income_support_reported"] + reported["jsa_income_reported"] + closed = (tax_credits > 0 and year >= 2025) or (is_or_jsa > 0 and year >= 2026) + return closed and not pension_route + + +@PROPERTY_SETTINGS +@given(st.lists(families(), min_size=1, max_size=25)) +def test_closure_claims_and_the_end_of_legacy_awards(units): + values = calculate(units) + for year in YEARS: + v = values[year] + for i, unit in enumerate(units): + closed = expected_closure(unit, year, bool(v["pension_route"][i])) + assert bool(v["legacy_benefits_closed"][i]) == closed, (year, unit) + claims = unit["would_claim_uc"] or (closed and unit["claims_at_closure"]) + assert bool(v["claims_universal_credit"][i]) == claims, (year, unit) + if not claims: + assert v["universal_credit"][i] == 0, (year, unit) + if closed: + for benefit in BENEFITS[1:]: + assert v[benefit][i] == 0, (year, benefit, unit) + + +@PROPERTY_SETTINGS +@given(st.lists(families(), min_size=1, max_size=25)) +def test_the_closure_flag_only_decides_closure_claims(units): + drawn = calculate(units) + negated = calculate(units, negate_flag=True) + for year in YEARS: + for i, unit in enumerate(units): + decides = ( + bool(drawn[year]["legacy_benefits_closed"][i]) + and not unit["would_claim_uc"] + ) + if year == 2024: + assert not decides, unit + if decides: + continue + for benefit in BENEFITS: + assert drawn[year][benefit][i] == negated[year][benefit][i], ( + year, + benefit, + unit, + ) + + +@PROPERTY_SETTINGS +@given(st.lists(families(), min_size=1, max_size=25)) +def test_a_closure_claim_pays_the_universal_credit_of_any_claim(units): + for unit in units: + unit["claims_at_closure"] = True + at_closure = calculate(units, would_claim_uc=False) + anyway = calculate(units, would_claim_uc=True) + for year in YEARS: + closed = at_closure[year]["legacy_benefits_closed"] + for i, unit in enumerate(units): + if closed[i]: + for benefit in BENEFITS: + assert ( + abs(at_closure[year][benefit][i] - anyway[year][benefit][i]) + < 0.01 + ), (year, benefit, unit) diff --git a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py index 4810792498..299c81c1e5 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py @@ -15,4 +15,11 @@ def formula(benunit, period, parameters): ESA = parameters(period).gov.dwp.ESA.income capital = benunit("esa_income_assessable_capital", period) reported_award = add(benunit, period, ["esa_income_reported"]) > 0 - return reported_award & (capital <= ESA.capital.limit) + # Once one of the family's legacy benefits has closed, DWP's + # migration notice ends this award too: a Universal Credit claim + # brings the abolition of income-related ESA (Welfare Reform Act + # 2012 s. 33(1)) into force for the claimant (see SI 2025/1148 + # art. 3A(3)), and a family that does not claim loses the award at the + # notice's deadline (SI 2014/1230 reg 46(1)(a)). + closed = benunit("legacy_benefits_closed", period) + return reported_award & ~closed & (capital <= ESA.capital.limit) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_eligible.py b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_eligible.py index f3955f6b5a..cb4219df82 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_eligible.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_eligible.py @@ -11,8 +11,10 @@ class housing_benefit_eligible(Variable): "Pension Credit " "can make a new claim; Universal Credit is not available to it. Any " "other family keeps Housing Benefit only while it continues an " - "existing award (reported Housing Benefit) and does not claim " - "Universal Credit. Claims for specified or temporary accommodation " + "existing award (reported Housing Benefit), does not claim " + "Universal Credit and has not been moved off a closed legacy benefit " + "(legacy_benefits_closed). Claims for specified or temporary " + "accommodation " "are not modelled." ) definition_period = YEAR @@ -20,6 +22,7 @@ class housing_benefit_eligible(Variable): "https://www.legislation.gov.uk/uksi/2014/1230/regulation/6A", "https://www.legislation.gov.uk/ukpga/2002/16/section/4", "https://www.legislation.gov.uk/uksi/2014/1230/regulation/8", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/46", "https://www.legislation.gov.uk/uksi/2019/37/article/4", "https://www.legislation.gov.uk/ukpga/2012/5/section/4", "https://www.legislation.gov.uk/nisr/2016/226/regulation/4A", @@ -47,8 +50,12 @@ def formula(benunit, period, parameters): # specified or temporary accommodation ended on 1 July 2026 in Great # Britain (SI 2025/1148 art. 7); that is not modelled. already_claiming = add(benunit, period, ["housing_benefit_reported"]) > 0 - claiming_uc = benunit("would_claim_uc", period) - continuing_award = already_claiming & ~claiming_uc + claiming_uc = benunit("claims_universal_credit", period) + # Once another of the family's legacy benefits has closed, DWP's + # migration notice ends this award even if the family does not claim + # Universal Credit (reg 46(1)(a), (2)). + migrated = benunit("legacy_benefits_closed", period) + continuing_award = already_claiming & ~claiming_uc & ~migrated social = benunit.any(person("in_social_housing", period)) lha_eligible = benunit("LHA_eligible", period) any_over_SP_age = benunit.any(sp_age) diff --git a/policyengine_uk/variables/gov/dwp/income_support.py b/policyengine_uk/variables/gov/dwp/income_support.py index 73b673bec1..1f9cd8ec94 100644 --- a/policyengine_uk/variables/gov/dwp/income_support.py +++ b/policyengine_uk/variables/gov/dwp/income_support.py @@ -12,4 +12,8 @@ class income_support(Variable): def formula(benunit, period, parameters): if not parameters(period).gov.dwp.income_support.active: return benunit.empty_array() - return benunit("income_support_entitlement", period) + # Once one of the family's legacy benefits has closed, DWP's + # migration notice ends this award too: on a Universal Credit claim + # (SI 2014/1230 reg 8(2A)) or at the notice's deadline (reg 46(1)(a)). + closed = benunit("legacy_benefits_closed", period) + return where(closed, 0, benunit("income_support_entitlement", period)) diff --git a/policyengine_uk/variables/gov/dwp/is_CTC_eligible.py b/policyengine_uk/variables/gov/dwp/is_CTC_eligible.py index e04b69a5fb..2fb86b2f7e 100644 --- a/policyengine_uk/variables/gov/dwp/is_CTC_eligible.py +++ b/policyengine_uk/variables/gov/dwp/is_CTC_eligible.py @@ -9,9 +9,9 @@ class is_CTC_eligible(Variable): reference = "Tax Credits Act 2002 s. 8" def formula(benunit, period, parameters): - already_claiming = (add(benunit, period, ["child_tax_credit_reported"]) > 0) & ( - ~add(benunit, period, ["would_claim_uc"]) > 0 - ) + already_claiming = ( + add(benunit, period, ["child_tax_credit_reported"]) > 0 + ) & ~benunit("claims_universal_credit", period) return ( benunit.any( benunit.members( diff --git a/policyengine_uk/variables/gov/dwp/jsa_income_eligible.py b/policyengine_uk/variables/gov/dwp/jsa_income_eligible.py index 30f366cd52..2a2e383e17 100644 --- a/policyengine_uk/variables/gov/dwp/jsa_income_eligible.py +++ b/policyengine_uk/variables/gov/dwp/jsa_income_eligible.py @@ -15,4 +15,11 @@ def formula(benunit, period, parameters): JSA = parameters(period).gov.dwp.JSA.income capital = benunit("jsa_income_assessable_capital", period) reported_award = add(benunit, period, ["jsa_income_reported"]) > 0 - return reported_award & (capital <= JSA.capital.limit) + # Once one of the family's legacy benefits has closed, DWP's + # migration notice ends this award too: a Universal Credit claim + # brings the abolition of income-based JSA (Welfare Reform Act + # 2012 s. 33(1)) into force for the claimant (see SI 2025/1148 + # art. 3A(3)), and a family that does not claim loses the award at the + # notice's deadline (SI 2014/1230 reg 46(1)(a)). + closed = benunit("legacy_benefits_closed", period) + return reported_award & ~closed & (capital <= JSA.capital.limit) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/claims_universal_credit.py b/policyengine_uk/variables/gov/dwp/universal_credit/claims_universal_credit.py new file mode 100644 index 0000000000..713f1ee2aa --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/claims_universal_credit.py @@ -0,0 +1,25 @@ +from policyengine_uk.model_api import * + + +class claims_universal_credit(Variable): + value_type = bool + entity = BenUnit + label = "Claims Universal Credit" + documentation = ( + "Whether this family claims Universal Credit: it would claim it " + "anyway (would_claim_uc), or its legacy benefits have closed " + "(legacy_benefits_closed) and it would claim when that happens " + "(would_claim_uc_at_legacy_closure). Universal Credit is paid only " + "to claiming families, and a claim ends any legacy award." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/8", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/46", + ) + + def formula(benunit, period, parameters): + claims_at_closure = benunit("legacy_benefits_closed", period) & benunit( + "would_claim_uc_at_legacy_closure", period + ) + return benunit("would_claim_uc", period) | claims_at_closure diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_has_deduction.py b/policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_has_deduction.py index 10af0ade40..9ae0e218f1 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_has_deduction.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_has_deduction.py @@ -39,6 +39,6 @@ def formula(benunit, period, parameters): - benunit("benefit_cap_reduction", period), 0, ) - on_uc = benunit("would_claim_uc", period) & (award > 0) + on_uc = benunit("claims_universal_credit", period) & (award > 0) draw = benunit("uc_deduction_random_draw", period) return on_uc & (draw < adjusted_incidence) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/legacy_benefits_closed.py b/policyengine_uk/variables/gov/dwp/universal_credit/legacy_benefits_closed.py new file mode 100644 index 0000000000..7e057be04a --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/legacy_benefits_closed.py @@ -0,0 +1,49 @@ +from policyengine_uk.model_api import * + + +class legacy_benefits_closed(Variable): + value_type = bool + entity = BenUnit + label = "Legacy benefits closed" + documentation = ( + "Whether a legacy means-tested benefit this working-age family " + "reports has closed: tax credits from 6 April 2025, and Income " + "Support and income-based Jobseeker's Allowance from 1 April 2026 " + "(their active parameters). DWP moved these families off legacy " + "benefits with a migration notice covering all their legacy awards, " + "so once one closes they all end: on a Universal Credit claim, or at " + "the notice's deadline if the family does not claim. Housing Benefit " + "and income-related Employment and Support Allowance join this list " + "when the model ends their working-age awards. Families on the " + "pension-age route are not moved to Universal Credit." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/8", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/44", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/46", + "https://www.legislation.gov.uk/ukpga/2012/5/section/33", + "https://www.legislation.gov.uk/uksi/2025/1148/article/3A", + ) + + def formula(benunit, period, parameters): + dwp = parameters(period).gov.dwp + + def reports(*variables): + return add(benunit, period, list(variables)) > 0 + + tax_credits_closed = reports( + "child_tax_credit_reported", "working_tax_credit_reported" + ) & (not dwp.tax_credits.active) + income_support_closed = reports("income_support_reported") & ( + not dwp.income_support.active + ) + jsa_income_closed = reports("jsa_income_reported") & (not dwp.JSA.income.active) + # The transitional rules terminating legacy awards on a Universal + # Credit claim do not apply to a claimant, or both joint claimants, + # over the qualifying age for State Pension Credit (SI 2014/1230 + # reg 8(2B)); those families move to Pension Credit instead. + pension_route = benunit("meets_pension_credit_age_conditions", period) + return ( + tax_credits_closed | income_support_closed | jsa_income_closed + ) & ~pension_route diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/universal_credit.py b/policyengine_uk/variables/gov/dwp/universal_credit/universal_credit.py index f30621334a..a77e418ca6 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/universal_credit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/universal_credit.py @@ -7,7 +7,7 @@ class universal_credit(Variable): definition_period = YEAR value_type = float unit = GBP - defined_for = "would_claim_uc" + defined_for = "claims_universal_credit" def formula(benunit, period, parameters): uc_max_entitlement = benunit("universal_credit_pre_benefit_cap", period) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/universal_credit_pre_benefit_cap.py b/policyengine_uk/variables/gov/dwp/universal_credit/universal_credit_pre_benefit_cap.py index 7f6392708e..b91aafd76a 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/universal_credit_pre_benefit_cap.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/universal_credit_pre_benefit_cap.py @@ -9,7 +9,7 @@ class universal_credit_pre_benefit_cap(Variable): definition_period = YEAR unit = GBP category = BENEFIT - defined_for = "would_claim_uc" + defined_for = "claims_universal_credit" adds = ["uc_maximum_amount"] subtracts = ["uc_income_reduction"] diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/would_claim_uc_at_legacy_closure.py b/policyengine_uk/variables/gov/dwp/universal_credit/would_claim_uc_at_legacy_closure.py new file mode 100644 index 0000000000..05e75d08f5 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/would_claim_uc_at_legacy_closure.py @@ -0,0 +1,17 @@ +from policyengine_uk.model_api import * + + +class would_claim_uc_at_legacy_closure(Variable): + value_type = bool + entity = BenUnit + label = "Would claim Universal Credit when its legacy benefits close" + documentation = ( + "Whether this family would claim Universal Credit once a legacy " + "means-tested benefit it receives closes (legacy_benefits_closed). " + "Datasets draw it at DWP's Move to Universal Credit claim rates by " + "legacy benefit combination. It does not affect years before the " + "closure, when would_claim_uc alone decides the Universal Credit " + "claim. Defaults to True, like would_claim_uc." + ) + definition_period = YEAR + default_value = True From 012480e674d9c07e96a8ed5e7215319c2dc4a029 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 16:15:06 -0400 Subject: [PATCH 2/8] Test that the tax credit closure ends awards paid the year before Co-Authored-By: Claude Opus 5.5 --- .../test_uc_legacy_closure_properties.py | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/policyengine_uk/tests/test_uc_legacy_closure_properties.py b/policyengine_uk/tests/test_uc_legacy_closure_properties.py index e8f49ac5d3..245ecf17e9 100644 --- a/policyengine_uk/tests/test_uc_legacy_closure_properties.py +++ b/policyengine_uk/tests/test_uc_legacy_closure_properties.py @@ -213,3 +213,51 @@ def test_a_closure_claim_pays_the_universal_credit_of_any_claim(units): abs(at_closure[year][benefit][i] - anyway[year][benefit][i]) < 0.01 ), (year, benefit, unit) + + +@st.composite +def paid_legacy_families(draw): + """Working-age families on tax credits and one other legacy award they + are paid in 2024: no earnings or savings, and for Income Support a lone + parent with a child under 5.""" + other = draw( + st.sampled_from( + ["income_support_reported", "esa_income_reported", "jsa_income_reported"] + ) + ) + members = [("claimant", draw(st.integers(18, 60)))] + members.append(("child", draw(st.integers(0, 4)))) + reported = {name: 0.0 for name in LEGACY} + reported["child_tax_credit_reported"] = draw(st.sampled_from([2_000.0, 6_000.0])) + reported[other] = draw(st.sampled_from([2_000.0, 6_000.0])) + reported["housing_benefit_reported"] = draw(st.sampled_from([0.0, 3_000.0])) + return dict( + shape="lone_parent", + members=members, + reported=reported, + tenure="RENT_FROM_COUNCIL", + rent=draw(st.floats(1_000, 8_000)), + earnings=0.0, + savings=0.0, + would_claim_uc=False, + claims_at_closure=draw(st.booleans()), + other=other, + ) + + +@PROPERTY_SETTINGS +@given(st.lists(paid_legacy_families(), min_size=1, max_size=25)) +def test_the_tax_credit_closure_ends_awards_paid_the_year_before(units): + values = calculate(units) + benefit = { + "income_support_reported": "income_support", + "esa_income_reported": "esa_income", + "jsa_income_reported": "jsa_income", + } + for i, unit in enumerate(units): + other = benefit[unit["other"]] + # The generator only makes families paid the award before 2025. + assert values[2024][other][i] > 0, unit + assert values[2025]["legacy_benefits_closed"][i], unit + assert values[2025][other][i] == 0, unit + assert values[2025]["housing_benefit"][i] == 0, unit From 4313fbd3a0647cef624970dce9375945886621ff Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 16:45:29 -0400 Subject: [PATCH 3/8] Route closing legacy families by DWP destination, including pension-age WTC migrants Review r1 found that excluding every family on the Pension Credit route left out families DWP moved to Universal Credit: protected mixed-age couples and pension-age claimants on Working Tax Credit without Pension Credit (SI 2019/167 art. 3A; SI 2014/1230 reg 60A; DWP HB circular A9/2024). - legacy_benefits_closed now sends to Pension Credit only families on it, and families on Child Tax Credit but not Working Tax Credit that meet the Pension Credit age conditions (art. 3A(1)). IS and income-based JSA closures do not move wholly pension-age families (reg 8(2B)). - claims_uc_at_legacy_closure: the closure claim. - is_uc_eligible admits a closure claimant on the pension-age route: a protected mixed-age couple claims jointly, and a pension-age WTC recipient has the upper age condition waived (reg 60A). - left_pension_route_at_legacy_closure: such a family leaves the route if it claims, or as a protected mixed-age couple loses its Housing Benefit and saving at the deadline; Housing Benefit's pension-age route and Pension Credit eligibility honour it. - Tax credits (CTC and WTC) are masked for families moved off legacy benefits, so a reform keeping tax credits open does not pay them with UC. - Documentation: claims_universal_credit's scope, the UC notebook's two claim routes and how to decline both. - Tests: seven YAML cases for these routes and the reform; property tests now generate mixed-age couples, pension-age families and Pension Credit reporters, and check UC/PC and UC/HB exclusivity. The childcare grant overlap test pins legacy_benefits_closed false to keep testing the overlap. Co-Authored-By: Claude Opus 5.5 --- .../uc-claim-at-legacy-closure.added.md | 2 +- .../programs/gov/dwp/universal-credit.ipynb | 2 +- .../claims_universal_credit.yaml | 196 ++++++++++++++++++ .../dfe/childcare_grant/childcare_grant.yaml | 2 + .../test_uc_legacy_closure_properties.py | 146 +++++++++---- .../housing_benefit_eligible.py | 8 +- .../variables/gov/dwp/is_CTC_eligible.py | 5 +- .../variables/gov/dwp/is_WTC_eligible.py | 6 +- .../is_pension_credit_eligible.py | 7 +- .../left_pension_route_at_legacy_closure.py | 32 +++ .../claims_uc_at_legacy_closure.py | 19 ++ .../claims_universal_credit.py | 18 +- .../dwp/universal_credit/is_uc_eligible.py | 20 +- .../legacy_benefits_closed.py | 66 ++++-- 14 files changed, 446 insertions(+), 83 deletions(-) create mode 100644 policyengine_uk/variables/gov/dwp/pension_credit/left_pension_route_at_legacy_closure.py create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/claims_uc_at_legacy_closure.py diff --git a/changelog.d/uc-claim-at-legacy-closure.added.md b/changelog.d/uc-claim-at-legacy-closure.added.md index 477e81ba64..d5f04c7359 100644 --- a/changelog.d/uc-claim-at-legacy-closure.added.md +++ b/changelog.d/uc-claim-at-legacy-closure.added.md @@ -1 +1 @@ -- Move working-age families onto Universal Credit when a legacy benefit they receive closes (tax credits from 2025-26, Income Support and income-based JSA from 2026-27). A new dataset input, `would_claim_uc_at_legacy_closure` (default true), says whether the family claims; `claims_universal_credit` replaces `would_claim_uc` wherever the model asks whether a family claims Universal Credit; and the family's other legacy awards (Housing Benefit, Income Support, income-related ESA, income-based JSA) end at the closure whether or not it claims (SI 2014/1230 regs 8 and 46). +- Move families onto Universal Credit when DWP closes a legacy benefit they receive: tax credits from 2025-26, Income Support and income-based JSA from 2026-27. Families on Pension Credit, and families on Child Tax Credit only that meet the Pension Credit age conditions, go to Pension Credit instead (SI 2019/167 art. 3A). Pension-age and protected mixed-age families on Working Tax Credit can claim Universal Credit (SI 2014/1230 reg 60A). A new dataset input, `would_claim_uc_at_legacy_closure` (default true), says whether the family claims. `claims_universal_credit` replaces `would_claim_uc` wherever the model asks whether a family claims Universal Credit. The family's other legacy awards end at the closure whether or not it claims (SI 2014/1230 regs 8 and 46). diff --git a/docs/book/programs/gov/dwp/universal-credit.ipynb b/docs/book/programs/gov/dwp/universal-credit.ipynb index 5e4ecbe443..90a79b5611 100644 --- a/docs/book/programs/gov/dwp/universal-credit.ipynb +++ b/docs/book/programs/gov/dwp/universal-credit.ipynb @@ -44,7 +44,7 @@ { "cell_type": "markdown", "metadata": {}, - "source": "## How PolicyEngine computes Universal Credit\n\nPolicyEngine computes Universal Credit at the benefit-unit level in three stages:\n\n1. **Maximum entitlement** (`uc_maximum_amount`): sum the elements the benunit qualifies for — `uc_standard_allowance`, `uc_child_element`, `uc_disability_elements`, `uc_carer_element`, `uc_housing_costs_element`, and `uc_childcare_element`.\n2. **Means test**: the maximum entitlement is reduced by the benunit's countable earnings above its work allowance (`uc_work_allowance`) at the published taper rate (the `gov.dwp.universal_credit.means_test.reduction_rate` parameter, currently 55%), plus all of its unearned income (`uc_unearned_income`), pound for pound. Unearned income is the list in `gov.dwp.universal_credit.means_test.income_definitions.unearned`: retirement pension income (State Pension and private pensions, UC Regulations 2013 regs 66(1)(a) and 67), Carer's Allowance, carer support payment (up to the Carer's Allowance rate, from November 2023), contribution-based JSA and ESA, maternity allowance, industrial injuries benefit, and the tariff income deemed from capital (reg 72). Actual savings interest, dividends and ordinary rental income are excluded from UC unearned income at every capital level; capital counts through its tariff income instead. Asset rules apply on top of this — capital above the lower threshold deems a tariff income, and capital above the upper threshold disqualifies the benunit entirely.\n3. **Benefit cap**: the post-means-test award (`universal_credit_pre_benefit_cap`) is finally reduced by `benefit_cap_reduction` to produce `universal_credit`. The benefit cap only applies to benunits without a benefit-cap exemption (working enough hours, having a qualifying disability benefit, etc.).\n\nThe take-up step is handled by the input variable `would_claim_uc`, which is populated stochastically when the dataset is built so that PolicyEngine's caseload aggregates match published DWP claimant numbers rather than the full eligible population.\n\nParameters live in `policyengine_uk/parameters/gov/dwp/universal_credit/` and the per-element formulas in `policyengine_uk/variables/gov/dwp/universal_credit/`." + "source": "## How PolicyEngine computes Universal Credit\n\nPolicyEngine computes Universal Credit at the benefit-unit level in three stages:\n\n1. **Maximum entitlement** (`uc_maximum_amount`): sum the elements the benunit qualifies for — `uc_standard_allowance`, `uc_child_element`, `uc_disability_elements`, `uc_carer_element`, `uc_housing_costs_element`, and `uc_childcare_element`.\n2. **Means test**: the maximum entitlement is reduced by the benunit's countable earnings above its work allowance (`uc_work_allowance`) at the published taper rate (the `gov.dwp.universal_credit.means_test.reduction_rate` parameter, currently 55%), plus all of its unearned income (`uc_unearned_income`), pound for pound. Unearned income is the list in `gov.dwp.universal_credit.means_test.income_definitions.unearned`: retirement pension income (State Pension and private pensions, UC Regulations 2013 regs 66(1)(a) and 67), Carer's Allowance, carer support payment (up to the Carer's Allowance rate, from November 2023), contribution-based JSA and ESA, maternity allowance, industrial injuries benefit, and the tariff income deemed from capital (reg 72). Actual savings interest, dividends and ordinary rental income are excluded from UC unearned income at every capital level; capital counts through its tariff income instead. Asset rules apply on top of this — capital above the lower threshold deems a tariff income, and capital above the upper threshold disqualifies the benunit entirely.\n3. **Benefit cap**: the post-means-test award (`universal_credit_pre_benefit_cap`) is finally reduced by `benefit_cap_reduction` to produce `universal_credit`. The benefit cap only applies to benunits without a benefit-cap exemption (working enough hours, having a qualifying disability benefit, etc.).\n\nUniversal Credit is paid only to families that claim it (`claims_universal_credit`). There are two routes. The input `would_claim_uc` is drawn stochastically when the dataset is built, so that caseload aggregates match published DWP claimant numbers rather than the full eligible population. The second route covers a family whose legacy benefit has closed: tax credits from 2025-26, Income Support and income-based JSA from 2026-27 (`legacy_benefits_closed`). DWP's migration notice ended all such a family's legacy awards, and it claims Universal Credit if the input `would_claim_uc_at_legacy_closure` is true. The dataset draws that input at DWP's Move to Universal Credit claim rates by legacy benefit combination. Families DWP sent to Pension Credit instead are excluded: those on Pension Credit, and those on Child Tax Credit only that meet the Pension Credit age conditions. Pension-age and protected mixed-age families on Working Tax Credit are included (SI 2019/167 art. 3A; SI 2014/1230 reg 60A). Both inputs default to true. To model a family that does not claim, set both to false. Transitional protection, including the transitional capital disregard (SI 2014/1230 regs 48-57), is not modelled.\n\nParameters live in `policyengine_uk/parameters/gov/dwp/universal_credit/` and the per-element formulas in `policyengine_uk/variables/gov/dwp/universal_credit/`." }, { "cell_type": "code", diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/claims_universal_credit.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/claims_universal_credit.yaml index fb158ebfdb..f676b11370 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/claims_universal_credit.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/claims_universal_credit.yaml @@ -297,3 +297,199 @@ output: legacy_benefits_closed: false claims_universal_credit: true + +# Pension-age and protected mixed-age families. A tax credit closure notice +# sent families on Pension Credit, and families on Child Tax Credit but not +# Working Tax Credit that meet the Pension Credit age conditions, to Pension +# Credit (SI 2019/167 art. 3A(1)). Families on Working Tax Credit got a +# Universal Credit migration notice, and a pension-age one not entitled to +# Pension Credit has the Universal Credit upper age condition waived +# (SI 2014/1230 reg 60A; DWP HB circular A9/2024 paras 5-14). + +- name: A protected mixed-age couple on Working Tax Credit claims Universal Credit at the closure and leaves the pension-age route + period: 2025 + input: + people: + older: + age: 72 + is_claimant_or_partner: true + working_tax_credit_reported: 2_000 + housing_benefit_reported: 4_000 + younger: + age: 40 + is_claimant_or_partner: true + benunits: + benunit: + members: [older, younger] + has_mixed_age_couple_pension_credit_saving: true + would_claim_uc: false + would_claim_uc_at_legacy_closure: true + households: + household: + members: [older, younger] + tenure_type: RENT_FROM_COUNCIL + output: + meets_pension_credit_age_conditions: true + legacy_benefits_closed: true + claims_universal_credit: true + is_uc_eligible: true + left_pension_route_at_legacy_closure: true + housing_benefit_eligible: false + is_pension_credit_eligible: false + +- name: A protected mixed-age couple on Working Tax Credit that does not claim loses its Housing Benefit and the saving + period: 2025 + input: + people: + older: + age: 72 + is_claimant_or_partner: true + working_tax_credit_reported: 2_000 + housing_benefit_reported: 4_000 + younger: + age: 40 + is_claimant_or_partner: true + benunits: + benunit: + members: [older, younger] + has_mixed_age_couple_pension_credit_saving: true + would_claim_uc: false + would_claim_uc_at_legacy_closure: false + households: + household: + members: [older, younger] + tenure_type: RENT_FROM_COUNCIL + output: + legacy_benefits_closed: true + claims_universal_credit: false + left_pension_route_at_legacy_closure: true + housing_benefit_eligible: false + is_pension_credit_eligible: false + universal_credit: 0 + +- name: A protected mixed-age couple on Child Tax Credit only takes the Pension Credit route and keeps its Housing Benefit + period: 2025 + input: + people: + older: + age: 72 + is_claimant_or_partner: true + child_tax_credit_reported: 3_000 + housing_benefit_reported: 4_000 + younger: + age: 40 + is_claimant_or_partner: true + child: + age: 10 + is_claimant_or_partner: false + benunits: + benunit: + members: [older, younger, child] + has_mixed_age_couple_pension_credit_saving: true + would_claim_uc: false + would_claim_uc_at_legacy_closure: true + households: + household: + members: [older, younger, child] + tenure_type: RENT_FROM_COUNCIL + output: + legacy_benefits_closed: false + claims_universal_credit: false + left_pension_route_at_legacy_closure: false + housing_benefit_eligible: true + +- name: A pension-age Working Tax Credit recipient not on Pension Credit can claim Universal Credit at the closure + period: 2025 + input: + people: + adult: + age: 70 + working_tax_credit_reported: 2_000 + housing_benefit_reported: 4_000 + benunits: + benunit: + members: [adult] + would_claim_uc: false + would_claim_uc_at_legacy_closure: true + households: + household: + members: [adult] + tenure_type: RENT_FROM_COUNCIL + output: + legacy_benefits_closed: true + claims_universal_credit: true + is_uc_eligible: true + housing_benefit_eligible: false + is_pension_credit_eligible: false + +- name: A pension-age Working Tax Credit recipient that does not claim stays on the pension-age route + period: 2025 + input: + people: + adult: + age: 70 + working_tax_credit_reported: 2_000 + housing_benefit_reported: 4_000 + benunits: + benunit: + members: [adult] + would_claim_uc: false + would_claim_uc_at_legacy_closure: false + households: + household: + members: [adult] + tenure_type: RENT_FROM_COUNCIL + output: + legacy_benefits_closed: true + claims_universal_credit: false + is_uc_eligible: false + left_pension_route_at_legacy_closure: false + housing_benefit_eligible: true + +- name: A pension-age Working Tax Credit recipient already on Pension Credit takes the Pension Credit route + period: 2025 + input: + people: + adult: + age: 70 + working_tax_credit_reported: 2_000 + pension_credit_reported: 1_000 + benunits: + benunit: + members: [adult] + would_claim_uc: false + would_claim_uc_at_legacy_closure: true + households: + household: + members: [adult] + output: + legacy_benefits_closed: false + claims_universal_credit: false + is_uc_eligible: false + +- name: Under a reform keeping tax credits open, a family moved off legacy benefits by the Income Support closure gets no Working Tax Credit + period: 2026 + input: + gov.dwp.tax_credits.active: true + people: + parent: + age: 35 + weekly_hours: 30 + employment_income: 5_000 + working_tax_credit_reported: 3_000 + income_support_reported: 4_000 + child: + age: 3 + benunits: + benunit: + members: [parent, child] + would_claim_uc: false + would_claim_uc_at_legacy_closure: true + households: + household: + members: [parent, child] + output: + legacy_benefits_closed: true + claims_universal_credit: true + is_WTC_eligible: false + tax_credits: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/dfe/childcare_grant/childcare_grant.yaml b/policyengine_uk/tests/policy/baseline/gov/dfe/childcare_grant/childcare_grant.yaml index 760c906392..a908734e91 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dfe/childcare_grant/childcare_grant.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dfe/childcare_grant/childcare_grant.yaml @@ -324,6 +324,8 @@ benunits: benunit: members: ["student", "child"] + # Tax credits closed in 2025; keep the award to test the overlap. + legacy_benefits_closed: false households: household: members: ["student", "child"] diff --git a/policyengine_uk/tests/test_uc_legacy_closure_properties.py b/policyengine_uk/tests/test_uc_legacy_closure_properties.py index 245ecf17e9..3af9c852a9 100644 --- a/policyengine_uk/tests/test_uc_legacy_closure_properties.py +++ b/policyengine_uk/tests/test_uc_legacy_closure_properties.py @@ -1,28 +1,38 @@ """Property-based tests for Universal Credit claims when legacy benefits close. Tax credits closed from 6 April 2025 and Income Support and income-based JSA -from 1 April 2026. DWP moved each working-age family off its legacy benefits -with a migration notice covering all of them: the awards end on a Universal -Credit claim (SI 2014/1230 reg 8) or at the notice's deadline if the family -does not claim (reg 46). Datasets say which families claim through +from 1 April 2026. DWP moved each family off its legacy benefits with a notice +covering all of them. A Universal Credit migration notice ends the awards on a +claim (SI 2014/1230 reg 8) or at its deadline (reg 46). A tax credit closure +notice sent families on Pension Credit, and families on Child Tax Credit but +not Working Tax Credit that meet the Pension Credit age conditions, to Pension +Credit instead (SI 2019/167 art. 3A). Pension-age families on Working Tax +Credit can claim Universal Credit (reg 60A), and a protected mixed-age couple +claims it jointly. Datasets say which families claim through would_claim_uc_at_legacy_closure. Invariants, for any generated population of families in 2024 to 2027: -1. Closure: legacy_benefits_closed holds exactly for a working-age family - (not on the pension-age route) that reports tax credits from 2025, or - Income Support or income-based JSA from 2026. +1. Closure: legacy_benefits_closed holds exactly for a family that reports + tax credits from 2025 and is not sent to Pension Credit, or reports Income + Support or income-based JSA from 2026, is not on Pension Credit and is not + wholly pension age. 2. Claim: claims_universal_credit is would_claim_uc, or the closure with the closure claim flag; Universal Credit is paid only to claiming families. -3. Migration ends every legacy award: a family whose legacy benefits have - closed gets no Housing Benefit, tax credits, Income Support, - income-related ESA or income-based JSA. -4. Metamorphic, the flag is inert unless it decides a claim: negating - would_claim_uc_at_legacy_closure changes no benefit for a family whose - legacy benefits have not closed or that would claim Universal Credit - anyway. In particular nothing changes in 2024, before any closure. -5. Differential: a family claiming at the closure gets the Universal Credit - it would get if would_claim_uc were true. +3. Migration ends every legacy award: a family moved off legacy benefits gets + no tax credits, Income Support, income-related ESA or income-based JSA. + It gets no Housing Benefit unless it is a wholly pension-age family that + does not claim Universal Credit and so keeps the pension-age route. + A family that leaves that route gets no Pension Credit either. +4. Exclusive: no family gets Universal Credit with Pension Credit or with + Housing Benefit. +5. Metamorphic, the flag is inert unless it decides a claim: negating + would_claim_uc_at_legacy_closure changes no benefit for a family not + moved off legacy benefits, or for a working-age one that would claim + Universal Credit anyway. In particular nothing changes in 2024, before + any closure. +6. Differential: a working-age family claiming at the closure gets the + benefits it would get if would_claim_uc were true. """ import numpy as np @@ -52,6 +62,12 @@ ], "pensioner": [("claimant", PENSION_AGE)], "pensioner_with_child": [("claimant", PENSION_AGE), ("child", st.integers(5, 15))], + "mixed_age": [("claimant", PENSION_AGE), ("partner", WORKING_AGE)], + "mixed_age_parents": [ + ("claimant", PENSION_AGE), + ("partner", WORKING_AGE), + ("child", st.integers(0, 15)), + ], } LEGACY = ( "child_tax_credit_reported", @@ -60,27 +76,39 @@ "esa_income_reported", "income_support_reported", "jsa_income_reported", + "pension_credit_reported", ) TENURES = ["RENT_FROM_COUNCIL", "RENT_PRIVATELY", "OWNED_OUTRIGHT"] BENEFITS = [ "universal_credit", "housing_benefit", + "pension_credit", "tax_credits", "income_support", "esa_income", "jsa_income", ] -OUTPUTS = BENEFITS + ["legacy_benefits_closed", "claims_universal_credit"] +OUTPUTS = BENEFITS + [ + "legacy_benefits_closed", + "claims_universal_credit", + "left_pension_route_at_legacy_closure", + "meets_pension_credit_age_conditions", + "is_mixed_age_couple", +] award = st.sampled_from([0.0, 0.0, 2_000.0, 6_000.0]) @st.composite def families(draw): shape = draw(st.sampled_from(sorted(SHAPES))) + reported = {name: draw(award) for name in LEGACY} + if not shape.startswith(("pensioner", "mixed_age")): + reported["pension_credit_reported"] = 0.0 return dict( shape=shape, members=[(role, draw(age)) for role, age in SHAPES[shape]], - reported={name: draw(award) for name in LEGACY}, + reported=reported, + saving=draw(st.booleans()), tenure=draw(st.sampled_from(TENURES)), rent=draw(st.floats(0, 12_000)), earnings=draw(st.sampled_from([0.0, 8_000.0, 25_000.0])), @@ -123,6 +151,7 @@ def situation(units, negate_flag=False, would_claim_uc=None): "would_claim_uc_at_legacy_closure": every_year( unit["claims_at_closure"] ^ negate_flag ), + "has_mixed_age_couple_pension_credit_saving": every_year(unit["saving"]), "claims_all_entitled_benefits": every_year(False), } households[f"h{i}"] = { @@ -136,24 +165,25 @@ def situation(units, negate_flag=False, would_claim_uc=None): def calculate(units, **kwargs): sim = Simulation(situation=situation(units, **kwargs)) - values = { + return { year: {v: np.asarray(sim.calculate(v, year)) for v in OUTPUTS} for year in YEARS } - for year in YEARS: - values[year]["pension_route"] = np.asarray( - sim.calculate("meets_pension_credit_age_conditions", year) - ) - return values -def expected_closure(unit, year, pension_route): - reported = unit["reported"] - tax_credits = ( - reported["child_tax_credit_reported"] + reported["working_tax_credit_reported"] +def expected_closure(unit, year, pension_age_route, mixed_age): + r = {name: amount > 0 for name, amount in unit["reported"].items()} + wholly_pension_age = pension_age_route and not mixed_age + tax_credits = r["child_tax_credit_reported"] or r["working_tax_credit_reported"] + to_pension_credit = r["pension_credit_reported"] or ( + pension_age_route and not r["working_tax_credit_reported"] + ) + is_or_jsa = r["income_support_reported"] or r["jsa_income_reported"] + return (tax_credits and year >= 2025 and not to_pension_credit) or ( + is_or_jsa + and year >= 2026 + and not r["pension_credit_reported"] + and not wholly_pension_age ) - is_or_jsa = reported["income_support_reported"] + reported["jsa_income_reported"] - closed = (tax_credits > 0 and year >= 2025) or (is_or_jsa > 0 and year >= 2026) - return closed and not pension_route @PROPERTY_SETTINGS @@ -163,15 +193,32 @@ def test_closure_claims_and_the_end_of_legacy_awards(units): for year in YEARS: v = values[year] for i, unit in enumerate(units): - closed = expected_closure(unit, year, bool(v["pension_route"][i])) + pension_age_route = bool(v["meets_pension_credit_age_conditions"][i]) + mixed_age = bool(v["is_mixed_age_couple"][i]) + closed = expected_closure(unit, year, pension_age_route, mixed_age) assert bool(v["legacy_benefits_closed"][i]) == closed, (year, unit) - claims = unit["would_claim_uc"] or (closed and unit["claims_at_closure"]) + claims_at_closure = closed and unit["claims_at_closure"] + claims = unit["would_claim_uc"] or claims_at_closure assert bool(v["claims_universal_credit"][i]) == claims, (year, unit) if not claims: assert v["universal_credit"][i] == 0, (year, unit) - if closed: - for benefit in BENEFITS[1:]: - assert v[benefit][i] == 0, (year, benefit, unit) + assert not (v["universal_credit"][i] > 0 and v["pension_credit"][i] > 0) + assert not (v["universal_credit"][i] > 0 and v["housing_benefit"][i] > 0) + if not closed: + continue + for benefit in [ + "tax_credits", + "income_support", + "esa_income", + "jsa_income", + ]: + assert v[benefit][i] == 0, (year, benefit, unit) + left = pension_age_route and (claims_at_closure or mixed_age) + assert bool(v["left_pension_route_at_legacy_closure"][i]) == left + if left or not pension_age_route: + assert v["housing_benefit"][i] == 0, (year, unit) + if left: + assert v["pension_credit"][i] == 0, (year, unit) @PROPERTY_SETTINGS @@ -181,9 +228,11 @@ def test_the_closure_flag_only_decides_closure_claims(units): negated = calculate(units, negate_flag=True) for year in YEARS: for i, unit in enumerate(units): - decides = ( - bool(drawn[year]["legacy_benefits_closed"][i]) - and not unit["would_claim_uc"] + # On the pension-age route would_claim_uc gives no Universal + # Credit, so there the flag alone decides the closure claim. + decides = bool(drawn[year]["legacy_benefits_closed"][i]) and ( + not unit["would_claim_uc"] + or bool(drawn[year]["meets_pension_credit_age_conditions"][i]) ) if year == 2024: assert not decides, unit @@ -205,14 +254,20 @@ def test_a_closure_claim_pays_the_universal_credit_of_any_claim(units): at_closure = calculate(units, would_claim_uc=False) anyway = calculate(units, would_claim_uc=True) for year in YEARS: - closed = at_closure[year]["legacy_benefits_closed"] + v = at_closure[year] for i, unit in enumerate(units): - if closed[i]: + # On the pension-age route only a closure claim reaches Universal + # Credit, so the two claims differ by design there. + if ( + v["legacy_benefits_closed"][i] + and not (v["meets_pension_credit_age_conditions"][i]) + ): for benefit in BENEFITS: - assert ( - abs(at_closure[year][benefit][i] - anyway[year][benefit][i]) - < 0.01 - ), (year, benefit, unit) + assert abs(v[benefit][i] - anyway[year][benefit][i]) < 0.01, ( + year, + benefit, + unit, + ) @st.composite @@ -235,6 +290,7 @@ def paid_legacy_families(draw): shape="lone_parent", members=members, reported=reported, + saving=False, tenure="RENT_FROM_COUNCIL", rent=draw(st.floats(1_000, 8_000)), earnings=0.0, diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_eligible.py b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_eligible.py index cb4219df82..169f68d81c 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_eligible.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_eligible.py @@ -42,7 +42,13 @@ def formula(benunit, period, parameters): # the claimant and partner (is_claimant_or_partner), so a pensioner # with an 18 or 19 year old dependant can claim Housing Benefit and # not Universal Credit. - pension_age = benunit("meets_pension_credit_age_conditions", period) + # A family DWP moved to Universal Credit leaves this route: it claims + # Universal Credit, or, as a protected mixed-age couple that does not, + # loses its award at the notice's deadline (reg 46(1)(a)) and with it + # the saving (left_pension_route_at_legacy_closure). + pension_age = benunit("meets_pension_credit_age_conditions", period) & ~benunit( + "left_pension_route_at_legacy_closure", period + ) # Working-age families, and mixed-age couples without the saving # (since 15 May 2019), claim Universal Credit instead. They keep an # existing award until they claim it (reg 8(2A)), so this route also diff --git a/policyengine_uk/variables/gov/dwp/is_CTC_eligible.py b/policyengine_uk/variables/gov/dwp/is_CTC_eligible.py index 2fb86b2f7e..2bcaa824eb 100644 --- a/policyengine_uk/variables/gov/dwp/is_CTC_eligible.py +++ b/policyengine_uk/variables/gov/dwp/is_CTC_eligible.py @@ -11,7 +11,10 @@ class is_CTC_eligible(Variable): def formula(benunit, period, parameters): already_claiming = ( add(benunit, period, ["child_tax_credit_reported"]) > 0 - ) & ~benunit("claims_universal_credit", period) + ) & ~( + benunit("claims_universal_credit", period) + | benunit("legacy_benefits_closed", period) + ) return ( benunit.any( benunit.members( diff --git a/policyengine_uk/variables/gov/dwp/is_WTC_eligible.py b/policyengine_uk/variables/gov/dwp/is_WTC_eligible.py index 9152c5078f..84b2d6c770 100644 --- a/policyengine_uk/variables/gov/dwp/is_WTC_eligible.py +++ b/policyengine_uk/variables/gov/dwp/is_WTC_eligible.py @@ -41,7 +41,11 @@ def formula(benunit, period, parameters): meets_medium_person_hours = max_person_hours >= WTC.min_hours.lower meets_medium = meets_medium_total_hours & meets_medium_person_hours meets_higher = total_hours >= WTC.min_hours.default - already_claiming = add(benunit, period, ["working_tax_credit_reported"]) > 0 + # A family DWP moved off its legacy benefits keeps no tax credits, + # even under a reform that restores them (SI 2014/1230 regs 8, 46). + already_claiming = ( + add(benunit, period, ["working_tax_credit_reported"]) > 0 + ) & ~benunit("legacy_benefits_closed", period) return ( (lower_req & meets_lower) | (medium_req & meets_medium) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/is_pension_credit_eligible.py b/policyengine_uk/variables/gov/dwp/pension_credit/is_pension_credit_eligible.py index d970390993..e89ca49424 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/is_pension_credit_eligible.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/is_pension_credit_eligible.py @@ -16,6 +16,9 @@ class is_pension_credit_eligible(Variable): def formula(benunit, period, parameters): is_gc_eligible = benunit("is_guarantee_credit_eligible", period) is_sc_eligible = benunit("is_savings_credit_eligible", period) - return benunit("meets_pension_credit_age_conditions", period) & ( - is_gc_eligible | is_sc_eligible + # A family that left the pension-age route when DWP moved it to + # Universal Credit cannot claim Pension Credit as well. + route = benunit("meets_pension_credit_age_conditions", period) & ~benunit( + "left_pension_route_at_legacy_closure", period ) + return route & (is_gc_eligible | is_sc_eligible) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/left_pension_route_at_legacy_closure.py b/policyengine_uk/variables/gov/dwp/pension_credit/left_pension_route_at_legacy_closure.py new file mode 100644 index 0000000000..d6777d1085 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/pension_credit/left_pension_route_at_legacy_closure.py @@ -0,0 +1,32 @@ +from policyengine_uk.model_api import * + + +class left_pension_route_at_legacy_closure(Variable): + value_type = bool + entity = BenUnit + label = "Left the pension-age route when its legacy benefits closed" + documentation = ( + "Whether a family meeting the Pension Credit age conditions left that " + "route when DWP moved it to Universal Credit (legacy_benefits_closed): " + "it claimed Universal Credit, or it is a protected mixed-age couple " + "whose Housing Benefit ended at the notice's deadline, ending the " + "saving that let it claim Pension Credit or Housing Benefit. A wholly " + "pension-age family that does not claim Universal Credit stays on the " + "route and can claim Pension Credit and Housing Benefit afresh." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/46", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/60A", + "https://www.legislation.gov.uk/uksi/2019/37/article/4", + "https://www.gov.uk/government/publications/housing-benefit-adjudication-circulars-2024/a92024-the-social-security-state-pension-age-claimants-closure-of-tax-credits-amendment-regulations-2024", + ) + + def formula(benunit, period, parameters): + moved = benunit("legacy_benefits_closed", period) & benunit( + "meets_pension_credit_age_conditions", period + ) + return moved & ( + benunit("claims_uc_at_legacy_closure", period) + | benunit("is_mixed_age_couple", period) + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/claims_uc_at_legacy_closure.py b/policyengine_uk/variables/gov/dwp/universal_credit/claims_uc_at_legacy_closure.py new file mode 100644 index 0000000000..2ee3f940cb --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/claims_uc_at_legacy_closure.py @@ -0,0 +1,19 @@ +from policyengine_uk.model_api import * + + +class claims_uc_at_legacy_closure(Variable): + value_type = bool + entity = BenUnit + label = "Claims Universal Credit when its legacy benefits close" + documentation = ( + "Whether this family claims Universal Credit because DWP moved it off " + "its legacy benefits (legacy_benefits_closed) and it would claim then " + "(would_claim_uc_at_legacy_closure)." + ) + definition_period = YEAR + reference = "https://www.legislation.gov.uk/uksi/2014/1230/regulation/44" + + def formula(benunit, period, parameters): + return benunit("legacy_benefits_closed", period) & benunit( + "would_claim_uc_at_legacy_closure", period + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/claims_universal_credit.py b/policyengine_uk/variables/gov/dwp/universal_credit/claims_universal_credit.py index 713f1ee2aa..21a1bdc84f 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/claims_universal_credit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/claims_universal_credit.py @@ -7,10 +7,15 @@ class claims_universal_credit(Variable): label = "Claims Universal Credit" documentation = ( "Whether this family claims Universal Credit: it would claim it " - "anyway (would_claim_uc), or its legacy benefits have closed " - "(legacy_benefits_closed) and it would claim when that happens " - "(would_claim_uc_at_legacy_closure). Universal Credit is paid only " - "to claiming families, and a claim ends any legacy award." + "anyway (would_claim_uc), or it claims when DWP moves it off its " + "legacy benefits (claims_uc_at_legacy_closure). Universal Credit is " + "paid only to claiming families. A claim ends the family's Housing " + "Benefit continuing award and Child Tax Credit. Once DWP moves a " + "family off legacy benefits (legacy_benefits_closed) all its legacy " + "awards end; for other claimants the overlap of Universal Credit " + "with Working Tax Credit, Income Support, income-related ESA and " + "income-based JSA is not modelled, nor is Housing Benefit for " + "specified or temporary accommodation." ) definition_period = YEAR reference = ( @@ -19,7 +24,6 @@ class claims_universal_credit(Variable): ) def formula(benunit, period, parameters): - claims_at_closure = benunit("legacy_benefits_closed", period) & benunit( - "would_claim_uc_at_legacy_closure", period + return benunit("would_claim_uc", period) | benunit( + "claims_uc_at_legacy_closure", period ) - return benunit("would_claim_uc", period) | claims_at_closure diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/is_uc_eligible.py b/policyengine_uk/variables/gov/dwp/universal_credit/is_uc_eligible.py index 214f762c85..0c0ad7ca3e 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/is_uc_eligible.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/is_uc_eligible.py @@ -11,7 +11,9 @@ class is_uc_eligible(Variable): "One qualifying claimant suffices, preserving mixed-age couples under " "regulation 3(2)(a). Dependants cannot satisfy the claimant age test. " "A family on the Pension Credit route " - "(meets_pension_credit_age_conditions) is not eligible." + "(meets_pension_credit_age_conditions) is not eligible unless it " + "claims when DWP moves it off its legacy benefits " + "(claims_uc_at_legacy_closure)." ) definition_period = YEAR reference = ( @@ -19,6 +21,7 @@ class is_uc_eligible(Variable): "https://www.legislation.gov.uk/ukpga/2012/5/section/4", "https://www.legislation.gov.uk/uksi/2013/376/regulation/3", "https://www.legislation.gov.uk/uksi/2014/1230/regulation/5", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/60A", ) def formula(benunit, period, parameters): @@ -39,4 +42,17 @@ def formula(benunit, period, parameters): # couples not reported on Universal Credit to Pension Credit before # 15 May 2019, are modelling choices. pension_credit_route = benunit("meets_pension_credit_age_conditions", period) - return has_qualifying_claimant & ~pension_credit_route & (capital <= limit) + # A family DWP moved off its legacy benefits claims Universal Credit + # even from the pension-age route: a protected mixed-age couple + # claims jointly, giving up the saving (reg 3(2)(a)), and a + # pension-age Working Tax Credit recipient not entitled to Pension + # Credit has the upper age condition waived (SI 2014/1230 reg 60A). + closure_claim = benunit("claims_uc_at_legacy_closure", period) + on_working_tax_credit = ( + add(benunit, period, ["working_tax_credit_reported"]) > 0 + ) + reg_60a = on_working_tax_credit & benunit.any(claimant & meets_minimum_age) + route = (has_qualifying_claimant & ~pension_credit_route) | ( + closure_claim & (has_qualifying_claimant | reg_60a) + ) + return route & (capital <= limit) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/legacy_benefits_closed.py b/policyengine_uk/variables/gov/dwp/universal_credit/legacy_benefits_closed.py index 7e057be04a..fec4ccaa0d 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/legacy_benefits_closed.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/legacy_benefits_closed.py @@ -4,24 +4,30 @@ class legacy_benefits_closed(Variable): value_type = bool entity = BenUnit - label = "Legacy benefits closed" + label = "Moved off legacy benefits onto the Universal Credit route" documentation = ( - "Whether a legacy means-tested benefit this working-age family " - "reports has closed: tax credits from 6 April 2025, and Income " - "Support and income-based Jobseeker's Allowance from 1 April 2026 " - "(their active parameters). DWP moved these families off legacy " - "benefits with a migration notice covering all their legacy awards, " - "so once one closes they all end: on a Universal Credit claim, or at " - "the notice's deadline if the family does not claim. Housing Benefit " - "and income-related Employment and Support Allowance join this list " - "when the model ends their working-age awards. Families on the " - "pension-age route are not moved to Universal Credit." + "Whether DWP has moved this family off its legacy means-tested " + "benefits towards Universal Credit because one it reports has closed: " + "tax credits from 6 April 2025, and Income Support and income-based " + "Jobseeker's Allowance from 1 April 2026 (their active parameters). " + "The migration notice covers all the family's legacy awards, so they " + "all end: on a Universal Credit claim, or at the notice's deadline if " + "the family does not claim. Families DWP sent to Pension Credit " + "instead are excluded: those on Pension Credit, and those on Child Tax " + "Credit but not Working Tax Credit that meet the Pension Credit age " + "conditions. Pension-age and protected mixed-age families on Working " + "Tax Credit are included; Income Support and income-based JSA " + "closures do not move a wholly pension-age family. Housing Benefit and income-related " + "Employment and Support Allowance join this list when the model ends " + "their working-age awards." ) definition_period = YEAR reference = ( "https://www.legislation.gov.uk/uksi/2014/1230/regulation/8", "https://www.legislation.gov.uk/uksi/2014/1230/regulation/44", "https://www.legislation.gov.uk/uksi/2014/1230/regulation/46", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/60A", + "https://www.legislation.gov.uk/uksi/2019/167/article/3A", "https://www.legislation.gov.uk/ukpga/2012/5/section/33", "https://www.legislation.gov.uk/uksi/2025/1148/article/3A", ) @@ -32,18 +38,34 @@ def formula(benunit, period, parameters): def reports(*variables): return add(benunit, period, list(variables)) > 0 - tax_credits_closed = reports( - "child_tax_credit_reported", "working_tax_credit_reported" - ) & (not dwp.tax_credits.active) + on_pension_credit = reports("pension_credit_reported") + pension_age_route = benunit("meets_pension_credit_age_conditions", period) + # A tax credit closure notice sent to Pension Credit, not Universal + # Credit, a family on Pension Credit, or one on Child Tax Credit but + # not Working Tax Credit that meets the Pension Credit age conditions + # (single, both pension age, or a protected mixed-age couple; SI + # 2019/167 art. 3A(1)). Every other tax credit family, including a + # pension-age one on Working Tax Credit (SI 2014/1230 reg 60A), got a + # Universal Credit migration notice (reg 44). + pension_credit_route = on_pension_credit | ( + pension_age_route & ~reports("working_tax_credit_reported") + ) + tax_credits_closed = ( + reports("child_tax_credit_reported", "working_tax_credit_reported") + & (not dwp.tax_credits.active) + & ~pension_credit_route + ) + # Income Support and income-based JSA are working-age benefits; the + # rules ending legacy awards on a Universal Credit claim do not apply + # to a single claimant, or joint claimants both, over the qualifying + # age for State Pension Credit (reg 8(2B)). + wholly_pension_age = pension_age_route & ~benunit("is_mixed_age_couple", period) income_support_closed = reports("income_support_reported") & ( not dwp.income_support.active ) jsa_income_closed = reports("jsa_income_reported") & (not dwp.JSA.income.active) - # The transitional rules terminating legacy awards on a Universal - # Credit claim do not apply to a claimant, or both joint claimants, - # over the qualifying age for State Pension Credit (SI 2014/1230 - # reg 8(2B)); those families move to Pension Credit instead. - pension_route = benunit("meets_pension_credit_age_conditions", period) - return ( - tax_credits_closed | income_support_closed | jsa_income_closed - ) & ~pension_route + return tax_credits_closed | ( + (income_support_closed | jsa_income_closed) + & ~on_pension_credit + & ~wholly_pension_age + ) From 3b2a394e98ff69b405d0b5cf1fac9f471f40fc34 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 17:37:32 -0400 Subject: [PATCH 4/8] Keep non-claiming protected couples on the pension route; count non-dependants for closure claims Review r2 (at cf40b021b) found two blocking issues: - B1: a protected mixed-age couple that does not claim Universal Credit at the closure keeps its route. SI 2019/37 art. 4(3)(b)(ii) (inserted by SI 2024/604) lets it reclaim Housing Benefit within three months and keep the saving (DWP HB circular A9/2024 paras 12-13). left_pension_route_at_ legacy_closure now needs a closure claim and eligibility for Universal Credit, so a claimant refused on capital (reg 51 is not modelled) stays on the route rather than losing everything. - B2: uc_non_dependants_counted, merged from main (#2006), still read would_claim_uc, so a closure claimant's household non-dependants were not counted. It now reads claims_universal_credit. Also: stale notes now that reg 60A migrants are modelled (meets_pension_credit_age_conditions, housing_benefit_pension_age_ regulations_apply, the CTR pensioner comment, the HB passport doc); the changelog credits reg 3(2)(a) for mixed-age couples. Tests: the protected couple case, a capital-refused reg 60A claimant, a non-dependant case, a property that every closure claimant within the capital limit is eligible, and the sharers reference check reads claims_universal_credit. Co-Authored-By: Claude Opus 5.5 --- .../uc-claim-at-legacy-closure.added.md | 2 +- .../claims_universal_credit.yaml | 69 +++++++++++++++++-- ...sharers_boarders_and_lodgers_properties.py | 2 +- .../test_uc_legacy_closure_properties.py | 17 +++-- .../housing_benefit_eligible.py | 9 +-- .../housing_benefit_on_passporting_benefit.py | 2 +- ...g_benefit_pension_age_regulations_apply.py | 10 +-- .../left_pension_route_at_legacy_closure.py | 24 ++++--- .../meets_pension_credit_age_conditions.py | 4 +- .../uc_non_dependants_counted.py | 2 +- .../council_tax_reduction_pensioner.py | 7 +- 11 files changed, 116 insertions(+), 32 deletions(-) diff --git a/changelog.d/uc-claim-at-legacy-closure.added.md b/changelog.d/uc-claim-at-legacy-closure.added.md index d5f04c7359..2da4fc8b22 100644 --- a/changelog.d/uc-claim-at-legacy-closure.added.md +++ b/changelog.d/uc-claim-at-legacy-closure.added.md @@ -1 +1 @@ -- Move families onto Universal Credit when DWP closes a legacy benefit they receive: tax credits from 2025-26, Income Support and income-based JSA from 2026-27. Families on Pension Credit, and families on Child Tax Credit only that meet the Pension Credit age conditions, go to Pension Credit instead (SI 2019/167 art. 3A). Pension-age and protected mixed-age families on Working Tax Credit can claim Universal Credit (SI 2014/1230 reg 60A). A new dataset input, `would_claim_uc_at_legacy_closure` (default true), says whether the family claims. `claims_universal_credit` replaces `would_claim_uc` wherever the model asks whether a family claims Universal Credit. The family's other legacy awards end at the closure whether or not it claims (SI 2014/1230 regs 8 and 46). +- Move families onto Universal Credit when DWP closes a legacy benefit they receive: tax credits from 2025-26, Income Support and income-based JSA from 2026-27. Families on Pension Credit, and families on Child Tax Credit only that meet the Pension Credit age conditions, go to Pension Credit instead (SI 2019/167 art. 3A). Pension-age families on Working Tax Credit can claim Universal Credit with the upper age condition waived (SI 2014/1230 reg 60A), and protected mixed-age couples on it can claim jointly (UC Regulations 2013 reg 3(2)(a)); either leaves the pension-age route only by claiming and being eligible. A new dataset input, `would_claim_uc_at_legacy_closure` (default true), says whether the family claims. `claims_universal_credit` replaces `would_claim_uc` wherever the model asks whether a family claims Universal Credit, including whose claim counts a household's non-dependants. The family's other legacy awards end at the closure whether or not it claims (SI 2014/1230 regs 8 and 46). diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/claims_universal_credit.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/claims_universal_credit.yaml index f676b11370..4e72a3c1c8 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/claims_universal_credit.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/claims_universal_credit.yaml @@ -337,7 +337,9 @@ housing_benefit_eligible: false is_pension_credit_eligible: false -- name: A protected mixed-age couple on Working Tax Credit that does not claim loses its Housing Benefit and the saving +- name: A protected mixed-age couple on Working Tax Credit that does not claim keeps the pension-age route + # SI 2019/37 art. 4(3)(b)(ii) lets it reclaim Housing Benefit and keep the + # saving (DWP HB circular A9/2024 paras 12-13). period: 2025 input: people: @@ -362,9 +364,9 @@ output: legacy_benefits_closed: true claims_universal_credit: false - left_pension_route_at_legacy_closure: true - housing_benefit_eligible: false - is_pension_credit_eligible: false + left_pension_route_at_legacy_closure: false + housing_benefit_eligible: true + is_pension_credit_eligible: true universal_credit: 0 - name: A protected mixed-age couple on Child Tax Credit only takes the Pension Credit route and keeps its Housing Benefit @@ -493,3 +495,62 @@ claims_universal_credit: true is_WTC_eligible: false tax_credits: 0 + +- name: A pension-age Working Tax Credit recipient refused Universal Credit on capital stays on the pension-age route + # The transitional capital disregard (SI 2014/1230 reg 51) is not modelled. + period: 2025 + input: + people: + adult: + age: 70 + working_tax_credit_reported: 2_000 + housing_benefit_reported: 4_000 + benunits: + benunit: + members: [adult] + would_claim_uc: false + would_claim_uc_at_legacy_closure: true + households: + household: + members: [adult] + tenure_type: RENT_FROM_COUNCIL + savings: 20_000 + output: + claims_universal_credit: true + is_uc_eligible: false + left_pension_route_at_legacy_closure: false + housing_benefit_eligible: true + +- name: A closure claim counts the household's non-dependants like any Universal Credit claim + # UC Regs 2013 Sch 4 para 9: a non-dependant son aged 24 in the household. + # The 2025-26 housing cost contribution is 93.02 a month (12 x 93.02). + period: 2025 + input: + people: + head: + age: 40 + is_household_head: true + working_tax_credit_reported: 2_000 + son: + age: 24 + employment_income: 20_000 + benunits: + head_family: + members: [head] + would_claim_uc: false + would_claim_uc_at_legacy_closure: true + son_family: + members: [son] + would_claim_uc: false + would_claim_uc_at_legacy_closure: false + households: + home: + members: [head, son] + rent: 9_000 + tenure_type: RENT_PRIVATELY + brma: MAIDSTONE + output: + claims_universal_credit: [true, false] + uc_non_dependants_counted: [true, false] + uc_non_dep_deductions: [1_116.24, 0] + LHA_allowed_bedrooms: [2, 1] diff --git a/policyengine_uk/tests/test_rent_of_sharers_boarders_and_lodgers_properties.py b/policyengine_uk/tests/test_rent_of_sharers_boarders_and_lodgers_properties.py index ccebd466a4..0b68d8b3b1 100644 --- a/policyengine_uk/tests/test_rent_of_sharers_boarders_and_lodgers_properties.py +++ b/policyengine_uk/tests/test_rent_of_sharers_boarders_and_lodgers_properties.py @@ -395,7 +395,7 @@ def test_no_op_without_sharers_boarders_or_lodgers(population): claims_uc = calc(sim, "uc_non_dependants_counted") assert np.array_equal( claims_uc, - head & calc(sim, "is_uc_eligible") & calc(sim, "would_claim_uc"), + head & calc(sim, "is_uc_eligible") & calc(sim, "claims_universal_credit"), ) person_head_family = sim.map_result(head, "benunit", "person") > 0 individual = calc(sim, "uc_individual_non_dep_deduction") diff --git a/policyengine_uk/tests/test_uc_legacy_closure_properties.py b/policyengine_uk/tests/test_uc_legacy_closure_properties.py index 3af9c852a9..aa1262a78e 100644 --- a/policyengine_uk/tests/test_uc_legacy_closure_properties.py +++ b/policyengine_uk/tests/test_uc_legacy_closure_properties.py @@ -21,9 +21,10 @@ closure claim flag; Universal Credit is paid only to claiming families. 3. Migration ends every legacy award: a family moved off legacy benefits gets no tax credits, Income Support, income-related ESA or income-based JSA. - It gets no Housing Benefit unless it is a wholly pension-age family that - does not claim Universal Credit and so keeps the pension-age route. - A family that leaves that route gets no Pension Credit either. + It gets no Housing Benefit unless it keeps the pension-age route by not + claiming (or by being refused Universal Credit). A family that leaves + that route gets no Pension Credit either, and any closure claimant + within the capital limit is eligible for Universal Credit. 4. Exclusive: no family gets Universal Credit with Pension Credit or with Housing Benefit. 5. Metamorphic, the flag is inert unless it decides a claim: negating @@ -94,6 +95,8 @@ "left_pension_route_at_legacy_closure", "meets_pension_credit_age_conditions", "is_mixed_age_couple", + "is_uc_eligible", + "uc_assessable_capital", ] award = st.sampled_from([0.0, 0.0, 2_000.0, 6_000.0]) @@ -213,7 +216,13 @@ def test_closure_claims_and_the_end_of_legacy_awards(units): "jsa_income", ]: assert v[benefit][i] == 0, (year, benefit, unit) - left = pension_age_route and (claims_at_closure or mixed_age) + uc_eligible = bool(v["is_uc_eligible"][i]) + # Any closure claimant within the capital limit is eligible for + # Universal Credit: on the pension-age route through a joint claim + # (reg 3(2)(a)) or the reg 60A waiver. + if claims_at_closure and v["uc_assessable_capital"][i] <= 16_000: + assert uc_eligible, (year, unit) + left = pension_age_route and claims_at_closure and uc_eligible assert bool(v["left_pension_route_at_legacy_closure"][i]) == left if left or not pension_age_route: assert v["housing_benefit"][i] == 0, (year, unit) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_eligible.py b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_eligible.py index 60470b4652..a5ad1a3ffa 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_eligible.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_eligible.py @@ -41,10 +41,11 @@ def formula(benunit, period, parameters): # the claimant and partner (is_claimant_or_partner), so a pensioner # with an 18 or 19 year old dependant can claim Housing Benefit and # not Universal Credit. - # A family DWP moved to Universal Credit leaves this route: it claims - # Universal Credit, or, as a protected mixed-age couple that does not, - # loses its award at the notice's deadline (reg 46(1)(a)) and with it - # the saving (left_pension_route_at_legacy_closure). + # A family DWP moved to Universal Credit leaves this route if it + # claims Universal Credit and is eligible for it + # (left_pension_route_at_legacy_closure). One that does not claim + # keeps it; a protected mixed-age couple can reclaim Housing Benefit + # and keep its saving (SI 2019/37 art. 4(3)(b)(ii)). pension_age = benunit("meets_pension_credit_age_conditions", period) & ~benunit( "left_pension_route_at_legacy_closure", period ) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_on_passporting_benefit.py b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_on_passporting_benefit.py index 6f525e3d70..42bc4575e4 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_on_passporting_benefit.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_on_passporting_benefit.py @@ -15,7 +15,7 @@ class housing_benefit_on_passporting_benefit(Variable): "in payment or not (SI 2006/213 and SR 2006/405 reg 2(3B)). The model " "reads that as Universal Credit above nil before the benefit cap and " "deductions (is_uc_entitled), for a family that claims it " - "(would_claim_uc), so a Universal Credit award that the cap or " + "(claims_universal_credit), so a Universal Credit award that the cap or " "deductions reduce to nil still counts. The model tests the year " "rather than each day, and both members of a joint claim are on " "Universal Credit. There is no age condition: the working-age " diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_pension_age_regulations_apply.py b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_pension_age_regulations_apply.py index d9bc07fec4..e2882b6e52 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_pension_age_regulations_apply.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_pension_age_regulations_apply.py @@ -19,11 +19,11 @@ class housing_benefit_pension_age_regulations_apply(Variable): "age. That is right for the cap, which SI 2014/1230 reg 60C disapplies " "for a claim where every claimant has reached that age, but not for " "reg 5 itself, under which such an award would bring in the " - "working-age regulations. The model has no lawful such awards (tax " - "credit migrants under SI 2014/1230 reg 60A are not modelled), and its " - "Universal Credit eligibility test also needs a claimant or partner " - "under the qualifying age, so the pension-age regulations are the right " - "result." + "working-age regulations. The model's only such awards go to tax " + "credit migrants under SI 2014/1230 reg 60A (claims_uc_at_legacy_" + "closure); they leave the pension-age route, so they get no Housing " + "Benefit under either set of regulations, and the cap does not apply " + "to them (reg 60C)." ) definition_period = YEAR reference = ( diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/left_pension_route_at_legacy_closure.py b/policyengine_uk/variables/gov/dwp/pension_credit/left_pension_route_at_legacy_closure.py index d6777d1085..58f61aa17a 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/left_pension_route_at_legacy_closure.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/left_pension_route_at_legacy_closure.py @@ -8,15 +8,18 @@ class left_pension_route_at_legacy_closure(Variable): documentation = ( "Whether a family meeting the Pension Credit age conditions left that " "route when DWP moved it to Universal Credit (legacy_benefits_closed): " - "it claimed Universal Credit, or it is a protected mixed-age couple " - "whose Housing Benefit ended at the notice's deadline, ending the " - "saving that let it claim Pension Credit or Housing Benefit. A wholly " - "pension-age family that does not claim Universal Credit stays on the " - "route and can claim Pension Credit and Housing Benefit afresh." + "it claimed Universal Credit at the closure and is eligible for it. A " + "family that does not claim keeps the route. A wholly pension-age one " + "can claim Pension Credit and Housing Benefit afresh, and a protected " + "mixed-age couple can reclaim Housing Benefit within three months, " + "keeping its saving. A claimant refused Universal Credit, say on " + "capital, is taken to stay on the route: the transitional capital " + "disregard that would admit a tax credit migrant is not modelled." ) definition_period = YEAR reference = ( "https://www.legislation.gov.uk/uksi/2014/1230/regulation/46", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/51", "https://www.legislation.gov.uk/uksi/2014/1230/regulation/60A", "https://www.legislation.gov.uk/uksi/2019/37/article/4", "https://www.gov.uk/government/publications/housing-benefit-adjudication-circulars-2024/a92024-the-social-security-state-pension-age-claimants-closure-of-tax-credits-amendment-regulations-2024", @@ -26,7 +29,12 @@ def formula(benunit, period, parameters): moved = benunit("legacy_benefits_closed", period) & benunit( "meets_pension_credit_age_conditions", period ) - return moved & ( - benunit("claims_uc_at_legacy_closure", period) - | benunit("is_mixed_age_couple", period) + # SI 2019/37 art. 4(3)(b)(ii), inserted by SI 2024/604, lets a + # protected couple that did not claim Universal Credit reclaim Housing + # Benefit within three months of its award ending and keep the saving + # (DWP HB circular A9/2024 paras 12-13), so only a claim moves it. + return ( + moved + & benunit("claims_uc_at_legacy_closure", period) + & benunit("is_uc_eligible", period) ) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/meets_pension_credit_age_conditions.py b/policyengine_uk/variables/gov/dwp/pension_credit/meets_pension_credit_age_conditions.py index ec299bceea..ec577f5437 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/meets_pension_credit_age_conditions.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/meets_pension_credit_age_conditions.py @@ -11,7 +11,9 @@ class meets_pension_credit_age_conditions(Variable): "2019), so has any partner, unless the couple keeps Pension Credit " "under the SI 2019/37 article 4 saving. A family meeting these " "conditions is on the pension-age route (Pension Credit and pension-age " - "Housing Benefit) and is not eligible for Universal Credit." + "Housing Benefit) and is not eligible for Universal Credit unless " + "it claims it when DWP moves it off its legacy benefits " + "(claims_uc_at_legacy_closure)." ) definition_period = YEAR reference = ( diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dependants_counted.py b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dependants_counted.py index f217751f58..c1b4d49993 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dependants_counted.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dependants_counted.py @@ -28,7 +28,7 @@ def formula(benunit, period, parameters): claims = ( liable_family & benunit("is_uc_eligible", period) - & benunit("would_claim_uc", period) + & benunit("claims_universal_credit", period) ) head_family = benunit("benunit_contains_household_head", period) # Order the claims: the household head's family first, then the diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.py index 32d33f3bdc..3de57569f1 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.py @@ -70,8 +70,11 @@ def formula(benunit, period, parameters): # both members reach the qualifying age (SI 2012/2885 reg 3(2); # WSI 2013/3029 reg 3(2); SSI 2021/249 reg 3(2)). England, and Wales # for schemes from 2026-27, also disregard a tax credit migrant's award - # (UC (TP) Regs 2014 reg 60A) and Scotland does not; the model does not - # model those migrants. + # (UC (TP) Regs 2014 reg 60A) and Scotland does not. The model's reg 60A + # migrants (claims_uc_at_legacy_closure) have no applicant or partner + # under the qualifying age, so their award is disregarded in every + # nation; counting it in Scotland, and in Wales before 2026-27, is not + # modelled. working_age_applicant = benunit.any(applicant_or_partner & ~over_qualifying_age) universal_credit_award = ( benunit("is_uc_entitled", period) From 88ab076c73be023efe784cb3fd82ddfe49e146c0 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 06:06:16 -0400 Subject: [PATCH 5/8] Use actual UC claims for shared legacy benefit rules --- .../legacy_closure_ineligible_partner.yaml | 49 +++++++++++++++++++ .../test_uc_legacy_closure_properties.py | 43 ++++++++++++++-- .../utils/uc_work_related_requirements.py | 8 +-- 3 files changed, 92 insertions(+), 8 deletions(-) create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/legacy_closure_ineligible_partner.yaml diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/legacy_closure_ineligible_partner.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/legacy_closure_ineligible_partner.yaml new file mode 100644 index 0000000000..c56cb9137c --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/legacy_closure_ineligible_partner.yaml @@ -0,0 +1,49 @@ +- name: A closure claimant with an ineligible partner uses the UC single claim rules + # UC Regulations 2013 reg 3(3), reg 82 and Schedule 4 paragraph 29. + # Both claims use the same single allowance and shared accommodation rent. + period: 2025 + absolute_error_margin: 0.02 + input: + people: + closure_claimant: + age: 30 + is_claimant_or_partner: true + working_tax_credit_reported: 2_000 + closure_partner: + age: 30 + is_claimant_or_partner: true + uc_is_ineligible_partner: true + ordinary_claimant: + age: 30 + is_claimant_or_partner: true + working_tax_credit_reported: 2_000 + ordinary_partner: + age: 30 + is_claimant_or_partner: true + uc_is_ineligible_partner: true + benunits: + closure: + members: [closure_claimant, closure_partner] + would_claim_uc: false + would_claim_uc_at_legacy_closure: true + ordinary: + members: [ordinary_claimant, ordinary_partner] + would_claim_uc: true + would_claim_uc_at_legacy_closure: true + households: + closure_home: + members: [closure_claimant, closure_partner] + tenure_type: RENT_PRIVATELY + rent: 9_000 + brma: MAIDSTONE + ordinary_home: + members: [ordinary_claimant, ordinary_partner] + tenure_type: RENT_PRIVATELY + rent: 9_000 + brma: MAIDSTONE + output: + claims_universal_credit: [true, true] + uc_member_of_couple_claims_as_single_person: [true, true] + is_lha_shared_accommodation_rate_specified_renter: [true, true] + is_benefit_cap_single_claimant_rate: [true, true] + universal_credit: [10_139.64, 10_139.64] diff --git a/policyengine_uk/tests/test_uc_legacy_closure_properties.py b/policyengine_uk/tests/test_uc_legacy_closure_properties.py index aa1262a78e..7faed1b45c 100644 --- a/policyengine_uk/tests/test_uc_legacy_closure_properties.py +++ b/policyengine_uk/tests/test_uc_legacy_closure_properties.py @@ -33,11 +33,12 @@ Universal Credit anyway. In particular nothing changes in 2024, before any closure. 6. Differential: a working-age family claiming at the closure gets the - benefits it would get if would_claim_uc were true. + benefits it would get if would_claim_uc were true, including when a + partner cannot be a joint claimant. """ import numpy as np -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation @@ -97,6 +98,9 @@ "is_mixed_age_couple", "is_uc_eligible", "uc_assessable_capital", + "uc_housing_costs_element", + "is_lha_shared_accommodation_rate_specified_renter", + "is_benefit_cap_single_claimant_rate", ] award = st.sampled_from([0.0, 0.0, 2_000.0, 6_000.0]) @@ -118,6 +122,7 @@ def families(draw): savings=draw(st.sampled_from([0.0, 5_000.0, 20_000.0])), would_claim_uc=draw(st.booleans()), claims_at_closure=draw(st.booleans()), + ineligible_partner=draw(st.booleans()), ) @@ -136,6 +141,9 @@ def situation(units, negate_flag=False, would_claim_uc=None): person = { "age": every_year(age), "is_claimant_or_partner": every_year(role != "child"), + "uc_is_ineligible_partner": every_year( + role == "partner" and unit.get("ineligible_partner", False) + ), "employment_income": every_year( unit["earnings"] if role == "claimant" else 0.0 ), @@ -257,6 +265,26 @@ def test_the_closure_flag_only_decides_closure_claims(units): @PROPERTY_SETTINGS @given(st.lists(families(), min_size=1, max_size=25)) +@example( + [ + dict( + shape="couple", + members=[("claimant", 30), ("partner", 30)], + reported={ + name: 2_000.0 if name == "working_tax_credit_reported" else 0.0 + for name in LEGACY + }, + saving=False, + tenure="RENT_PRIVATELY", + rent=9_000.0, + earnings=0.0, + savings=0.0, + would_claim_uc=False, + claims_at_closure=True, + ineligible_partner=True, + ) + ] +) def test_a_closure_claim_pays_the_universal_credit_of_any_claim(units): for unit in units: unit["claims_at_closure"] = True @@ -271,8 +299,15 @@ def test_a_closure_claim_pays_the_universal_credit_of_any_claim(units): v["legacy_benefits_closed"][i] and not (v["meets_pension_credit_age_conditions"][i]) ): - for benefit in BENEFITS: - assert abs(v[benefit][i] - anyway[year][benefit][i]) < 0.01, ( + for benefit in BENEFITS + [ + "uc_housing_costs_element", + "is_lha_shared_accommodation_rate_specified_renter", + "is_benefit_cap_single_claimant_rate", + ]: + assert ( + abs(float(v[benefit][i]) - float(anyway[year][benefit][i])) + < 0.01 + ), ( year, benefit, unit, diff --git a/policyengine_uk/utils/uc_work_related_requirements.py b/policyengine_uk/utils/uc_work_related_requirements.py index 4df69d4cbc..5c58eaa0d2 100644 --- a/policyengine_uk/utils/uc_work_related_requirements.py +++ b/policyengine_uk/utils/uc_work_related_requirements.py @@ -38,13 +38,13 @@ def stays_on_legacy_benefits(benunit, period): """Whether the family stays on legacy benefits rather than claiming UC. A family that reports a legacy benefit (`claims_legacy_benefits`) and - would not claim Universal Credit keeps its legacy awards; this is the + does not claim Universal Credit keeps its legacy awards; this is the route `housing_benefit_eligible` gives a continuing working-age Housing - Benefit award. A family that would claim Universal Credit is calculated - on Universal Credit whatever it reports. + Benefit award before its legacy benefits close. A family claiming + Universal Credit, including at a legacy closure, uses the UC rules. """ return benunit("claims_legacy_benefits", period) & ~benunit( - "would_claim_uc", period + "claims_universal_credit", period ) From 47b8027c78e1f914fc61464fb1f28901dcfb9fe9 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 06:18:43 -0400 Subject: [PATCH 6/8] Preserve the pension route for nil migration UC entitlement --- .../uc-claim-at-legacy-closure.added.md | 2 +- .../legacy_closure_pension_route.yaml | 155 ++++++++++++++++++ .../test_uc_legacy_closure_properties.py | 12 +- .../tests/test_uc_non_dep_pension_credit.py | 115 +++++++++++++ .../housing_benefit_eligible.py | 9 +- ...g_benefit_pension_age_regulations_apply.py | 7 +- .../left_pension_route_at_legacy_closure.py | 17 +- .../uc_non_dep_deduction_exempt.py | 2 +- .../uc_non_dep_receives_pension_credit.py | 45 +++++ .../dwp/universal_credit/is_uc_eligible.py | 6 +- 10 files changed, 351 insertions(+), 19 deletions(-) create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/legacy_closure_pension_route.yaml create mode 100644 policyengine_uk/tests/test_uc_non_dep_pension_credit.py create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_receives_pension_credit.py diff --git a/changelog.d/uc-claim-at-legacy-closure.added.md b/changelog.d/uc-claim-at-legacy-closure.added.md index 2da4fc8b22..5c5ed7c847 100644 --- a/changelog.d/uc-claim-at-legacy-closure.added.md +++ b/changelog.d/uc-claim-at-legacy-closure.added.md @@ -1 +1 @@ -- Move families onto Universal Credit when DWP closes a legacy benefit they receive: tax credits from 2025-26, Income Support and income-based JSA from 2026-27. Families on Pension Credit, and families on Child Tax Credit only that meet the Pension Credit age conditions, go to Pension Credit instead (SI 2019/167 art. 3A). Pension-age families on Working Tax Credit can claim Universal Credit with the upper age condition waived (SI 2014/1230 reg 60A), and protected mixed-age couples on it can claim jointly (UC Regulations 2013 reg 3(2)(a)); either leaves the pension-age route only by claiming and being eligible. A new dataset input, `would_claim_uc_at_legacy_closure` (default true), says whether the family claims. `claims_universal_credit` replaces `would_claim_uc` wherever the model asks whether a family claims Universal Credit, including whose claim counts a household's non-dependants. The family's other legacy awards end at the closure whether or not it claims (SI 2014/1230 regs 8 and 46). +- Move families onto Universal Credit when DWP closes a legacy benefit they receive: tax credits from 2025-26, Income Support and income-based JSA from 2026-27. Families on Pension Credit, and families on Child Tax Credit only that meet the Pension Credit age conditions, go to Pension Credit instead (SI 2019/167 art. 3A). Pension-age families on Working Tax Credit can claim Universal Credit with the upper age condition waived (SI 2014/1230 reg 60A), and protected mixed-age couples on it can claim jointly (UC Regulations 2013 reg 3(2)(a)); either leaves the pension-age route only by claiming and having positive Universal Credit entitlement before the benefit cap and deductions. A new dataset input, `would_claim_uc_at_legacy_closure` (default true), says whether the family claims. `claims_universal_credit` replaces `would_claim_uc` wherever the model asks whether a family claims Universal Credit, including whose claim counts a household's non-dependants. The family's other legacy awards end at the closure whether or not it claims (SI 2014/1230 regs 8 and 46). diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/legacy_closure_pension_route.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/legacy_closure_pension_route.yaml new file mode 100644 index 0000000000..8fabfce305 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/legacy_closure_pension_route.yaml @@ -0,0 +1,155 @@ +# SI 2019/37 art. 4(3)(b)(ii), inserted by SI 2024/604 art. 2, +# preserves the Housing Benefit reclaim route for a protected mixed-age +# couple that claims Universal Credit before the final deadline but is not +# entitled. Entitlement is assessed before benefit-cap reductions. + +- name: Protected mixed-age couple claiming nil Universal Credit keeps its Housing Benefit reclaim route + # Preserve the review's 139.83 reproducer with its then-modelled 2025 + # weekly couple allowance. Main subsequently corrected that rate to 366; + # the unmodified current-policy case below has the same route outcome. + period: 2025 + absolute_error_margin: 0.01 + input: + gov.dwp.housing_benefit.allowances.couple.aged: 357.98 + people: + older: + age: 72 + is_claimant_or_partner: true + working_tax_credit_reported: 2_000 + housing_benefit_reported: 4_000 + employment_income: 37_500 + younger: + age: 40 + is_claimant_or_partner: true + benunits: + benunit: + members: [older, younger] + has_mixed_age_couple_pension_credit_saving: true + would_claim_uc: false + would_claim_uc_at_legacy_closure: true + households: + household: + members: [older, younger] + rent: 9_000 + tenure_type: RENT_FROM_COUNCIL + output: + legacy_benefits_closed: true + claims_universal_credit: true + is_uc_eligible: true + universal_credit_pre_benefit_cap: 0 + is_uc_entitled: false + universal_credit: 0 + left_pension_route_at_legacy_closure: false + housing_benefit_eligible: true + housing_benefit: 139.83 + +- name: Protected mixed-age couple declining Universal Credit keeps the same Housing Benefit reclaim route + period: 2025 + absolute_error_margin: 0.01 + input: + gov.dwp.housing_benefit.allowances.couple.aged: 357.98 + people: + older: + age: 72 + is_claimant_or_partner: true + working_tax_credit_reported: 2_000 + housing_benefit_reported: 4_000 + employment_income: 37_500 + younger: + age: 40 + is_claimant_or_partner: true + benunits: + benunit: + members: [older, younger] + has_mixed_age_couple_pension_credit_saving: true + would_claim_uc: false + would_claim_uc_at_legacy_closure: false + households: + household: + members: [older, younger] + rent: 9_000 + tenure_type: RENT_FROM_COUNCIL + output: + legacy_benefits_closed: true + claims_universal_credit: false + is_uc_entitled: false + universal_credit: 0 + left_pension_route_at_legacy_closure: false + housing_benefit_eligible: true + housing_benefit: 139.83 + +- name: Current pension-age allowance preserves Housing Benefit for a nil Universal Credit claimant + # Current 2025-26 pension-age couple allowance: 366 a week. The family + # otherwise matches the review's nil-entitlement reproducer above. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + older: + age: 72 + is_claimant_or_partner: true + working_tax_credit_reported: 2_000 + housing_benefit_reported: 4_000 + employment_income: 37_500 + younger: + age: 40 + is_claimant_or_partner: true + benunits: + benunit: + members: [older, younger] + has_mixed_age_couple_pension_credit_saving: true + would_claim_uc: false + would_claim_uc_at_legacy_closure: true + households: + household: + members: [older, younger] + rent: 9_000 + tenure_type: RENT_FROM_COUNCIL + output: + legacy_benefits_closed: true + claims_universal_credit: true + is_uc_eligible: true + universal_credit_pre_benefit_cap: 0 + is_uc_entitled: false + universal_credit: 0 + left_pension_route_at_legacy_closure: false + housing_benefit_eligible: true + housing_benefit: 410.90 + +- name: Benefit cap reducing Universal Credit to nil does not preserve the pension-age route + # Isolate the distinction between a nil means-tested entitlement and a + # positive entitlement whose payment is then reduced to zero by the cap. + period: 2025 + input: + people: + older: + age: 72 + is_claimant_or_partner: true + working_tax_credit_reported: 2_000 + housing_benefit_reported: 4_000 + younger: + age: 40 + is_claimant_or_partner: true + benunits: + benunit: + members: [older, younger] + has_mixed_age_couple_pension_credit_saving: true + would_claim_uc: false + would_claim_uc_at_legacy_closure: true + uc_maximum_amount: 1_200 + uc_income_reduction: 0 + benefit_cap_reduction: 1_200 + households: + household: + members: [older, younger] + rent: 9_000 + tenure_type: RENT_FROM_COUNCIL + output: + claims_universal_credit: true + universal_credit_pre_benefit_cap: 1_200 + is_uc_entitled: true + universal_credit: 0 + left_pension_route_at_legacy_closure: true + housing_benefit_eligible: false + housing_benefit: 0 + is_pension_credit_eligible: false diff --git a/policyengine_uk/tests/test_uc_legacy_closure_properties.py b/policyengine_uk/tests/test_uc_legacy_closure_properties.py index 7faed1b45c..81a15f5bcd 100644 --- a/policyengine_uk/tests/test_uc_legacy_closure_properties.py +++ b/policyengine_uk/tests/test_uc_legacy_closure_properties.py @@ -22,7 +22,7 @@ 3. Migration ends every legacy award: a family moved off legacy benefits gets no tax credits, Income Support, income-related ESA or income-based JSA. It gets no Housing Benefit unless it keeps the pension-age route by not - claiming (or by being refused Universal Credit). A family that leaves + claiming (or by having nil Universal Credit entitlement). A family that leaves that route gets no Pension Credit either, and any closure claimant within the capital limit is eligible for Universal Credit. 4. Exclusive: no family gets Universal Credit with Pension Credit or with @@ -38,11 +38,14 @@ """ import numpy as np +import pytest from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + YEARS = (2024, 2025, 2026, 2027) PROPERTY_SETTINGS = settings( max_examples=10, @@ -97,6 +100,7 @@ "meets_pension_credit_age_conditions", "is_mixed_age_couple", "is_uc_eligible", + "universal_credit_pre_benefit_cap", "uc_assessable_capital", "uc_housing_costs_element", "is_lha_shared_accommodation_rate_specified_renter", @@ -230,7 +234,11 @@ def test_closure_claims_and_the_end_of_legacy_awards(units): # (reg 3(2)(a)) or the reg 60A waiver. if claims_at_closure and v["uc_assessable_capital"][i] <= 16_000: assert uc_eligible, (year, unit) - left = pension_age_route and claims_at_closure and uc_eligible + left = ( + pension_age_route + and claims_at_closure + and v["universal_credit_pre_benefit_cap"][i] > 0 + ) assert bool(v["left_pension_route_at_legacy_closure"][i]) == left if left or not pension_age_route: assert v["housing_benefit"][i] == 0, (year, unit) diff --git a/policyengine_uk/tests/test_uc_non_dep_pension_credit.py b/policyengine_uk/tests/test_uc_non_dep_pension_credit.py new file mode 100644 index 0000000000..b47fec46ad --- /dev/null +++ b/policyengine_uk/tests/test_uc_non_dep_pension_credit.py @@ -0,0 +1,115 @@ +"""Pension Credit receipt for UC non-dependants stays acyclic and current.""" + +import numpy as np +import pytest + +from policyengine_uk import Simulation + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +YEAR = 2025 + + +def mixed_families(): + people = {} + for name, age, head, wtc, pension in [ + ("tenant", 40, True, 2_000, 0), + ("pc_older", 72, False, 2_000, 12_000), + ("pc_younger", 40, False, 0, 0), + ("working_non_dep", 30, False, 0, 0), + ("renter_older", 72, True, 2_000, 0), + ("renter_younger", 40, False, 0, 0), + ]: + people[name] = { + "age": {YEAR: age}, + "is_household_head": {YEAR: head}, + "is_claimant_or_partner": {YEAR: True}, + "working_tax_credit_reported": {YEAR: wtc}, + "state_pension_reported": {YEAR: pension}, + "employment_income": {YEAR: 0}, + } + benunits = {} + for name, members, saving, claims in [ + ("tenant", ["tenant"], False, True), + ("pc_non_deps", ["pc_older", "pc_younger"], True, True), + ("working_non_dep", ["working_non_dep"], False, False), + ("protected_renters", ["renter_older", "renter_younger"], True, True), + ]: + benunits[name] = { + "members": members, + "has_mixed_age_couple_pension_credit_saving": {YEAR: saving}, + "would_claim_uc": {YEAR: False}, + "would_claim_uc_at_legacy_closure": {YEAR: claims}, + } + households = { + "with_non_deps": { + "members": ["tenant", "pc_older", "pc_younger", "working_non_dep"], + "rent": {YEAR: 9_000}, + "tenure_type": {YEAR: "RENT_FROM_COUNCIL"}, + }, + "protected_renters": { + "members": ["renter_older", "renter_younger"], + "rent": {YEAR: 9_000}, + "tenure_type": {YEAR: "RENT_FROM_COUNCIL"}, + }, + } + return {"people": people, "benunits": benunits, "households": households} + + +def test_non_dependants_pension_credit_exempts_only_its_own_family(): + sim = Simulation(situation=mixed_families()) + # Calculate UC first: the renters' UC must not ask for their own PC + # through the non-dependant exemption and recurse into UC entitlement. + uc = np.asarray(sim.calculate("universal_credit", YEAR)) + pc = np.asarray(sim.calculate("pension_credit", YEAR)) + np.testing.assert_array_equal( + sim.calculate("uc_non_dep_receives_pension_credit", YEAR), + [False, True, False, False], + ) + assert pc[1] > 0 + assert uc[1] == 0 + assert uc[0] > 0 and uc[3] > 0 + assert pc[0] == 0 and pc[3] == 0 + # Both members of the protected PC family are exempt. Only the separate + # working-age non-dependant contributes 12 x 93.02 in 2025-26. + np.testing.assert_allclose( + sim.calculate("uc_non_dep_deductions", YEAR), + [1_116.24, 0, 0, 0], + atol=0.01, + ) + + +def test_non_dependant_exemption_recalculates_after_earnings_change(): + sim = Simulation(situation=mixed_families()) + assert sim.calculate("uc_non_dep_receives_pension_credit", YEAR)[1] + sim.set_input("employment_income", YEAR, [0, 50_000, 0, 0, 0, 0]) + sim.drop_computed_arrays() + assert sim.calculate("pension_credit", YEAR)[1] == 0 + assert not sim.calculate("uc_non_dep_receives_pension_credit", YEAR)[1] + # The two former PC recipients now contribute alongside the other + # non-dependant. A stale counterfactual branch would keep them exempt. + np.testing.assert_allclose( + sim.calculate("uc_non_dep_deductions", YEAR), + [3_348.72, 0, 0, 0], + atol=0.01, + ) + + +def test_non_dependant_exemption_keeps_frozen_pension_credit_receipt(): + remove_couple_guarantee = { + "gov.dwp.pension_credit.guarantee_credit.minimum_guarantee.COUPLE": { + "2025-01-01": 0 + } + } + unfrozen = Simulation(situation=mixed_families(), reform=remove_couple_guarantee) + frozen = Simulation( + situation=mixed_families(), + reform={ + **remove_couple_guarantee, + "gov.contrib.freeze_pension_credit": {"2025-01-01": True}, + }, + ) + assert unfrozen.calculate("pension_credit", YEAR)[1] == 0 + assert not unfrozen.calculate("uc_non_dep_receives_pension_credit", YEAR)[1] + assert frozen.calculate("pension_credit", YEAR)[1] > 0 + assert frozen.calculate("uc_non_dep_receives_pension_credit", YEAR)[1] diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_eligible.py b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_eligible.py index a5ad1a3ffa..fab1207355 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_eligible.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_eligible.py @@ -42,10 +42,11 @@ def formula(benunit, period, parameters): # with an 18 or 19 year old dependant can claim Housing Benefit and # not Universal Credit. # A family DWP moved to Universal Credit leaves this route if it - # claims Universal Credit and is eligible for it - # (left_pension_route_at_legacy_closure). One that does not claim - # keeps it; a protected mixed-age couple can reclaim Housing Benefit - # and keep its saving (SI 2019/37 art. 4(3)(b)(ii)). + # claims Universal Credit and is entitled before benefit-cap reductions + # and deductions (left_pension_route_at_legacy_closure). One that does + # not claim, or claims with nil entitlement, keeps it; a protected + # mixed-age couple can reclaim Housing Benefit and keep its saving + # (SI 2019/37 art. 4(3)(b)(ii), inserted by SI 2024/604 art. 2). pension_age = benunit("meets_pension_credit_age_conditions", period) & ~benunit( "left_pension_route_at_legacy_closure", period ) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_pension_age_regulations_apply.py b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_pension_age_regulations_apply.py index e2882b6e52..c30929212c 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_pension_age_regulations_apply.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_pension_age_regulations_apply.py @@ -21,9 +21,10 @@ class housing_benefit_pension_age_regulations_apply(Variable): "reg 5 itself, under which such an award would bring in the " "working-age regulations. The model's only such awards go to tax " "credit migrants under SI 2014/1230 reg 60A (claims_uc_at_legacy_" - "closure); they leave the pension-age route, so they get no Housing " - "Benefit under either set of regulations, and the cap does not apply " - "to them (reg 60C)." + "closure). While entitled, they leave the pension-age route and get " + "no Housing Benefit under either set of regulations; a claim with " + "nil entitlement keeps that route. The cap does not apply to their " + "Universal Credit awards (reg 60C)." ) definition_period = YEAR reference = ( diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/left_pension_route_at_legacy_closure.py b/policyengine_uk/variables/gov/dwp/pension_credit/left_pension_route_at_legacy_closure.py index 58f61aa17a..8b24f90e10 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/left_pension_route_at_legacy_closure.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/left_pension_route_at_legacy_closure.py @@ -8,8 +8,10 @@ class left_pension_route_at_legacy_closure(Variable): documentation = ( "Whether a family meeting the Pension Credit age conditions left that " "route when DWP moved it to Universal Credit (legacy_benefits_closed): " - "it claimed Universal Credit at the closure and is eligible for it. A " - "family that does not claim keeps the route. A wholly pension-age one " + "it claimed Universal Credit at the closure and is entitled to it " + "before benefit-cap reductions and deductions. A family that does " + "not claim, or claims but has nil entitlement, keeps the route. A " + "wholly pension-age one " "can claim Pension Credit and Housing Benefit afresh, and a protected " "mixed-age couple can reclaim Housing Benefit within three months, " "keeping its saving. A claimant refused Universal Credit, say on " @@ -22,6 +24,7 @@ class left_pension_route_at_legacy_closure(Variable): "https://www.legislation.gov.uk/uksi/2014/1230/regulation/51", "https://www.legislation.gov.uk/uksi/2014/1230/regulation/60A", "https://www.legislation.gov.uk/uksi/2019/37/article/4", + "https://www.legislation.gov.uk/uksi/2024/604/article/2", "https://www.gov.uk/government/publications/housing-benefit-adjudication-circulars-2024/a92024-the-social-security-state-pension-age-claimants-closure-of-tax-credits-amendment-regulations-2024", ) @@ -30,11 +33,13 @@ def formula(benunit, period, parameters): "meets_pension_credit_age_conditions", period ) # SI 2019/37 art. 4(3)(b)(ii), inserted by SI 2024/604, lets a - # protected couple that did not claim Universal Credit reclaim Housing - # Benefit within three months of its award ending and keep the saving - # (DWP HB circular A9/2024 paras 12-13), so only a claim moves it. + # protected couple that did not claim Universal Credit, or claimed + # but was not entitled, reclaim Housing Benefit within three months + # of its award ending and keep the saving (DWP HB circular A9/2024 + # paras 12-13). Entitlement is read before benefit-cap reductions + # and deductions, which can reduce an entitled family's payment to nil. return ( moved & benunit("claims_uc_at_legacy_closure", period) - & benunit("is_uc_eligible", period) + & benunit("is_uc_entitled", period) ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_deduction_exempt.py b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_deduction_exempt.py index ea5db9b881..6c81d42cad 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_deduction_exempt.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_deduction_exempt.py @@ -18,7 +18,7 @@ class uc_non_dep_deduction_exempt(Variable): def formula(person, period, parameters): return ( - (person.benunit("pension_credit", period) > 0) + person.benunit("uc_non_dep_receives_pension_credit", period) | person("dla_sc_middle_plus", period) | (person("pip_dl", period) > 0) | (person("attendance_allowance", period) > 0) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_receives_pension_credit.py b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_receives_pension_credit.py new file mode 100644 index 0000000000..483b5c7cd1 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_receives_pension_credit.py @@ -0,0 +1,45 @@ +from policyengine_uk.model_api import * + + +class uc_non_dep_receives_pension_credit(Variable): + value_type = bool + entity = BenUnit + label = ( + "Non-rent-liable family receives Pension Credit for UC housing contributions" + ) + documentation = ( + "Whether a family not liable for rent receives Pension Credit, for the " + "Universal Credit non-dependant housing contribution exemption. Such " + "a family's own Universal Credit housing element is zero. Calculating " + "its Pension Credit in a branch with that element fixed at zero " + "therefore preserves its entitlement, including legacy-closure " + "routing, take-up, explicit inputs and frozen Pension Credit reforms. " + "It avoids evaluating a renter's own Pension Credit while calculating " + "their Universal Credit housing element. Rent-liable families' " + "branch results are disregarded because they are not non-dependants." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/9", + "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/16", + ) + + def formula(benunit, period, parameters): + # A non-dependant is not liable for the rent (Sch 4 para 9(2)(d)), + # so their own housing element is already zero. This branch keeps + # their actual Pension Credit calculation while breaking the cycle + # through the renter's UC entitlement and pension-age route. + simulation = benunit.simulation + name = "uc_non_dep_pension_credit" + while name in simulation.branches or name == simulation.branch_name: + name += "_" + branch = simulation.get_branch(name) + try: + branch.set_input( + "uc_housing_costs_element", period, np.zeros(benunit.count) + ) + pension_credit = branch.calculate("pension_credit", period) + finally: + # A later period or changed input must start from current inputs. + del simulation.branches[name] + return (pension_credit > 0) & ~benunit("benunit_is_rent_liable", period) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/is_uc_eligible.py b/policyengine_uk/variables/gov/dwp/universal_credit/is_uc_eligible.py index 0e79b12c28..1f8b18f5a2 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/is_uc_eligible.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/is_uc_eligible.py @@ -43,7 +43,8 @@ def formula(benunit, period, parameters): # No family gets both Pension Credit and Universal Credit (UC # (Transitional Provisions) Regs 2014 reg 5(1)(d)). A mixed-age couple # keeping the SI 2019/37 saving could still choose Universal Credit - # jointly (UC Regs 2013 reg 3(2)(a)) and lose the saving; the model + # jointly (UC Regs 2013 reg 3(2)(a)) and leave the saving while entitled; + # the model # keeps it on the Pension Credit route, anchored to its reported # Pension Credit or Housing Benefit. That, and routing mixed-age # couples not reported on Universal Credit to Pension Credit before @@ -51,7 +52,8 @@ def formula(benunit, period, parameters): pension_credit_route = benunit("meets_pension_credit_age_conditions", period) # A family DWP moved off its legacy benefits claims Universal Credit # even from the pension-age route: a protected mixed-age couple - # claims jointly, giving up the saving (reg 3(2)(a)), and a + # claims jointly (reg 3(2)(a)), leaving the pension-age route only + # while entitled, and a # pension-age Working Tax Credit recipient not entitled to Pension # Credit has the upper age condition waived (SI 2014/1230 reg 60A). closure_claim = benunit("claims_uc_at_legacy_closure", period) From 4cb49f45363c3540ac6c62c05c429090d166b4f4 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 06:29:21 -0400 Subject: [PATCH 7/8] Apply national CTR rules to pension-age UC migrants --- changelog.d/2119.fixed.md | 1 + .../programs/gov/dwp/universal-credit.ipynb | 4 +- .../legacy_closure_migrant_nations.yaml | 257 ++++++++++++++++++ .../test_uc_legacy_closure_properties.py | 27 +- .../council_tax_reduction_pensioner.py | 52 +++- 5 files changed, 319 insertions(+), 22 deletions(-) create mode 100644 changelog.d/2119.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/legacy_closure_migrant_nations.yaml diff --git a/changelog.d/2119.fixed.md b/changelog.d/2119.fixed.md new file mode 100644 index 0000000000..f03367f965 --- /dev/null +++ b/changelog.d/2119.fixed.md @@ -0,0 +1 @@ +- Fixed Universal Credit closure claims with ineligible partners, preserved protected mixed-age couples' pension-age route when Universal Credit entitlement is nil, and applied the nation-specific Council Tax Reduction rules for pension-age tax credit migrants. diff --git a/docs/book/programs/gov/dwp/universal-credit.ipynb b/docs/book/programs/gov/dwp/universal-credit.ipynb index 1578e46643..fddac7a189 100644 --- a/docs/book/programs/gov/dwp/universal-credit.ipynb +++ b/docs/book/programs/gov/dwp/universal-credit.ipynb @@ -44,7 +44,7 @@ { "cell_type": "markdown", "metadata": {}, - "source": "## How PolicyEngine computes Universal Credit\n\nPolicyEngine computes Universal Credit at the benefit-unit level in three stages:\n\n1. **Maximum entitlement** (`uc_maximum_amount`): sum the elements the benunit qualifies for — `uc_standard_allowance`, `uc_child_element`, `uc_disability_elements`, `uc_carer_element`, `uc_housing_costs_element`, and `uc_childcare_element`.\n2. **Means test**: only the claimants' income counts: the single claimant, or the two joint claimants (`is_uc_assessed_claimant`; UC Regulations 2013 reg 22(1)). The earnings and unearned income of a child, a qualifying young person or anyone else in the benefit unit are theirs, not the claimant's, so the Universal Credit means test does not count them. Who the claimants are comes from `is_uc_claimant` (the claimant and partner); a member flagged there is taken as a claimant whatever their age. Each person's earnings (`uc_individual_earned_income`) are net of their own relievable pension contributions and the income tax and National Insurance they pay on that employment or self-employment (UC Regulations 2013 regs 55(5) and 57(2)): `uc_income_tax_on_earnings` treats earnings as the lowest slice of the person's non-savings income, so tax on pensions, property, savings and dividends is never deducted, and one partner's tax never reduces the other's earnings. A claimant in gainful self-employment outside a start-up period who would otherwise be subject to all work-related requirements is subject to the minimum income floor (reg 62): when their earnings after those deductions are below `uc_minimum_income_floor`, they are treated as having that amount instead. The floor does not apply to a claimant in the no work-related requirements, work-focused interview or work preparation group (`uc_work_related_group_apart_from_earnings`; Welfare Reform Act 2012 ss 19 to 21): for example one with limited capability for work-related activity, a carer, a claimant over State Pension Credit qualifying age, or the responsible carer of a child under 3. Only one member of a couple is the responsible carer (`uc_is_responsible_carer`); where the nomination is not supplied the model takes the claimant who works fewer hours (`hours_worked`), then the elder. `hours_worked` defaults to 0, so for a couple entered without hours the default falls to the elder; supply `uc_is_responsible_carer` where the nomination is known. The floor is the minimum wage for their age for their expected hours (`uc_expected_hours`: 35 a week, or for the responsible carer of a child under 13 the hours DWP uses: 30 from February 2025, from April 2017 until then 16 for a child under compulsory school age and 25 for an older child, and 25 for any child under 13 before April 2017; regs 88 and 90(2)), less the income tax and the Class 2 and Class 4 National Insurance a self-employed person would pay on it (reg 62(4)(b) leaves this amount to the Secretary of State; this is the basis of DWP's guidance and figures, and setting the `gov.dwp.universal_credit.means_test.minimum_income_floor.self_employed_national_insurance` parameter to false deducts primary Class 1 instead). A member of a couple is lifted only while the couple's combined earnings are below the couple threshold, and never above it (reg 62(3)). The couple threshold is the sum of both partners' thresholds (reg 90(3)): 16 hours for a partner in the work-focused interview or work preparation group, nothing for a partner with no work-related requirements for a reason other than earnings, and 35 hours at the national living wage (the adult rate before April 2016) for a partner who cannot be a joint claimant (reg 90(3)(b)). The maximum entitlement is then reduced by 55% (the `gov.dwp.universal_credit.means_test.reduction_rate` parameter) of the claimants' combined earnings above the work allowance (`uc_work_allowance`), and by all of the claimants' unearned income (`uc_unearned_income`), pound for pound. Unearned income is the list in `gov.dwp.universal_credit.means_test.income_definitions.unearned`: retirement pension income (State Pension and private pensions, UC Regulations 2013 regs 66(1)(a) and 67), Carer's Allowance, carer support payment (up to the Carer's Allowance rate, from November 2023), contribution-based JSA and ESA, maternity allowance, industrial injuries benefit, and the tariff income deemed from capital (reg 72). Actual savings interest, dividends and ordinary rental income are excluded from UC unearned income at every capital level; capital counts through its tariff income instead. Asset rules apply on top of this — capital above the lower threshold deems a tariff income, and capital above the upper threshold disqualifies the benunit entirely.\n3. **Benefit cap**: the post-means-test award (`universal_credit_pre_benefit_cap`) is finally reduced by `benefit_cap_reduction` to produce `universal_credit`. The benefit cap only applies to benunits without a benefit-cap exemption (the claimants earning enough, having a qualifying disability benefit, etc.). The earnings exception reads the claimants' earnings only (reg 82(1)(a)), the capped total counts only the claimants' own contributory benefits (WRA 2012 s.96(1)), and the childcare work condition tests only the claimants (reg 32(1)).\n\nUniversal Credit is paid only to families that claim it (`claims_universal_credit`). There are two routes. The input `would_claim_uc` is drawn stochastically when the dataset is built, so that caseload aggregates match published DWP claimant numbers rather than the full eligible population. The second route covers a family whose legacy benefit has closed: tax credits from 2025-26, Income Support and income-based JSA from 2026-27 (`legacy_benefits_closed`). DWP's migration notice ended all such a family's legacy awards, and it claims Universal Credit if the input `would_claim_uc_at_legacy_closure` is true. The dataset draws that input at DWP's Move to Universal Credit claim rates by legacy benefit combination. Families DWP sent to Pension Credit instead are excluded: those on Pension Credit, and those on Child Tax Credit only that meet the Pension Credit age conditions (SI 2019/167 art. 3A). Pension-age families on Working Tax Credit can claim, because the upper age condition is waived for them (SI 2014/1230 reg 60A). Protected mixed-age couples on it can claim jointly (UC Regulations 2013 reg 3(2)(a)). Such a family leaves the pension-age route if it claims and is eligible. A protected couple that does not claim keeps its route, because it can reclaim Housing Benefit (SI 2019/37 art. 4(3)(b)(ii)). Both inputs default to true. To model a family that does not claim, set both to false. Not modelled: transitional protection, including the transitional capital disregard (SI 2014/1230 regs 48-57); nil-award Working Tax Credit families, which DWP also moved to Universal Credit; and the choice between routes, since a pension-age family that claims may get less than it would by declining.\n\nParameters live in `policyengine_uk/parameters/gov/dwp/universal_credit/` and the per-element formulas in `policyengine_uk/variables/gov/dwp/universal_credit/`." + "source": "## How PolicyEngine computes Universal Credit\n\nPolicyEngine computes Universal Credit at the benefit-unit level in three stages:\n\n1. **Maximum entitlement** (`uc_maximum_amount`): sum the elements the benunit qualifies for — `uc_standard_allowance`, `uc_child_element`, `uc_disability_elements`, `uc_carer_element`, `uc_housing_costs_element`, and `uc_childcare_element`.\n2. **Means test**: only the claimants' income counts: the single claimant, or the two joint claimants (`is_uc_assessed_claimant`; UC Regulations 2013 reg 22(1)). The earnings and unearned income of a child, a qualifying young person or anyone else in the benefit unit are theirs, not the claimant's, so the Universal Credit means test does not count them. Who the claimants are comes from `is_uc_claimant` (the claimant and partner); a member flagged there is taken as a claimant whatever their age. Each person's earnings (`uc_individual_earned_income`) are net of their own relievable pension contributions and the income tax and National Insurance they pay on that employment or self-employment (UC Regulations 2013 regs 55(5) and 57(2)): `uc_income_tax_on_earnings` treats earnings as the lowest slice of the person's non-savings income, so tax on pensions, property, savings and dividends is never deducted, and one partner's tax never reduces the other's earnings. A claimant in gainful self-employment outside a start-up period who would otherwise be subject to all work-related requirements is subject to the minimum income floor (reg 62): when their earnings after those deductions are below `uc_minimum_income_floor`, they are treated as having that amount instead. The floor does not apply to a claimant in the no work-related requirements, work-focused interview or work preparation group (`uc_work_related_group_apart_from_earnings`; Welfare Reform Act 2012 ss 19 to 21): for example one with limited capability for work-related activity, a carer, a claimant over State Pension Credit qualifying age, or the responsible carer of a child under 3. Only one member of a couple is the responsible carer (`uc_is_responsible_carer`); where the nomination is not supplied the model takes the claimant who works fewer hours (`hours_worked`), then the elder. `hours_worked` defaults to 0, so for a couple entered without hours the default falls to the elder; supply `uc_is_responsible_carer` where the nomination is known. The floor is the minimum wage for their age for their expected hours (`uc_expected_hours`: 35 a week, or for the responsible carer of a child under 13 the hours DWP uses: 30 from February 2025, from April 2017 until then 16 for a child under compulsory school age and 25 for an older child, and 25 for any child under 13 before April 2017; regs 88 and 90(2)), less the income tax and the Class 2 and Class 4 National Insurance a self-employed person would pay on it (reg 62(4)(b) leaves this amount to the Secretary of State; this is the basis of DWP's guidance and figures, and setting the `gov.dwp.universal_credit.means_test.minimum_income_floor.self_employed_national_insurance` parameter to false deducts primary Class 1 instead). A member of a couple is lifted only while the couple's combined earnings are below the couple threshold, and never above it (reg 62(3)). The couple threshold is the sum of both partners' thresholds (reg 90(3)): 16 hours for a partner in the work-focused interview or work preparation group, nothing for a partner with no work-related requirements for a reason other than earnings, and 35 hours at the national living wage (the adult rate before April 2016) for a partner who cannot be a joint claimant (reg 90(3)(b)). The maximum entitlement is then reduced by 55% (the `gov.dwp.universal_credit.means_test.reduction_rate` parameter) of the claimants' combined earnings above the work allowance (`uc_work_allowance`), and by all of the claimants' unearned income (`uc_unearned_income`), pound for pound. Unearned income is the list in `gov.dwp.universal_credit.means_test.income_definitions.unearned`: retirement pension income (State Pension and private pensions, UC Regulations 2013 regs 66(1)(a) and 67), Carer's Allowance, carer support payment (up to the Carer's Allowance rate, from November 2023), contribution-based JSA and ESA, maternity allowance, industrial injuries benefit, and the tariff income deemed from capital (reg 72). Actual savings interest, dividends and ordinary rental income are excluded from UC unearned income at every capital level; capital counts through its tariff income instead. Asset rules apply on top of this — capital above the lower threshold deems a tariff income, and capital above the upper threshold disqualifies the benunit entirely.\n3. **Benefit cap**: the post-means-test award (`universal_credit_pre_benefit_cap`) is finally reduced by `benefit_cap_reduction` to produce `universal_credit`. The benefit cap only applies to benunits without a benefit-cap exemption (the claimants earning enough, having a qualifying disability benefit, etc.). The earnings exception reads the claimants' earnings only (reg 82(1)(a)), the capped total counts only the claimants' own contributory benefits (WRA 2012 s.96(1)), and the childcare work condition tests only the claimants (reg 32(1)).\n\nUniversal Credit is paid only to families that claim it (`claims_universal_credit`). There are two routes. The input `would_claim_uc` is drawn stochastically when the dataset is built, so that caseload aggregates match published DWP claimant numbers rather than the full eligible population. The second route covers a family whose legacy benefit has closed: tax credits from 2025-26, Income Support and income-based JSA from 2026-27 (`legacy_benefits_closed`). DWP's migration notice ended all such a family's legacy awards, and it claims Universal Credit if the input `would_claim_uc_at_legacy_closure` is true. The dataset draws that input at DWP's Move to Universal Credit claim rates by legacy benefit combination. Families DWP sent to Pension Credit instead are excluded: those on Pension Credit, and those on Child Tax Credit only that meet the Pension Credit age conditions (SI 2019/167 art. 3A). Pension-age families on Working Tax Credit can claim, because the upper age condition is waived for them (SI 2014/1230 reg 60A). Protected mixed-age couples on it can claim jointly (UC Regulations 2013 reg 3(2)(a)). Such a family leaves the pension-age route only if it claims and has positive Universal Credit entitlement before the benefit cap and deductions. A protected mixed-age couple that does not claim, or claims but has nil entitlement, can reclaim Housing Benefit and keep its saving (SI 2019/37 art. 4(3)(b)(ii), as amended by SI 2024/604 art. 2). For Council Tax Reduction, Scotland treats pension-age tax credit migrants entitled to Universal Credit as working-age applicants. England disregards their reg 60A awards for schemes from April 2025, and Wales does so only for schemes from April 2026 (SI 2025/39; WSI 2026/13). Both inputs default to true. To model a family that does not claim, set both to false. Not modelled: transitional protection, including the transitional capital disregard (SI 2014/1230 regs 48-57); pension-age and protected mixed-age Child Tax Credit recipients whose Working Tax Credit entitlement tapered to nil, whom DWP also moved to Universal Credit (HB circular A9/2024 paras 6 and 10). Reported award amounts and current annual hours/income do not establish pre-migration Working Tax Credit entitlement. This gap requires a methodology decision or historical-entitlement input; no proxy is selected; and the choice between routes, since a pension-age family that claims may get less than it would by declining.\n\nParameters live in `policyengine_uk/parameters/gov/dwp/universal_credit/` and the per-element formulas in `policyengine_uk/variables/gov/dwp/universal_credit/`." }, { "cell_type": "code", @@ -1384,4 +1384,4 @@ }, "nbformat": 4, "nbformat_minor": 1 -} \ No newline at end of file +} diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/legacy_closure_migrant_nations.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/legacy_closure_migrant_nations.yaml new file mode 100644 index 0000000000..d2d2842137 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/legacy_closure_migrant_nations.yaml @@ -0,0 +1,257 @@ +# UC (Transitional Provisions) Regulations 2014 reg 60A waives the upper +# age limit for a single or wholly pension-age couple migrated from WTC. +# England disregards these awards for pensioner CTR from the 2025-26 scheme +# year (SI 2025/39 regs 1(3), 4); Wales from 2026-27 (WSI 2026/13 regs +# 1(3), 4). Scotland has no migrant disregard (SSI 2021/249 reg 3). + +- name: A Scottish pension-age WTC migrant uses working-age CTR in 2025 + period: 2025 + input: + people: + claimant: + age: 70 + employment_income: 8_000 + weekly_hours: 30 + working_tax_credit_reported: 2_000 + benunits: + family: + members: [claimant] + would_claim_uc: false + would_claim_uc_at_legacy_closure: true + households: + household: + members: [claimant] + country: SCOTLAND + local_authority: GLASGOW_CITY + council_tax: 2_000 + savings: 0 + output: + legacy_benefits_closed: true + claims_uc_at_legacy_closure: true + is_uc_eligible: true + is_uc_entitled: true + council_tax_reduction_pensioner: false + council_tax_reduction_pensioner_earnings_disregard: 0 + +- name: A Scottish pension-age WTC migrant still uses working-age CTR in 2026 + period: 2026 + input: + people: + claimant: + age: 70 + employment_income: 8_000 + weekly_hours: 30 + working_tax_credit_reported: 2_000 + benunits: + family: + members: [claimant] + would_claim_uc: false + would_claim_uc_at_legacy_closure: true + households: + household: + members: [claimant] + country: SCOTLAND + local_authority: GLASGOW_CITY + council_tax: 2_000 + savings: 0 + output: + legacy_benefits_closed: true + claims_uc_at_legacy_closure: true + is_uc_eligible: true + is_uc_entitled: true + council_tax_reduction_pensioner: false + council_tax_reduction_pensioner_earnings_disregard: 0 + +- name: A Welsh pension-age WTC migrant uses working-age CTR in 2025 + period: 2025 + input: + people: + claimant: + age: 70 + employment_income: 8_000 + weekly_hours: 30 + working_tax_credit_reported: 2_000 + benunits: + family: + members: [claimant] + would_claim_uc: false + would_claim_uc_at_legacy_closure: true + households: + household: + members: [claimant] + country: WALES + local_authority: CARDIFF + council_tax: 2_000 + savings: 0 + output: + legacy_benefits_closed: true + claims_uc_at_legacy_closure: true + is_uc_eligible: true + is_uc_entitled: true + council_tax_reduction_pensioner: false + council_tax_reduction_pensioner_earnings_disregard: 0 + +- name: A Welsh pension-age WTC migrant uses pensioner CTR from the 2026 scheme + period: 2026 + input: + people: + claimant: + age: 70 + employment_income: 8_000 + weekly_hours: 30 + working_tax_credit_reported: 2_000 + benunits: + family: + members: [claimant] + would_claim_uc: false + would_claim_uc_at_legacy_closure: true + households: + household: + members: [claimant] + country: WALES + local_authority: CARDIFF + council_tax: 2_000 + savings: 0 + output: + legacy_benefits_closed: true + claims_uc_at_legacy_closure: true + is_uc_eligible: true + is_uc_entitled: true + council_tax_reduction_pensioner: true + +- name: An English pension-age WTC migrant uses pensioner CTR from the 2025 scheme + period: 2025 + input: + people: + claimant: + age: 70 + employment_income: 8_000 + weekly_hours: 30 + working_tax_credit_reported: 2_000 + benunits: + family: + members: [claimant] + would_claim_uc: false + would_claim_uc_at_legacy_closure: true + households: + household: + members: [claimant] + country: ENGLAND + local_authority: MAIDSTONE + council_tax: 2_000 + savings: 0 + output: + legacy_benefits_closed: true + claims_uc_at_legacy_closure: true + is_uc_eligible: true + is_uc_entitled: true + council_tax_reduction_pensioner: true + +- name: An English mixed-age WTC migrant has no reg 60A pensioner CTR exemption + period: 2025 + input: + people: + older: + age: 70 + employment_income: 8_000 + weekly_hours: 30 + working_tax_credit_reported: 2_000 + younger: + age: 40 + benunits: + family: + members: [older, younger] + would_claim_uc: false + would_claim_uc_at_legacy_closure: true + has_mixed_age_couple_pension_credit_saving: true + households: + household: + members: [older, younger] + country: ENGLAND + local_authority: MAIDSTONE + council_tax: 2_000 + savings: 0 + output: + legacy_benefits_closed: true + claims_uc_at_legacy_closure: true + is_uc_eligible: true + is_uc_entitled: true + council_tax_reduction_pensioner: false + +- name: A Welsh mixed-age WTC migrant has no reg 60A exemption in the 2026 scheme + period: 2026 + input: + people: + older: + age: 70 + employment_income: 8_000 + weekly_hours: 30 + working_tax_credit_reported: 2_000 + younger: + age: 40 + benunits: + family: + members: [older, younger] + would_claim_uc: false + would_claim_uc_at_legacy_closure: true + has_mixed_age_couple_pension_credit_saving: true + households: + household: + members: [older, younger] + country: WALES + local_authority: CARDIFF + council_tax: 2_000 + savings: 0 + output: + legacy_benefits_closed: true + claims_uc_at_legacy_closure: true + is_uc_eligible: true + is_uc_entitled: true + council_tax_reduction_pensioner: false + +- name: A Scottish pensioner with a non-migrant UC run-on award keeps pensioner CTR + # An input award without a working-age claimant or reg 60A migration + # retains the existing approximation for an award awaiting termination, + # or awarded in error: SSI 2021/249 reg 3(2). + period: 2025 + input: + people: + claimant: + age: 70 + benunits: + family: + members: [claimant] + universal_credit_pre_benefit_cap: 4_000 + households: + household: + members: [claimant] + country: SCOTLAND + local_authority: GLASGOW_CITY + council_tax: 2_000 + savings: 0 + output: + claims_uc_at_legacy_closure: false + is_uc_entitled: true + council_tax_reduction_pensioner: true + +- name: A Welsh pensioner with a non-migrant UC input keeps the existing CTR classification + period: 2025 + input: + people: + claimant: + age: 70 + benunits: + family: + members: [claimant] + universal_credit_pre_benefit_cap: 4_000 + households: + household: + members: [claimant] + country: WALES + local_authority: CARDIFF + council_tax: 2_000 + savings: 0 + output: + claims_uc_at_legacy_closure: false + is_uc_entitled: true + council_tax_reduction_pensioner: true diff --git a/policyengine_uk/tests/test_uc_legacy_closure_properties.py b/policyengine_uk/tests/test_uc_legacy_closure_properties.py index 81a15f5bcd..2455c6a02d 100644 --- a/policyengine_uk/tests/test_uc_legacy_closure_properties.py +++ b/policyengine_uk/tests/test_uc_legacy_closure_properties.py @@ -24,7 +24,8 @@ It gets no Housing Benefit unless it keeps the pension-age route by not claiming (or by having nil Universal Credit entitlement). A family that leaves that route gets no Pension Credit either, and any closure claimant - within the capital limit is eligible for Universal Credit. + with a qualifying claimant and within the capital limit is eligible for + Universal Credit. 4. Exclusive: no family gets Universal Credit with Pension Credit or with Housing Benefit. 5. Metamorphic, the flag is inert unless it decides a claim: negating @@ -229,11 +230,25 @@ def test_closure_claims_and_the_end_of_legacy_awards(units): ]: assert v[benefit][i] == 0, (year, benefit, unit) uc_eligible = bool(v["is_uc_eligible"][i]) - # Any closure claimant within the capital limit is eligible for - # Universal Credit: on the pension-age route through a joint claim - # (reg 3(2)(a)) or the reg 60A waiver. - if claims_at_closure and v["uc_assessable_capital"][i] <= 16_000: - assert uc_eligible, (year, unit) + # A working-age claimant, or the reg 60A upper-age waiver for + # WTC migrants, is still needed. A pensioner with an ineligible + # working-age partner cannot claim on an IS/JSA closure alone. + age_qualified = ( + any( + role != "child" + and age <= 60 + and not ( + role == "partner" and unit.get("ineligible_partner", False) + ) + for role, age in unit["members"] + ) + or unit["reported"]["working_tax_credit_reported"] > 0 + ) + if claims_at_closure: + expected_eligible = ( + age_qualified and v["uc_assessable_capital"][i] <= 16_000 + ) + assert uc_eligible == expected_eligible, (year, unit) left = ( pension_age_route and claims_at_closure diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.py index 3de57569f1..f070259c7d 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.household.demographic.country import Country class council_tax_reduction_pensioner(Variable): @@ -12,7 +13,13 @@ class council_tax_reduction_pensioner(Variable): "on Income Support, income-based Jobseeker's Allowance or " "income-related Employment and Support Allowance, or has an award of " "Universal Credit. A mixed-age couple on Universal Credit is therefore " - "not a pensioner in England, Wales or Scotland. Each applicant's " + "not a pensioner in England, Wales or Scotland. Wholly pension-age " + "tax credit migrants whose upper UC age limit is waived under " + "regulation 60A retain pensioner treatment in England from the " + "2025-26 scheme year and Wales from 2026-27; Scotland treats their " + "UC awards under the working-age rules. Other wholly pension-age " + "UC input awards retain the model's approximation for an award " + "awaiting termination or made in error. Each applicant's " "scheme follows their own family, so where families share the rent " "and each claims, a working-age family is assessed under the " "working-age rules even if the household head's family is " @@ -23,8 +30,12 @@ class council_tax_reduction_pensioner(Variable): definition_period = YEAR reference = ( "https://www.legislation.gov.uk/uksi/2012/2885/regulation/3", + "https://www.legislation.gov.uk/uksi/2025/39/regulation/1", + "https://www.legislation.gov.uk/uksi/2025/39/regulation/4", "https://www.legislation.gov.uk/uksi/2012/2886/schedule/paragraph/3", "https://www.legislation.gov.uk/wsi/2013/3029/regulation/3", + "https://www.legislation.gov.uk/wsi/2026/13/regulation/1", + "https://www.legislation.gov.uk/wsi/2026/13/regulation/4", "https://www.legislation.gov.uk/ssi/2021/249/regulation/3", "https://www.legislation.gov.uk/ssi/2012/319/regulation/12", "https://www.legislation.gov.uk/ssi/2012/303/regulation/12", @@ -57,29 +68,42 @@ def formula(benunit, period, parameters): & ~head_applies_alone ) # The award before the benefit cap: the cap reduces an award - # (UC Regs 2013 reg 81) rather than removing it. An award counts only - # where the applicant or partner is under the qualifying age, which - # Universal Credit needs (UC Regs 2013 reg 3(2)(a)). A dependant is + # (UC Regs 2013 reg 81) rather than removing it. An ordinary award + # counts where the applicant or partner is under the qualifying age + # (UC Regs 2013 reg 3(2)(a)). A dependant is # neither: outside a head who applies alone, the applicant and partner # are the benefit unit's claimant and partner, and # is_claimant_or_partner presumes a member under 20 and at least 16 # years younger than the claimant to be their child, so a pensioner's # 18- or 19-year-old does not count, whether or not is_parent # identifies the pensioner as the parent. - # This also stands in for the rules that disregard an award held after - # both members reach the qualifying age (SI 2012/2885 reg 3(2); - # WSI 2013/3029 reg 3(2); SSI 2021/249 reg 3(2)). England, and Wales - # for schemes from 2026-27, also disregard a tax credit migrant's award - # (UC (TP) Regs 2014 reg 60A) and Scotland does not. The model's reg 60A - # migrants (claims_uc_at_legacy_closure) have no applicant or partner - # under the qualifying age, so their award is disregarded in every - # nation; counting it in Scotland, and in Wales before 2026-27, is not - # modelled. working_age_applicant = benunit.any(applicant_or_partner & ~over_qualifying_age) + # The model recognises reg 60A awards through the closure claim and + # reported WTC. The waiver applies to a single pension-age claimant + # or joint claimants both pension age, not mixed-age couples. + pension_age_tax_credit_migrant = ( + benunit("claims_uc_at_legacy_closure", period) + & (add(benunit, period, ["working_tax_credit_reported"]) > 0) + & ~working_age_applicant + ) + country = benunit.household("country", period) + # Commencement alone does not determine the scheme: SI 2025/39 + # reg 1(3) applies from the financial year beginning April 2025; + # WSI 2026/13 reg 1(3) from April 2026. Model years represent those + # scheme years. SSI 2021/249 reg 3 has no reg 60A migrant exception. + migrant_award_disregarded = ( + (country == Country.ENGLAND) & (period.start.year >= 2025) + ) | ((country == Country.WALES) & (period.start.year >= 2026)) + # Other awards with no working-age applicant retain the existing + # approximation for a final UC assessment-period run-on or an award + # made in error (SI 2012/2885 reg 3(2); SSI 2021/249 reg 3(2)). universal_credit_award = ( benunit("is_uc_entitled", period) & ~head_applies_alone - & working_age_applicant + & ( + working_age_applicant + | (pension_age_tax_credit_migrant & ~migrant_award_disregarded) + ) ) return ( attained_qualifying_age & ~income_related_benefit & ~universal_credit_award From 84c760bf4f1a96cd675e924b78fbbf795e23b985 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 07:10:33 -0400 Subject: [PATCH 8/8] Cover the migration age waiver in closure properties --- .../test_uc_legacy_closure_properties.py | 67 ++++++++++++++++--- 1 file changed, 56 insertions(+), 11 deletions(-) diff --git a/policyengine_uk/tests/test_uc_legacy_closure_properties.py b/policyengine_uk/tests/test_uc_legacy_closure_properties.py index 2455c6a02d..2e95eed8c1 100644 --- a/policyengine_uk/tests/test_uc_legacy_closure_properties.py +++ b/policyengine_uk/tests/test_uc_legacy_closure_properties.py @@ -28,10 +28,11 @@ Universal Credit. 4. Exclusive: no family gets Universal Credit with Pension Credit or with Housing Benefit. -5. Metamorphic, the flag is inert unless it decides a claim: negating +5. Metamorphic, the flag is inert unless it decides a claim or age waiver: negating would_claim_uc_at_legacy_closure changes no benefit for a family not moved off legacy benefits, or for a working-age one that would claim - Universal Credit anyway. In particular nothing changes in 2024, before + Universal Credit anyway and has a qualifying working-age claimant. In + particular nothing changes in 2024, before any closure. 6. Differential: a working-age family claiming at the closure gets the benefits it would get if would_claim_uc were true, including when a @@ -202,6 +203,15 @@ def expected_closure(unit, year, pension_age_route, mixed_age): ) +def has_working_age_claimant(unit): + return any( + role != "child" + and age <= 60 + and not (role == "partner" and unit.get("ineligible_partner", False)) + for role, age in unit["members"] + ) + + @PROPERTY_SETTINGS @given(st.lists(families(), min_size=1, max_size=25)) def test_closure_claims_and_the_end_of_legacy_awards(units): @@ -234,14 +244,7 @@ def test_closure_claims_and_the_end_of_legacy_awards(units): # WTC migrants, is still needed. A pensioner with an ineligible # working-age partner cannot claim on an IS/JSA closure alone. age_qualified = ( - any( - role != "child" - and age <= 60 - and not ( - role == "partner" and unit.get("ineligible_partner", False) - ) - for role, age in unit["members"] - ) + has_working_age_claimant(unit) or unit["reported"]["working_tax_credit_reported"] > 0 ) if claims_at_closure: @@ -263,6 +266,26 @@ def test_closure_claims_and_the_end_of_legacy_awards(units): @PROPERTY_SETTINGS @given(st.lists(families(), min_size=1, max_size=25)) +@example( + [ + dict( + shape="mixed_age", + members=[("claimant", 70), ("partner", 40)], + reported={ + name: 2_000.0 if name == "working_tax_credit_reported" else 0.0 + for name in LEGACY + }, + saving=False, + tenure="RENT_FROM_COUNCIL", + rent=9_000.0, + earnings=0.0, + savings=0.0, + would_claim_uc=True, + claims_at_closure=True, + ineligible_partner=True, + ) + ] +) def test_the_closure_flag_only_decides_closure_claims(units): drawn = calculate(units) negated = calculate(units, negate_flag=True) @@ -270,10 +293,32 @@ def test_the_closure_flag_only_decides_closure_claims(units): for i, unit in enumerate(units): # On the pension-age route would_claim_uc gives no Universal # Credit, so there the flag alone decides the closure claim. - decides = bool(drawn[year]["legacy_benefits_closed"][i]) and ( + # A sole pension-age claimant also needs the reg 60A age waiver, + # even without a protected Pension Credit route. + age_waiver_decides = unit["reported"][ + "working_tax_credit_reported" + ] > 0 and not has_working_age_claimant(unit) + closed = bool(drawn[year]["legacy_benefits_closed"][i]) + decides = closed and ( not unit["would_claim_uc"] or bool(drawn[year]["meets_pension_credit_age_conditions"][i]) + or age_waiver_decides ) + if closed and age_waiver_decides: + for values, closure_flag in ( + (drawn, unit["claims_at_closure"]), + (negated, not unit["claims_at_closure"]), + ): + expected_eligible = ( + closure_flag + and values[year]["uc_assessable_capital"][i] <= 16_000 + ) + assert ( + bool(values[year]["is_uc_eligible"][i]) == expected_eligible + ), ( + year, + unit, + ) if year == 2024: assert not decides, unit if decides: