From be2d8e459ce395e10e7b3b526d92d987137dad26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Sat, 17 Oct 2026 06:51:44 +0100 Subject: [PATCH] Keep WTC worker age and hours conditions together --- changelog.d/2042.fixed.md | 1 + docs/book/programs/gov/dwp/tax-credits.md | 20 +- .../working_tax_credit/min_age.yaml | 9 + ...in_age_without_children_or_disability.yaml | 9 + .../family/tax_credits_working_hours.yaml | 476 ++++++++++++++++++ ...rking_tax_credit_eligibility_properties.py | 116 +++++ .../variables/gov/dwp/is_WTC_eligible.py | 43 ++ 7 files changed, 673 insertions(+), 1 deletion(-) create mode 100644 changelog.d/2042.fixed.md create mode 100644 policyengine_uk/parameters/gov/dwp/tax_credits/working_tax_credit/min_age.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/tax_credits/working_tax_credit/min_age_without_children_or_disability.yaml create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/tax_credits_working_hours.yaml create mode 100644 policyengine_uk/tests/test_working_tax_credit_eligibility_properties.py diff --git a/changelog.d/2042.fixed.md b/changelog.d/2042.fixed.md new file mode 100644 index 0000000000..d99e12b288 --- /dev/null +++ b/changelog.d/2042.fixed.md @@ -0,0 +1 @@ +- Corrected Working Tax Credit eligibility so age, disability and hours conditions applied to the same claimant or partner, while preserving the parental combined-hours and partner-exception routes. diff --git a/docs/book/programs/gov/dwp/tax-credits.md b/docs/book/programs/gov/dwp/tax-credits.md index eac83f4600..2ee1aa23e1 100644 --- a/docs/book/programs/gov/dwp/tax-credits.md +++ b/docs/book/programs/gov/dwp/tax-credits.md @@ -47,7 +47,25 @@ from the means test, gated by the `would_claim_WTC` take-up input. The `min_hours` tree (`min_hours/default.yaml`, `lower.yaml`, `old_age.yaml`, `couple_with_children.yaml`) encodes the bands that gate WTC eligibility — typically 30 hours/week for adults with no kids, 16 for -lone parents, with a lower band for over-60s. +lone parents, with a lower band for workers aged 60 or over. + +For the joint-claim rules from 6 April 2012, an individual's own age or +disability must qualify that individual's hours. Two childless workers cannot +pool 15 hours each to meet the ordinary 30-hour route, and a nonworking +partner cannot lend their age or disability to the worker. Parents can combine +24 hours, with at least one claimant working 16 hours. Hours worked by a +child or other dependant do not count towards that total. The `min_age` and +`min_age_without_children_or_disability` parameters provide the 16-year and +25-year thresholds; the existing `min_hours.old_age` parameter provides 60. +Native annual formula selection retains the earlier formula for a year whose +start precedes 6 April 2012; this change does not certify all earlier rules. + +`is_disabled_for_benefits` remains a generic proxy for the statutory disabled +worker conditions. For parents, the model also retains its existing proxy +for an exception based on the other partner's disability. These flags do not +encode all regulation 9 or regulation 13 tests, incapacity, hospital or prison +status, carer's allowances, or the expected four-week and paid-work conditions. +The reported-award, migration, take-up and scheme-closure rules remain separate. ## Child Tax Credit (CTC) diff --git a/policyengine_uk/parameters/gov/dwp/tax_credits/working_tax_credit/min_age.yaml b/policyengine_uk/parameters/gov/dwp/tax_credits/working_tax_credit/min_age.yaml new file mode 100644 index 0000000000..7d80b22d1e --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/tax_credits/working_tax_credit/min_age.yaml @@ -0,0 +1,9 @@ +description: Minimum age for the specified Working Tax Credit 16-hour and parental routes. +values: + # Joint-claim variations inserted by SI 2012/848 from 6 April 2012. + 2012-04-06: 16 +metadata: + unit: year + reference: + title: The Working Tax Credit (Entitlement and Maximum Rate) Regulations 2002, regulation 4 + href: https://www.legislation.gov.uk/uksi/2002/2005/regulation/4/2024-04-06 diff --git a/policyengine_uk/parameters/gov/dwp/tax_credits/working_tax_credit/min_age_without_children_or_disability.yaml b/policyengine_uk/parameters/gov/dwp/tax_credits/working_tax_credit/min_age_without_children_or_disability.yaml new file mode 100644 index 0000000000..6a50fc0c40 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/tax_credits/working_tax_credit/min_age_without_children_or_disability.yaml @@ -0,0 +1,9 @@ +description: Minimum age for the ordinary Working Tax Credit 30-hour route without children or a qualifying disability. +values: + # Joint-claim variations inserted by SI 2012/848 from 6 April 2012. + 2012-04-06: 25 +metadata: + unit: year + reference: + title: The Working Tax Credit (Entitlement and Maximum Rate) Regulations 2002, regulation 4 + href: https://www.legislation.gov.uk/uksi/2002/2005/regulation/4/2024-04-06 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/tax_credits_working_hours.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/tax_credits_working_hours.yaml new file mode 100644 index 0000000000..b4c1d9e90e --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/tax_credits_working_hours.yaml @@ -0,0 +1,476 @@ +# Same-worker WTC conditions, SI2002/2005reg4 (joint variations from6April2012). +# Amounts use unchanged2024 model parameters; no eligibility/element/final-award overrides. +# Generic disability and other-disabled-partner exception remain inherited proxies. +- name: A childless worker cannot borrow a nonworking partners age sixty + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult_0: + age: 60 + hours_worked: 0 + employment_income: 0 + is_parent: false + is_disabled_for_benefits: false + working_tax_credit_reported: 1 + adult_1: + age: 40 + hours_worked: 1040 + employment_income: 5000 + is_parent: false + is_disabled_for_benefits: false + benunits: + family: + members: + - adult_0 + - adult_1 + output: + working_tax_credit: 0 + is_WTC_eligible: false +- name: A worker aged sixty qualifies on their own sixteen hours + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult_0: + age: 60 + hours_worked: 832 + employment_income: 5000 + is_parent: false + is_disabled_for_benefits: false + working_tax_credit_reported: 1 + adult_1: + age: 40 + hours_worked: 0 + employment_income: 0 + is_parent: false + is_disabled_for_benefits: false + benunits: + family: + members: + - adult_0 + - adult_1 + output: + is_WTC_eligible: true + working_tax_credit: 4935 +- name: A thirty hour worker cannot borrow a partners age twenty five + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult_0: + age: 40 + hours_worked: 0 + employment_income: 0 + is_parent: false + is_disabled_for_benefits: false + working_tax_credit_reported: 1 + adult_1: + age: 24 + hours_worked: 1560 + employment_income: 5000 + is_parent: false + is_disabled_for_benefits: false + benunits: + family: + members: + - adult_0 + - adult_1 + output: + working_tax_credit: 0 + is_WTC_eligible: false +- name: A worker aged twenty five qualifies on their own thirty hours + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult_0: + age: 40 + hours_worked: 0 + employment_income: 0 + is_parent: false + is_disabled_for_benefits: false + working_tax_credit_reported: 1 + adult_1: + age: 25 + hours_worked: 1560 + employment_income: 5000 + is_parent: false + is_disabled_for_benefits: false + benunits: + family: + members: + - adult_0 + - adult_1 + output: + is_WTC_eligible: true + working_tax_credit: 5950 +- name: Two childless fifteen hour workers do not make one thirty hour worker + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult_0: + age: 40 + hours_worked: 780 + employment_income: 5000 + is_parent: false + is_disabled_for_benefits: false + working_tax_credit_reported: 1 + adult_1: + age: 40 + hours_worked: 780 + employment_income: 5000 + is_parent: false + is_disabled_for_benefits: false + benunits: + family: + members: + - adult_0 + - adult_1 + output: + working_tax_credit: 0 + is_WTC_eligible: false +- name: A childless worker cannot borrow a nonworking partners disability + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult_0: + age: 40 + hours_worked: 0 + employment_income: 0 + is_parent: false + is_disabled_for_benefits: true + working_tax_credit_reported: 1 + adult_1: + age: 40 + hours_worked: 1040 + employment_income: 5000 + is_parent: false + is_disabled_for_benefits: false + benunits: + family: + members: + - adult_0 + - adult_1 + output: + working_tax_credit: 0 + is_WTC_eligible: false +- name: A disabled worker qualifies on their own sixteen hours + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult_0: + age: 40 + hours_worked: 832 + employment_income: 5000 + is_parent: false + is_disabled_for_benefits: true + working_tax_credit_reported: 1 + adult_1: + age: 40 + hours_worked: 0 + employment_income: 0 + is_parent: false + is_disabled_for_benefits: false + benunits: + family: + members: + - adult_0 + - adult_1 + output: + is_WTC_eligible: true + working_tax_credit: 8870 +- name: A worker aged fifty nine still needs thirty hours without children + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult_0: + age: 59 + hours_worked: 832 + employment_income: 5000 + is_parent: false + is_disabled_for_benefits: false + working_tax_credit_reported: 1 + adult_1: + age: 40 + hours_worked: 0 + employment_income: 0 + is_parent: false + is_disabled_for_benefits: false + benunits: + family: + members: + - adult_0 + - adult_1 + output: + is_WTC_eligible: false + working_tax_credit: 0 +- name: Joint parents may combine sixteen and eight hours + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult_0: + age: 30 + hours_worked: 832 + employment_income: 3000 + is_parent: true + is_disabled_for_benefits: false + working_tax_credit_reported: 1 + adult_1: + age: 28 + hours_worked: 416 + employment_income: 2000 + is_parent: true + is_disabled_for_benefits: false + child_0: + age: 10 + hours_worked: 0 + employment_income: 0 + benunits: + family: + members: + - adult_0 + - adult_1 + - child_0 + output: + is_WTC_eligible: true + working_tax_credit: 4935 +- name: Joint parents twelve hours each do not meet the individual sixteen hour limb + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult_0: + age: 30 + hours_worked: 624 + employment_income: 5000 + is_parent: true + is_disabled_for_benefits: false + working_tax_credit_reported: 1 + adult_1: + age: 28 + hours_worked: 624 + employment_income: 5000 + is_parent: true + is_disabled_for_benefits: false + child_0: + age: 10 + hours_worked: 0 + employment_income: 0 + benunits: + family: + members: + - adult_0 + - adult_1 + - child_0 + output: + is_WTC_eligible: false + working_tax_credit: 0 +- name: Joint parents sixteen and seven hours do not meet twenty four hours + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult_0: + age: 30 + hours_worked: 832 + employment_income: 5000 + is_parent: true + is_disabled_for_benefits: false + working_tax_credit_reported: 1 + adult_1: + age: 28 + hours_worked: 364 + employment_income: 5000 + is_parent: true + is_disabled_for_benefits: false + child_0: + age: 10 + hours_worked: 0 + employment_income: 0 + benunits: + family: + members: + - adult_0 + - adult_1 + - child_0 + output: + is_WTC_eligible: false + working_tax_credit: 0 +- name: Parents retain the other disabled partner exception proxy + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult_0: + age: 30 + hours_worked: 832 + employment_income: 5000 + is_parent: true + is_disabled_for_benefits: false + working_tax_credit_reported: 1 + adult_1: + age: 28 + hours_worked: 0 + employment_income: 0 + is_parent: true + is_disabled_for_benefits: true + child_0: + age: 10 + hours_worked: 0 + employment_income: 0 + benunits: + family: + members: + - adult_0 + - adult_1 + - child_0 + output: + is_WTC_eligible: true + working_tax_credit: 4935 +- name: A childs eight hours cannot complete the parents twenty four hours + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult_0: + age: 30 + hours_worked: 832 + employment_income: 5000 + is_parent: true + is_disabled_for_benefits: false + working_tax_credit_reported: 1 + adult_1: + age: 28 + hours_worked: 0 + employment_income: 0 + is_parent: true + is_disabled_for_benefits: false + child_0: + age: 15 + hours_worked: 416 + employment_income: 1000 + benunits: + family: + members: + - adult_0 + - adult_1 + - child_0 + output: + working_tax_credit: 0 + is_WTC_eligible: false +- name: An unreported historical claim remains ineligible + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult_0: + age: 40 + hours_worked: 0 + employment_income: 0 + is_parent: false + is_disabled_for_benefits: false + working_tax_credit_reported: 0 + adult_1: + age: 25 + hours_worked: 1560 + employment_income: 5000 + is_parent: false + is_disabled_for_benefits: false + benunits: + family: + members: + - adult_0 + - adult_1 + output: + is_WTC_eligible: false + working_tax_credit: 0 +- name: A closed historical claim remains ineligible + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult_0: + age: 40 + hours_worked: 0 + employment_income: 0 + is_parent: false + is_disabled_for_benefits: false + working_tax_credit_reported: 1 + adult_1: + age: 25 + hours_worked: 1560 + employment_income: 5000 + is_parent: false + is_disabled_for_benefits: false + benunits: + family: + members: + - adult_0 + - adult_1 + legacy_benefits_closed: true + output: + is_WTC_eligible: false + working_tax_credit: 0 +- name: A single ordinary worker qualifies at thirty hours + period: 2024 + absolute_error_margin: 0.01 + input: + people: + worker: + age: 35 + hours_worked: 1560 + employment_income: 5000 + working_tax_credit_reported: 1 + is_parent: false + benunits: + family: + members: + - worker + output: + is_WTC_eligible: true + working_tax_credit: 3450 +- name: A lone parent retains the sixteen hour route + period: 2024 + absolute_error_margin: 0.01 + input: + people: + worker: + age: 30 + hours_worked: 832 + employment_income: 5000 + working_tax_credit_reported: 1 + is_parent: true + child_0: + age: 10 + benunits: + family: + members: + - worker + - child_0 + output: + is_WTC_eligible: true + working_tax_credit: 4935 +- name: A single worker below the own age threshold does not qualify + period: 2024 + absolute_error_margin: 0.01 + input: + people: + worker: + age: 24 + hours_worked: 1560 + employment_income: 5000 + working_tax_credit_reported: 1 + is_parent: false + benunits: + family: + members: + - worker + output: + is_WTC_eligible: false + working_tax_credit: 0 diff --git a/policyengine_uk/tests/test_working_tax_credit_eligibility_properties.py b/policyengine_uk/tests/test_working_tax_credit_eligibility_properties.py new file mode 100644 index 0000000000..45c54a012a --- /dev/null +++ b/policyengine_uk/tests/test_working_tax_credit_eligibility_properties.py @@ -0,0 +1,116 @@ +"""WTC worker conditions stay with their person and benefit unit. + +These controls use genuine work/claim inputs and literal final-award oracles. +They exercise composition and parameter hooks, rather than duplicate the +production predicate. Disability retains the model's existing generic proxy. +""" + +import numpy as np +import pytest + +from policyengine_uk import Simulation + + +YEAR = 2024 + + +def situation(families, reverse=False): + people, benunits, households = {}, {}, {} + for i, (adults, children) in enumerate(families): + names = [] + for j, inputs in enumerate(adults + children): + name = f"person_{i}_{j}" + people[name] = {key: {YEAR: value} for key, value in inputs.items()} + people[name]["is_claimant_or_partner"] = {YEAR: j < len(adults)} + names.append(name) + if reverse: + names.reverse() + benunits[f"family_{i}"] = {"members": names} + households[f"household_{i}"] = {"members": names} + if reverse: + people = dict(reversed(list(people.items()))) + return {"people": people, "benunits": benunits, "households": households} + + +def worker(age, hours, earnings=5_000, **inputs): + return { + "age": age, + "hours_worked": hours * 52, + "employment_income": earnings if hours else 0, + **inputs, + } + + +@pytest.mark.parametrize("reverse", [False, True]) +def test_worker_conditions_do_not_move_between_people_or_families(reverse): + families = [ + ([worker(40, 0, working_tax_credit_reported=1), worker(24, 30)], []), + ([worker(40, 0, working_tax_credit_reported=1), worker(25, 30)], []), + ( + [ + worker(30, 16, is_parent=True, working_tax_credit_reported=1), + worker(28, 0, is_parent=True), + ], + [worker(15, 8, earnings=1_000)], + ), + ( + [ + worker( + 30, + 16, + earnings=3_000, + is_parent=True, + working_tax_credit_reported=1, + ), + worker(28, 8, earnings=2_000, is_parent=True), + ], + [{"age": 10}], + ), + ] + simulation = Simulation(situation=situation(families, reverse)) + # Request the final award first, so an eligibility-only cache cannot hide + # a cross-person or cross-family error in the cash consumer. + np.testing.assert_allclose( + simulation.calculate("working_tax_credit", YEAR), + [0, 5_950, 0, 4_935], + atol=0.01, + rtol=0, + ) + np.testing.assert_array_equal( + simulation.calculate("is_WTC_eligible", YEAR), [False, True, False, True] + ) + + +@pytest.mark.parametrize( + "age,hours,disabled,parameter,value,baseline_cash", + [ + (25, 30, False, "min_age_without_children_or_disability", 26, 3_450), + (16, 16, True, "min_age", 17, 6_370), + (60, 16, False, "min_hours.old_age", 61, 2_435), + ], +) +def test_worker_age_parameters_change_actual_cash_awards( + age, hours, disabled, parameter, value, baseline_cash +): + inputs = situation( + [ + ( + [ + worker( + age, + hours, + is_disabled_for_benefits=disabled, + working_tax_credit_reported=1, + ) + ], + [], + ) + ] + ) + baseline = Simulation(situation=inputs) + reform = Simulation( + situation=inputs, + reform={f"gov.dwp.tax_credits.working_tax_credit.{parameter}": value}, + ) + assert baseline.calculate("working_tax_credit", YEAR)[0] == baseline_cash + assert reform.calculate("working_tax_credit", YEAR)[0] == 0 diff --git a/policyengine_uk/variables/gov/dwp/is_WTC_eligible.py b/policyengine_uk/variables/gov/dwp/is_WTC_eligible.py index 84b2d6c770..9e18d4d049 100644 --- a/policyengine_uk/variables/gov/dwp/is_WTC_eligible.py +++ b/policyengine_uk/variables/gov/dwp/is_WTC_eligible.py @@ -51,3 +51,46 @@ def formula(benunit, period, parameters): | (medium_req & meets_medium) | (higher_req & meets_higher) ) & already_claiming + + def formula_2012_04_06(benunit, period, parameters): + WTC = parameters(period).gov.dwp.tax_credits.working_tax_credit + person = benunit.members + claimant = person("is_claimant_or_partner", period) + age = person("age", period) + hours = person("weekly_hours", period) + disabled = person("is_disabled_for_benefits", period) + works_lower_hours = claimant & (hours >= WTC.min_hours.lower) + working_at_minimum_age = works_lower_hours & (age >= WTC.min_age) + disabled_worker = benunit.any(working_at_minimum_age & disabled) + older_worker = benunit.any(works_lower_hours & (age >= WTC.min_hours.old_age)) + + responsible_for_child = benunit( + "is_responsible_for_child_or_qualifying_young_person_for_child_tax_credit", + period, + ) + ordinary_worker = ~responsible_for_child & benunit.any( + claimant + & (age >= WTC.min_age_without_children_or_disability) + & (hours >= WTC.min_hours.default) + ) + lone_parent = ( + benunit("is_single", period) + & responsible_for_child + & benunit.any(working_at_minimum_age) + ) + joint_parents = benunit("is_couple", period) & responsible_for_child + meets_joint_hours = ( + benunit.sum(hours * claimant) >= WTC.min_hours.couple_with_children + ) & benunit.any(working_at_minimum_age) + # Preserve the existing other-disabled-partner exception proxy. This + # generic flag does not encode every reg 13 exception or disability test. + partner_exception_proxy = benunit.any(claimant & disabled) & benunit.any( + working_at_minimum_age + ) + parents = joint_parents & (meets_joint_hours | partner_exception_proxy) + already_claiming = ( + add(benunit, period, ["working_tax_credit_reported"]) > 0 + ) & ~benunit("legacy_benefits_closed", period) + return ( + disabled_worker | older_worker | ordinary_worker | lone_parent | parents + ) & already_claiming