From 28eb7d2d44373f61271967dda242c34257ad3de6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 01:58:05 -0400 Subject: [PATCH 1/9] Lift the tax credit income test for State Pension Credit TCA 2002 s.7(2) lifts the tax credit income test while the claimant or either joint claimant is entitled to a prescribed benefit, and SI 2002/2008 reg 4(1)(d) prescribes State Pension Credit (from 26 November 2003). The model passported Income Support, income-based JSA and income-related ESA but not Pension Credit. Pension Credit counts working tax credit as income (SPCA 2002 s.15(1)(b)), so reading Pension Credit from the income test is circular. The passport is taken from Pension Credit calculated in a core branch in which the income test is lifted: it holds when Pension Credit stays payable with the passported award counted, and the simulation's own award and Pension Credit then equal the branch's. In years with no tax credit awards Pension Credit is read directly; wtc_entitlement returns before the income test in those years, so there is no cycle. Reg 4(1)(d) covers either credit (SPCA 2002 s.1(3)). The reg 4(1)(a) exception (IS Regs 1987 reg 6(2)-(3), the lone parent run-on) is spent from 25 October 2004, and reg 4(2) (the WTC four-week run-on) has no period in a whole-year award; both are documented, not modelled. Adds gov.dwp.tax_credits.means_test.pension_credit_passport, YAML tests hand-computed from the law, and Hypothesis properties (differential against a passported simulation, consistency, no-harm, request-order independence, and the no-award years). Co-Authored-By: Claude Opus 5.5 --- ...x-credits-pension-credit-passport.fixed.md | 1 + .../means_test/pension_credit_passport.yaml | 15 + .../tax_credits_pension_credit_passport.yaml | 301 ++++++++++++++++++ ...dits_pension_credit_passport_properties.py | 227 +++++++++++++ .../gov/dwp/tax_credits_applicable_income.py | 55 +++- .../variables/gov/dwp/wtc_entitlement.py | 6 + 6 files changed, 602 insertions(+), 3 deletions(-) create mode 100644 changelog.d/tax-credits-pension-credit-passport.fixed.md create mode 100644 policyengine_uk/parameters/gov/dwp/tax_credits/means_test/pension_credit_passport.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits_pension_credit_passport.yaml create mode 100644 policyengine_uk/tests/test_tax_credits_pension_credit_passport_properties.py diff --git a/changelog.d/tax-credits-pension-credit-passport.fixed.md b/changelog.d/tax-credits-pension-credit-passport.fixed.md new file mode 100644 index 0000000000..db72c2a198 --- /dev/null +++ b/changelog.d/tax-credits-pension-credit-passport.fixed.md @@ -0,0 +1 @@ +- Lift the tax credit income test for a claimant or couple entitled to State Pension Credit, guarantee credit or savings credit (TCA 2002 s.7(2); SI 2002/2008 reg 4(1)(d)). Working tax credit is Pension Credit income (SPCA 2002 s.15(1)(b)), so the passport applies where Pension Credit is payable with the passported award counted as income. The new parameter `gov.dwp.tax_credits.means_test.pension_credit_passport` dates the rule from 26 November 2003. diff --git a/policyengine_uk/parameters/gov/dwp/tax_credits/means_test/pension_credit_passport.yaml b/policyengine_uk/parameters/gov/dwp/tax_credits/means_test/pension_credit_passport.yaml new file mode 100644 index 0000000000..6c5b0ad872 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/tax_credits/means_test/pension_credit_passport.yaml @@ -0,0 +1,15 @@ +description: Whether entitlement to State Pension Credit (either credit) lifts the tax credit income test. +values: + 2003-04-06: false + # SI 2003/2815 reg 18 added reg 4(1)(d) from 26 November 2003. + 2003-11-26: true +metadata: + unit: bool + label: Pension Credit tax credit passport + reference: + - title: Tax Credits Act 2002 s. 7(2) + href: https://www.legislation.gov.uk/ukpga/2002/21/section/7 + - title: The Tax Credits (Income Thresholds and Determination of Rates) Regulations 2002 reg. 4(1)(d) + href: https://www.legislation.gov.uk/uksi/2002/2008/regulation/4 + - title: The Tax Credits (Miscellaneous Amendments No.2) Regulations 2003 reg. 18 + href: https://www.legislation.gov.uk/uksi/2003/2815/regulation/18 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits_pension_credit_passport.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits_pension_credit_passport.yaml new file mode 100644 index 0000000000..978706f41e --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits_pension_credit_passport.yaml @@ -0,0 +1,301 @@ +# State Pension Credit lifts the tax credit income test. +# +# TCA 2002 s.7(2): the income test "does not apply ... for so long as the +# person, or either of the persons, is entitled to any social security benefit +# prescribed". SI 2002/2008 reg 4(1)(d) prescribes "state pension credit within +# the meaning of the State Pension Credit Act 2002", which is the guarantee +# credit, the savings credit or both (SPCA 2002 s.1(3)). +# +# Working tax credit is Pension Credit income (SPCA 2002 s.15(1)(b)). The +# passport holds when Pension Credit is payable with the passported award +# counted; when Pension Credit is payable only on the income-tested award, +# lifting the test would end it, and the income test stays. +# +# 2024-25 rates: +# - WTC basic element £2,435, couple element £2,500 (WTC Regs 2002 Sch 2). +# - Income threshold £7,955, or £19,995 for a claim with no WTC entitlement; +# withdrawal rate 41% (SI 2002/2008 regs 3 and 7, as uprated). +# - CTC child element £3,455; family element £545 (CTC Regs 2002 reg 7). +# - Standard minimum guarantee £218.15 a week single, £332.95 couple; savings +# credit threshold £301.22 couple (SPC Regs 2002 regs 6 and 7, as uprated). +# WEEKS_IN_YEAR is 52. +# +# Tax credit income (SI 2002/2006 reg 3): State Pension is step-one pension +# income (reg 5(1)(a); ITEPA 2003 s.577) and savings interest step-one +# investment income (reg 10); step one counts only its excess over £300. +# Earnings are step two. Each pensioner has £300 of interest on £10,000 of +# savings, so step one's excess is the State Pension. (The model counts State +# Pension in step two, which gives the same total once the interest uses the +# £300.) £10,000 of capital gives no Pension Credit tariff income (SPC Regs +# 2002 reg 15(6)), and savings interest is not Pension Credit income. +# +# Pension Credit earnings are entered net of the SPC Regs 2002 Sch VI para 5 +# disregard (£5 a week single, £10 a couple), which the model does not apply. +# No case has income tax: each person's income is within the personal +# allowance. + +- name: Guarantee Credit lifts the income test, with the maximum WTC counted as its income + # Tax credit income: (6,000 + 300 - 300) + 2,600 = £8,600. + # Lifting the test pays the maximum WTC, £2,435. Pension Credit income is + # then £2,340 + £6,000 + £2,435 = £10,775 against a guarantee of + # 218.15 x 52 = £11,343.80, so Guarantee Credit is £568.80 and the + # passport holds. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + is_claimant_or_partner: true + employment_income: 2_600 + weekly_hours: 20 + state_pension: 6_000 + savings_interest_income: 300 + working_tax_credit_reported: 1 + benunits: + benunit: + members: [pensioner] + pension_credit_earnings: 2_340 # 2,600 - 5 x 52 + households: + household: + members: [pensioner] + savings: 10_000 + output: + tax_credits_applicable_income: 0 + working_tax_credit: 2_435 + guarantee_credit: 568.80 + pension_credit: 568.80 + +- name: Pension Credit payable only on the income-tested award leaves the income test in place + # Tax credit income: (6,600 + 300 - 300) + 2,600 = £9,200. With the test lifted, Pension + # Credit income is £2,340 + £6,600 + £2,435 = £11,375, above the £11,343.80 + # guarantee, so no Pension Credit would be payable and the test applies. + # WTC: 2,435 - 0.41 x (9,200 - 7,955) = £1,924.55. Pension Credit on that + # award: 11,343.80 - (2,340 + 6,600 + 1,924.55) = £479.25. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + is_claimant_or_partner: true + employment_income: 2_600 + weekly_hours: 20 + state_pension: 6_600 + savings_interest_income: 300 + working_tax_credit_reported: 1 + benunits: + benunit: + members: [pensioner] + pension_credit_earnings: 2_340 + households: + household: + members: [pensioner] + savings: 10_000 + output: + tax_credits_applicable_income: 9_200 + working_tax_credit: 1_924.55 + pension_credit: 479.25 + +- name: A pensioner with no Pension Credit keeps the income test + # Tax credit income: (9,500 + 300 - 300) + 2,600 = £12,100. Pension Credit income is at + # least £2,340 + £9,500 = £11,840, above the guarantee. + # WTC: 2,435 - 0.41 x (12,100 - 7,955) = £735.55. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + is_claimant_or_partner: true + employment_income: 2_600 + weekly_hours: 20 + state_pension: 9_500 + savings_interest_income: 300 + working_tax_credit_reported: 1 + benunits: + benunit: + members: [pensioner] + pension_credit_earnings: 2_340 + households: + household: + members: [pensioner] + savings: 10_000 + output: + tax_credits_applicable_income: 12_100 + working_tax_credit: 735.55 + pension_credit: 0 + +- name: Pension Credit that is not claimed does not lift the income test + # The first case's household without a Pension Credit claim: there is no + # entitlement without a claim (SSAA 1992 s.1(1)). + # WTC: 2,435 - 0.41 x (8,600 - 7,955) = £2,170.55. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + is_claimant_or_partner: true + employment_income: 2_600 + weekly_hours: 20 + state_pension: 6_000 + savings_interest_income: 300 + working_tax_credit_reported: 1 + benunits: + benunit: + members: [pensioner] + pension_credit_earnings: 2_340 + would_claim_pc: false + households: + household: + members: [pensioner] + savings: 10_000 + output: + tax_credits_applicable_income: 8_600 + working_tax_credit: 2_170.55 + pension_credit: 0 + +- name: Savings Credit alone lifts the income test for Child Tax Credit + # A couple aged 80 (State Pension age before 6 April 2016, so Savings Credit + # applies) responsible for a grandchild, claiming CTC only. + # Tax credit income: step one, State Pension £9,000 + £8,000 and savings + # interest £500, less £300, = £17,200; step two, earnings £3,000; total + # £20,200, above the £19,995 threshold. + # Pension Credit income: £17,000 + (3,000 - 10 x 52) = £19,480 (no income + # tax; capital of £10,000 gives no tariff income). Guarantee Credit: none + # (above 332.95 x 52 = £17,313.40). Savings Credit: maximum + # 0.6 x (332.95 - 301.22) x 52 = £989.98, less 0.4 x (19,480 - 17,313.40) + # = £866.64, so £123.34. + # CTC: £545 + £3,455 = £4,000, not 4,000 - 0.41 x 205 = £3,915.95. + # SPC Regs 2002 reg 6(6)(d) and (11) give no Schedule IIA child amount to + # a person awarded a tax credit, and the CTC family element has been £545 + # since 2011-12; both are entered because the model applies neither. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + grandparent_1: + age: 80 + is_claimant_or_partner: true + state_pension: 9_000 + employment_income: 3_000 + savings_interest_income: 500 + child_tax_credit_reported: 1 + grandparent_2: + age: 80 + is_claimant_or_partner: true + state_pension: 8_000 + grandchild: + age: 10 + is_claimant_or_partner: false + benunits: + benunit: + members: [grandparent_1, grandparent_2, grandchild] + would_claim_uc: false + pension_credit_earnings: 2_480 # 3,000 - 10 x 52 + child_minimum_guarantee_addition: 0 + CTC_family_element: 545 + households: + household: + members: [grandparent_1, grandparent_2, grandchild] + savings: 10_000 + output: + guarantee_credit: 0 + savings_credit: 123.34 + tax_credits_applicable_income: 0 + child_tax_credit: 4_000 + +- name: A mixed-age couple cannot get Pension Credit, so the income test applies + # SPCA 2002 s.4(1A) (from 15 May 2019): no Pension Credit where the other + # member of the couple is under the qualifying age. The SI 2019/37 art. 4 + # saving is not modelled here. A couple both over that age with this income + # would get Guarantee Credit with the passport. + # Tax credit income: (6,000 + 300 - 300) + 2,600 = £8,600. WTC maximum: £2,435 + £2,500 + # = £4,935, less 0.41 x (8,600 - 7,955) = £264.45, so £4,670.55. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + older: + age: 70 + is_claimant_or_partner: true + state_pension: 6_000 + savings_interest_income: 300 + younger: + age: 40 + is_claimant_or_partner: true + employment_income: 2_600 + weekly_hours: 20 + working_tax_credit_reported: 1 + benunits: + benunit: + members: [older, younger] + would_claim_uc: false + households: + household: + members: [older, younger] + savings: 10_000 + output: + pension_credit: 0 + tax_credits_applicable_income: 8_600 + working_tax_credit: 4_670.55 + +- name: With no tax credit awards in payment, Pension Credit still lifts the income test + # Tax credits ended on 5 April 2025, so no award is paid in 2025-26 and + # working tax credit is nil either way. Guarantee Credit: + # 221.85 x 52 - (2,340 + 6,000) = £3,196.20. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 71 + is_claimant_or_partner: true + employment_income: 2_600 + weekly_hours: 20 + state_pension: 6_000 + savings_interest_income: 300 + working_tax_credit_reported: 1 + benunits: + benunit: + members: [pensioner] + pension_credit_earnings: 2_340 + households: + household: + members: [pensioner] + savings: 10_000 + output: + working_tax_credit: 0 + pension_credit: 3_196.20 + tax_credits_applicable_income: 0 + +- name: Without the Pension Credit passport, the first case is income-tested + # The first case's household with gov.dwp.tax_credits.means_test + # .pension_credit_passport switched off: WTC £2,170.55, and Pension Credit + # 11,343.80 - (2,340 + 6,000 + 2,170.55) = £833.25. + period: 2024 + absolute_error_margin: 0.01 + input: + gov.dwp.tax_credits.means_test.pension_credit_passport: false + people: + pensioner: + age: 70 + is_claimant_or_partner: true + employment_income: 2_600 + weekly_hours: 20 + state_pension: 6_000 + savings_interest_income: 300 + working_tax_credit_reported: 1 + benunits: + benunit: + members: [pensioner] + pension_credit_earnings: 2_340 + households: + household: + members: [pensioner] + savings: 10_000 + output: + tax_credits_applicable_income: 8_600 + working_tax_credit: 2_170.55 + pension_credit: 833.25 diff --git a/policyengine_uk/tests/test_tax_credits_pension_credit_passport_properties.py b/policyengine_uk/tests/test_tax_credits_pension_credit_passport_properties.py new file mode 100644 index 0000000000..62a18b258d --- /dev/null +++ b/policyengine_uk/tests/test_tax_credits_pension_credit_passport_properties.py @@ -0,0 +1,227 @@ +"""State Pension Credit lifts the tax credit income test. + +TCA 2002 s.7(2) lifts the income test "for so long as the person, or either +of the persons, is entitled to" a benefit prescribed by SI 2002/2008 reg 4(1), +which includes "state pension credit" (reg 4(1)(d)): the guarantee credit, +the savings credit or both (SPCA 2002 s.1(3)). Working tax credit is Pension +Credit income (SPCA 2002 s.15(1)(b)), so the passport is decided on the +Pension Credit payable with the passported award counted. The model takes +that from a branch in which the income test is lifted. + +Properties, for any family: + +1. Differential: the passport holds exactly when an independent simulation, + with the tax credit income test lifted by input, pays Pension Credit. + Otherwise the applicable income is what it is with the Pension Credit + passport switched off. +2. Consistency: where Pension Credit gives the passport, the simulation's own + tax credits and Pension Credit equal the passported simulation's. Where it + does not, they equal the values with the passport switched off. +3. Lifting the income test never lowers a tax credit award. +4. Pension Credit in payment without the passport happens only where the + passported working tax credit would be larger than the income-tested one + and would remove Pension Credit; a family with no working tax credit is + passported whenever it gets Pension Credit. +5. The results do not depend on whether Pension Credit or the income test is + requested first, and the branch is removed afterwards. +6. In a year with no tax credit awards, the income test is lifted exactly + when Pension Credit is payable, with no dependency cycle. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.variables.gov.dwp.tax_credits_applicable_income import ( + PENSION_CREDIT_PASSPORT_BRANCH, +) + +ACTIVE_YEAR = 2024 # 2024-25, the last year of tax credit awards +INACTIVE_YEAR = 2025 +PASSPORT_OFF = {"gov.dwp.tax_credits.means_test.pension_credit_passport": False} +OUTPUTS = [ + "tax_credits_applicable_income", + "working_tax_credit", + "child_tax_credit", + "pension_credit", +] + + +@st.composite +def adults(draw, partner): + age = draw(st.integers(40 if partner else 55, 90)) + earnings = draw(st.sampled_from([0, 0, 2_600, 6_000, 12_000])) + return { + "age": age, + "employment_income": earnings, + "weekly_hours": draw(st.sampled_from([16, 20, 30])) if earnings else 0, + "state_pension": ( + draw(st.sampled_from([0, 4_000, 6_000, 9_000, 11_500])) if age >= 66 else 0 + ), + "private_pension_income": draw(st.sampled_from([0, 0, 1_500, 5_000])), + "savings_interest_income": draw(st.sampled_from([0, 0, 500])), + "working_tax_credit_reported": draw(st.sampled_from([0, 1])), + "child_tax_credit_reported": draw(st.sampled_from([0, 1])), + "esa_income_reported": draw(st.sampled_from([0, 0, 0, 0, 3_000])), + } + + +@st.composite +def families(draw): + members = [draw(adults(partner=False))] + if draw(st.booleans()): + members.append(draw(adults(partner=True))) + children = [ + {"age": draw(st.integers(0, 15))} for _ in range(draw(st.integers(0, 2))) + ] + benunit = {"would_claim_pc": draw(st.sampled_from([True, True, True, False]))} + household = {"savings": draw(st.sampled_from([0, 5_000, 12_000, 20_000]))} + return members, children, benunit, household + + +def situation(families, year, benunit_inputs=None): + people, benunits, households = {}, {}, {} + for i, (members, children, benunit, household) in enumerate(families): + names = [] + for j, inputs in enumerate(members): + name = f"a{i}_{j}" + people[name] = {k: {year: v} for k, v in inputs.items()} + people[name]["is_claimant_or_partner"] = {year: True} + names.append(name) + for j, inputs in enumerate(children): + name = f"c{i}_{j}" + people[name] = {k: {year: v} for k, v in inputs.items()} + people[name]["is_claimant_or_partner"] = {year: False} + names.append(name) + benunits[f"b{i}"] = { + "members": names, + "would_claim_uc": {year: False}, + **{k: {year: v} for k, v in benunit.items()}, + **{k: {year: v} for k, v in (benunit_inputs or {}).items()}, + } + households[f"h{i}"] = { + "members": names, + **{k: {year: v} for k, v in household.items()}, + } + return {"people": people, "benunits": benunits, "households": households} + + +def calculate(sim, year, order=OUTPUTS): + values = {variable: sim.calculate(variable, year) for variable in order} + assert PENSION_CREDIT_PASSPORT_BRANCH not in sim.branches + return values + + +SETTINGS = settings( + max_examples=20, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +FAMILIES = st.lists(families(), min_size=1, max_size=6) + + +@SETTINGS +@given(FAMILIES) +def test_passport_matches_an_independent_passported_simulation(drawn): + on = calculate(Simulation(situation=situation(drawn, ACTIVE_YEAR)), ACTIVE_YEAR) + off = calculate( + Simulation(situation=situation(drawn, ACTIVE_YEAR), reform=PASSPORT_OFF), + ACTIVE_YEAR, + ) + lifted = calculate( + Simulation( + situation=situation( + drawn, + ACTIVE_YEAR, + benunit_inputs={"tax_credits_applicable_income": 0}, + ) + ), + ACTIVE_YEAR, + ) + passported = lifted["pension_credit"] > 0 + # 1. Differential. + expected = np.where(passported, 0, off["tax_credits_applicable_income"]) + assert np.allclose(on["tax_credits_applicable_income"], expected), drawn + # 2. Consistency. + for variable in OUTPUTS[1:]: + expected = np.where(passported, lifted[variable], off[variable]) + assert np.allclose(on[variable], expected, atol=0.01), (variable, drawn) + # 3. Lifting the test never lowers an award. + for variable in ["working_tax_credit", "child_tax_credit"]: + assert (on[variable] >= off[variable] - 0.01).all(), (variable, drawn) + # 4. Pension Credit without the passport needs a passported WTC award + # larger than the income-tested one (which then removes Pension Credit), + # so a family with no WTC is passported whenever it gets Pension Credit. + unpassported_pc = (on["pension_credit"] > 0) & ~passported + assert ( + lifted["working_tax_credit"][unpassported_pc] + > on["working_tax_credit"][unpassported_pc] + ).all(), drawn + + +@SETTINGS +@given(FAMILIES) +def test_results_do_not_depend_on_request_order(drawn): + forward = calculate( + Simulation(situation=situation(drawn, ACTIVE_YEAR)), ACTIVE_YEAR + ) + backward = calculate( + Simulation(situation=situation(drawn, ACTIVE_YEAR)), + ACTIVE_YEAR, + order=list(reversed(OUTPUTS)), + ) + for variable in OUTPUTS: + assert np.allclose(forward[variable], backward[variable]), (variable, drawn) + + +@SETTINGS +@given(FAMILIES) +def test_without_awards_pension_credit_alone_decides_the_passport(drawn): + on = calculate(Simulation(situation=situation(drawn, INACTIVE_YEAR)), INACTIVE_YEAR) + off = calculate( + Simulation(situation=situation(drawn, INACTIVE_YEAR), reform=PASSPORT_OFF), + INACTIVE_YEAR, + ) + assert (on["working_tax_credit"] == 0).all() + assert (on["child_tax_credit"] == 0).all() + assert np.allclose(on["pension_credit"], off["pension_credit"]), drawn + expected = np.where( + on["pension_credit"] > 0, 0, off["tax_credits_applicable_income"] + ) + assert np.allclose(on["tax_credits_applicable_income"], expected), drawn + + +def test_passport_works_in_traced_and_nested_branch_simulations(): + """The branch runs under tracing and inside another branch. + + The household is the YAML's first case (Guarantee Credit with Working Tax + Credit). The marginal tax rate calculates household income in its own + branch, so the passport branch is nested there. + """ + year = ACTIVE_YEAR + family = ( + [ + { + "age": 70, + "employment_income": 2_600, + "weekly_hours": 20, + "state_pension": 6_000, + "working_tax_credit_reported": 1, + } + ], + [], + {"pension_credit_earnings": 2_340}, + {}, + ) + for trace in [False, True]: + sim = Simulation(situation=situation([family], year), trace=trace) + assert sim.calculate("tax_credits_applicable_income", year)[0] == 0 + assert np.isclose(sim.calculate("working_tax_credit", year)[0], 2_435) + assert np.isclose(sim.calculate("pension_credit", year)[0], 568.80) + mtr = sim.calculate("marginal_tax_rate", year)[0] + assert np.isfinite(mtr) + assert PENSION_CREDIT_PASSPORT_BRANCH not in sim.branches + for branch in sim.branches.values(): + assert PENSION_CREDIT_PASSPORT_BRANCH not in branch.branches diff --git a/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py b/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py index 087a676d59..fdaeea7129 100644 --- a/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py +++ b/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py @@ -1,5 +1,38 @@ from policyengine_uk.model_api import * +# The branch in which tax credits are paid without the income test. +PENSION_CREDIT_PASSPORT_BRANCH = "tax_credits_pension_credit_passport" + + +def pension_credit_with_passported_tax_credits(benunit, period, parameters): + """Pension Credit payable alongside tax credits paid without the income test. + + Working tax credit is Pension Credit income (SPCA 2002 s.15(1)(b)), so + Pension Credit depends on the tax credit award, and TCA 2002 s.7(2) makes + the award depend on Pension Credit. The passport is consistent when + Pension Credit stays payable once the passported award is counted. This + calculates Pension Credit in a branch in which the income test is lifted: + where it is payable there, the passport holds, and the simulation's own + award and Pension Credit then equal the branch's. + + Where Pension Credit is payable only on the income-tested award, no + consistent state exists: lifting the test would end Pension Credit, which + would restore the test. The income test then stays, with the Pension + Credit calculated on that award. + + In years with no tax credit awards, working tax credit is nil whether or + not the test applies, so Pension Credit is read directly. + """ + if not parameters(period).gov.dwp.tax_credits.active: + return benunit("pension_credit", period) + simulation = benunit.simulation + branch = simulation.get_branch(PENSION_CREDIT_PASSPORT_BRANCH) + try: + branch.set_input("tax_credits_applicable_income", period, benunit.empty_array()) + return branch.calculate("pension_credit", period) + finally: + simulation.branches.pop(PENSION_CREDIT_PASSPORT_BRANCH, None) + class tax_credits_applicable_income(Variable): value_type = float @@ -8,7 +41,9 @@ class tax_credits_applicable_income(Variable): definition_period = YEAR unit = GBP reference = ( - "The Tax Credits (Definition and Calculation of Income) Regulations 2002 s. 3" + "https://www.legislation.gov.uk/uksi/2002/2006/regulation/3", + "https://www.legislation.gov.uk/ukpga/2002/21/section/7", + "https://www.legislation.gov.uk/uksi/2002/2008/regulation/4", ) def formula(benunit, period, parameters): @@ -39,10 +74,16 @@ def formula(benunit, period, parameters): if bi.interactions.include_in_means_tests: STEP_2_COMPONENTS.append("basic_income") income += add_for_members(benunit, period, STEP_2_COMPONENTS, members) - # TCA 2002 s.7(2) and SI 2002/2008 reg 4: no income test while "the + # TCA 2002 s.7(2) and SI 2002/2008 reg 4(1): no income test while "the # person, or either of the persons" claiming is entitled to Income # Support, income-based JSA or income-related ESA. That is the claimant's - # or partner's award, not another member's. + # or partner's award, not another member's. The reg 4(1)(a) exception, + # Income Support due only under IS Regs 1987 reg 6(2) and (3) (the + # lone parent run-on), has no effect: those paragraphs were omitted + # from 25 October 2004 (SI 2003/1589 reg 2(a)(i)). Reg 4(2), which + # keeps the test for working tax credit in the four-week run-on of + # WTC Regs 2002 reg 7D, is not modelled: a whole-year award has no + # run-on period. EXEMPT_BENEFITS = [ "income_support", "claimant_or_partner_esa_income", @@ -51,4 +92,12 @@ def formula(benunit, period, parameters): on_exempt_benefits = ( add_for_members(benunit, period, EXEMPT_BENEFITS, members) > 0 ) + # Reg 4(1)(d): State Pension Credit, which is the guarantee credit, + # the savings credit or both (SPCA 2002 s.1(3)). A Pension Credit + # award covers the claimant and any partner. + if TC.means_test.pension_credit_passport: + on_exempt_benefits = on_exempt_benefits | ( + pension_credit_with_passported_tax_credits(benunit, period, parameters) + > 0 + ) return income * ~on_exempt_benefits diff --git a/policyengine_uk/variables/gov/dwp/wtc_entitlement.py b/policyengine_uk/variables/gov/dwp/wtc_entitlement.py index ff4eeabdc6..3a0a606c99 100644 --- a/policyengine_uk/variables/gov/dwp/wtc_entitlement.py +++ b/policyengine_uk/variables/gov/dwp/wtc_entitlement.py @@ -10,6 +10,12 @@ class wtc_entitlement(Variable): defined_for = "is_WTC_eligible" def formula(benunit, period, parameters): + # With no awards in payment, return before reading the income test: + # Pension Credit counts working tax credit (SPCA 2002 s.15(1)(b)), and + # the tax credit income test reads Pension Credit directly in those + # years (tax_credits_applicable_income). + if not parameters(period).gov.dwp.tax_credits.active: + return benunit.empty_array() return where( benunit("tax_credits", period) > 0, benunit("working_tax_credit_pre_minimum", period), From 284434ffcfe938604cbb72bb48651a15e25f4f43 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 02:26:14 -0400 Subject: [PATCH 2/9] Pin the YAML households as Hypothesis examples Random draws passport through Pension Credit often (55 of 236 families in a derandomized sample) but never raise CTC and hit the third row once. The YAML households now run as explicit examples of each property, and a test checks that they reach every case: WTC and CTC raised by the passport, the third row, and a mixed-age couple. Co-Authored-By: Claude Opus 5.5 --- ...dits_pension_credit_passport_properties.py | 89 ++++++++++++++++++- 1 file changed, 88 insertions(+), 1 deletion(-) diff --git a/policyengine_uk/tests/test_tax_credits_pension_credit_passport_properties.py b/policyengine_uk/tests/test_tax_credits_pension_credit_passport_properties.py index 62a18b258d..4ce4c8c664 100644 --- a/policyengine_uk/tests/test_tax_credits_pension_credit_passport_properties.py +++ b/policyengine_uk/tests/test_tax_credits_pension_credit_passport_properties.py @@ -29,7 +29,7 @@ """ 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 @@ -121,9 +121,73 @@ def calculate(sim, year, order=OUTPUTS): ) FAMILIES = st.lists(families(), min_size=1, max_size=6) +# The YAML cases, run on every invocation: random draws seldom raise CTC or +# hit the third row. Pension Credit earnings are net of SPC Regs Sch VI +# para 5; the CTC family element and the Sch IIA child amount are entered at +# their statutory values (see the YAML file). +_PENSIONER = { + "age": 70, + "employment_income": 2_600, + "weekly_hours": 20, + "savings_interest_income": 300, + "working_tax_credit_reported": 1, +} +YAML_FAMILIES = [ + # Guarantee Credit with WTC: passported. + ( + [{**_PENSIONER, "state_pension": 6_000}], + [], + {"pension_credit_earnings": 2_340}, + {"savings": 10_000}, + ), + # Pension Credit only on the income-tested award: the third row. + ( + [{**_PENSIONER, "state_pension": 6_600}], + [], + {"pension_credit_earnings": 2_340}, + {"savings": 10_000}, + ), + # Savings Credit alone, with CTC raised by the passport. + ( + [ + { + "age": 80, + "state_pension": 9_000, + "employment_income": 3_000, + "savings_interest_income": 500, + "child_tax_credit_reported": 1, + }, + {"age": 80, "state_pension": 8_000}, + ], + [{"age": 10}], + { + "pension_credit_earnings": 2_480, + "child_minimum_guarantee_addition": 0, + "CTC_family_element": 545, + }, + {"savings": 10_000}, + ), + # A mixed-age couple: no Pension Credit, no passport. + ( + [ + {"age": 70, "state_pension": 6_000, "savings_interest_income": 300}, + { + "age": 40, + "employment_income": 2_600, + "weekly_hours": 20, + "working_tax_credit_reported": 1, + }, + ], + [], + {}, + {"savings": 10_000}, + ), +] + @SETTINGS @given(FAMILIES) +@example(YAML_FAMILIES) def test_passport_matches_an_independent_passported_simulation(drawn): on = calculate(Simulation(situation=situation(drawn, ACTIVE_YEAR)), ACTIVE_YEAR) off = calculate( @@ -163,6 +227,7 @@ def test_passport_matches_an_independent_passported_simulation(drawn): @SETTINGS @given(FAMILIES) +@example(YAML_FAMILIES) def test_results_do_not_depend_on_request_order(drawn): forward = calculate( Simulation(situation=situation(drawn, ACTIVE_YEAR)), ACTIVE_YEAR @@ -178,6 +243,7 @@ def test_results_do_not_depend_on_request_order(drawn): @SETTINGS @given(FAMILIES) +@example(YAML_FAMILIES) def test_without_awards_pension_credit_alone_decides_the_passport(drawn): on = calculate(Simulation(situation=situation(drawn, INACTIVE_YEAR)), INACTIVE_YEAR) off = calculate( @@ -225,3 +291,24 @@ def test_passport_works_in_traced_and_nested_branch_simulations(): assert PENSION_CREDIT_PASSPORT_BRANCH not in sim.branches for branch in sim.branches.values(): assert PENSION_CREDIT_PASSPORT_BRANCH not in branch.branches + + +def test_yaml_families_reach_every_case(): + """The pinned examples are not vacuous: they hit each row of the rule.""" + year = ACTIVE_YEAR + on = calculate(Simulation(situation=situation(YAML_FAMILIES, year)), year) + off = calculate( + Simulation(situation=situation(YAML_FAMILIES, year), reform=PASSPORT_OFF), + year, + ) + passported = (on["tax_credits_applicable_income"] == 0) & ( + off["tax_credits_applicable_income"] > 0 + ) + assert passported.tolist() == [True, False, True, False] + # The passport raises WTC (first) and CTC (third). + assert on["working_tax_credit"][0] > off["working_tax_credit"][0] + assert on["child_tax_credit"][2] > off["child_tax_credit"][2] + # The third row: Pension Credit paid without the passport. + assert on["pension_credit"][1] > 0 and not passported[1] + # The mixed-age couple gets no Pension Credit. + assert on["pension_credit"][3] == 0 From f2eb82445b9013cb540c34529260d3551357a229 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 07:10:39 -0400 Subject: [PATCH 3/9] Test childcare on gross tax credit income; review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of #2034 (Subfleet job 20261002-020316-tc-pc-passport-review-r1c): - The two-year-old childcare criteria read tax_credits_applicable_income, which the passport zeroes, so a savings-credit-only family with CTC passed the £16,190 "annual gross income" limit (SI 2014/2147 reg 1(2)) whatever its income. Split out tax_credits_current_year_income (TCA 2002 s.7(4); SI 2002/2006 reg 3); the applicable income is that or nil, and both childcare criteria read the gross figure. - YAML case 6 relied on is_WTC_eligible pooling age and hours across the couple (#2042): the older partner now does the work, with the same figures. - Read the branch's Pension Credit through its population, so a microsimulation returns an unweighted array, and take a branch name in use by no other branch. - Docstring: the rule's premise (Pension Credit never rises with WTC in law), the model-only case its savings credit creates (#1954), and the nil-guarantee boundary. Comment covers reg 4(1)(b). - Tests: a childcare case, a 2018-19 case, a Microsimulation test, a household-net-income-first request order, a childcare property, and income-related ESA drawn only below State Pension age. Co-Authored-By: Claude Opus 5.5 --- ...x-credits-pension-credit-passport.fixed.md | 1 + .../tax_credits_pension_credit_passport.yaml | 95 ++++++++++++++++++- ...dits_pension_credit_passport_properties.py | 89 +++++++++++++---- ...eria_for_targeted_childcare_entitlement.py | 7 +- ...eria_for_targeted_childcare_entitlement.py | 9 +- .../gov/dwp/tax_credits_applicable_income.py | 84 ++++++++-------- .../dwp/tax_credits_current_year_income.py | 51 ++++++++++ 7 files changed, 266 insertions(+), 70 deletions(-) create mode 100644 policyengine_uk/variables/gov/dwp/tax_credits_current_year_income.py diff --git a/changelog.d/tax-credits-pension-credit-passport.fixed.md b/changelog.d/tax-credits-pension-credit-passport.fixed.md index db72c2a198..a3518c69eb 100644 --- a/changelog.d/tax-credits-pension-credit-passport.fixed.md +++ b/changelog.d/tax-credits-pension-credit-passport.fixed.md @@ -1 +1,2 @@ - Lift the tax credit income test for a claimant or couple entitled to State Pension Credit, guarantee credit or savings credit (TCA 2002 s.7(2); SI 2002/2008 reg 4(1)(d)). Working tax credit is Pension Credit income (SPCA 2002 s.15(1)(b)), so the passport applies where Pension Credit is payable with the passported award counted as income. The new parameter `gov.dwp.tax_credits.means_test.pension_credit_passport` dates the rule from 26 November 2003. +- Add `tax_credits_current_year_income`, the tax credit income before the passport (TCA 2002 s.7(4); SI 2002/2006 reg 3). `tax_credits_applicable_income` is that income, or nil while the test is lifted. The targeted childcare tax credit criteria now test the gross income against the £16,190 limit (SI 2014/2147 reg 1(2)), not the nil income of a passported award. diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits_pension_credit_passport.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits_pension_credit_passport.yaml index 978706f41e..a5e7a5fb78 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits_pension_credit_passport.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits_pension_credit_passport.yaml @@ -66,6 +66,41 @@ guarantee_credit: 568.80 pension_credit: 568.80 +- name: The passport in 2018-19, before the mixed-age rule + # 2018-19 rates: WTC basic element £1,960; income threshold £6,420; + # standard minimum guarantee £163.00 a week (£8,476 a year). A man aged 66 + # reached pensionable age (65) in 2017, so has no savings credit: SPCA 2002 + # s.3(1)(a) needs pensionable age "before 6 April 2016". + # Tax credit income: (4,000 + 300 - 300) + 2,600 = £6,600. With the test + # lifted, WTC is £1,960, and Pension Credit income is 2,340 + 4,000 + 1,960 + # = £8,300, so Guarantee Credit is 8,476 - 8,300 = £176: the passport holds. + period: 2018 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 66 + gender: MALE + is_claimant_or_partner: true + employment_income: 2_600 + weekly_hours: 20 + state_pension: 4_000 + savings_interest_income: 300 + working_tax_credit_reported: 1 + benunits: + benunit: + members: [pensioner] + pension_credit_earnings: 2_340 + households: + household: + members: [pensioner] + savings: 10_000 + output: + tax_credits_applicable_income: 0 + tax_credits_current_year_income: 6_600 + working_tax_credit: 1_960 + pension_credit: 176 + - name: Pension Credit payable only on the income-tested award leaves the income test in place # Tax credit income: (6,600 + 300 - 300) + 2,600 = £9,200. With the test lifted, Pension # Credit income is £2,340 + £6,600 + £2,435 = £11,375, above the £11,343.80 @@ -206,11 +241,65 @@ tax_credits_applicable_income: 0 child_tax_credit: 4_000 +- name: The passport does not bring a family under the childcare gross income limit + # England's two-year-old offer: a child whose parent is entitled to "child + # tax credit where that parent is not also entitled to working tax credit + # ... and has an annual gross income not exceeding £16,190", or to "the + # guaranteed element of state pension credit" (SI 2014/2147 reg 1(2), + # "eligible child", as at 1 September 2024). The previous case's couple, + # with a grandchild aged 2: their CTC is passported through the savings + # credit, but their gross income is £20,200 and they have no Guarantee + # Credit, so the child is not eligible through either route. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + grandparent_1: + age: 80 + is_claimant_or_partner: true + state_pension: 9_000 + employment_income: 3_000 + savings_interest_income: 500 + child_tax_credit_reported: 1 + grandparent_2: + age: 80 + is_claimant_or_partner: true + state_pension: 8_000 + grandchild: + age: 2 + is_claimant_or_partner: false + benunits: + benunit: + members: [grandparent_1, grandparent_2, grandchild] + would_claim_uc: false + pension_credit_earnings: 2_480 + child_minimum_guarantee_addition: 0 + CTC_family_element: 545 + working_tax_credit_run_on: false + extended_childcare_entitlement_eligible: false + households: + household: + members: [grandparent_1, grandparent_2, grandchild] + savings: 10_000 + country: ENGLAND + output: + guarantee_credit: 0 + savings_credit: 123.34 + tax_credits_applicable_income: 0 + tax_credits_current_year_income: 20_200 + child_tax_credit: 4_000 + meets_child_tax_credit_criteria_for_targeted_childcare_entitlement: false + targeted_childcare_entitlement_eligible: false + - name: A mixed-age couple cannot get Pension Credit, so the income test applies # SPCA 2002 s.4(1A) (from 15 May 2019): no Pension Credit where the other # member of the couple is under the qualifying age. The SI 2019/37 art. 4 # saving is not modelled here. A couple both over that age with this income # would get Guarantee Credit with the passport. + # The older partner works 20 hours, which meets the WTC hours condition for + # a joint claim with no child: "aged at least 60 and undertakes work for not + # less than 16 hours per week" (WTC Regs 2002 reg 4(1), Second condition, + # second variation). # Tax credit income: (6,000 + 300 - 300) + 2,600 = £8,600. WTC maximum: £2,435 + £2,500 # = £4,935, less 0.41 x (8,600 - 7,955) = £264.45, so £4,670.55. period: 2024 @@ -222,12 +311,12 @@ is_claimant_or_partner: true state_pension: 6_000 savings_interest_income: 300 - younger: - age: 40 - is_claimant_or_partner: true employment_income: 2_600 weekly_hours: 20 working_tax_credit_reported: 1 + younger: + age: 40 + is_claimant_or_partner: true benunits: benunit: members: [older, younger] diff --git a/policyengine_uk/tests/test_tax_credits_pension_credit_passport_properties.py b/policyengine_uk/tests/test_tax_credits_pension_credit_passport_properties.py index 4ce4c8c664..fa6d49c562 100644 --- a/policyengine_uk/tests/test_tax_credits_pension_credit_passport_properties.py +++ b/policyengine_uk/tests/test_tax_credits_pension_credit_passport_properties.py @@ -13,7 +13,9 @@ 1. Differential: the passport holds exactly when an independent simulation, with the tax credit income test lifted by input, pays Pension Credit. Otherwise the applicable income is what it is with the Pension Credit - passport switched off. + passport switched off. (This checks the branch against the same rule + computed outside the formula, so it catches stale or leaked caches; the + rule itself is checked against the law by the YAML cases.) 2. Consistency: where Pension Credit gives the passport, the simulation's own tax credits and Pension Credit equal the passported simulation's. Where it does not, they equal the values with the passport switched off. @@ -22,19 +24,23 @@ passported working tax credit would be larger than the income-tested one and would remove Pension Credit; a family with no working tax credit is passported whenever it gets Pension Credit. -5. The results do not depend on whether Pension Credit or the income test is - requested first, and the branch is removed afterwards. +5. The results do not depend on whether Pension Credit, the income test or + household net income is requested first, and the branch is removed + afterwards. 6. In a year with no tax credit awards, the income test is lifted exactly when Pension Credit is payable, with no dependency cycle. +7. The targeted childcare tax credit criteria are met only where the gross + (pre-passport) income is within the £16,190 limit. """ import numpy as np from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st -from policyengine_uk import Simulation +from policyengine_uk import Microsimulation, Simulation from policyengine_uk.variables.gov.dwp.tax_credits_applicable_income import ( PENSION_CREDIT_PASSPORT_BRANCH, + pension_credit_with_passported_tax_credits, ) ACTIVE_YEAR = 2024 # 2024-25, the last year of tax credit awards @@ -63,7 +69,10 @@ def adults(draw, partner): "savings_interest_income": draw(st.sampled_from([0, 0, 500])), "working_tax_credit_reported": draw(st.sampled_from([0, 1])), "child_tax_credit_reported": draw(st.sampled_from([0, 1])), - "esa_income_reported": draw(st.sampled_from([0, 0, 0, 0, 3_000])), + # Income-related ESA ends at State Pension age. + "esa_income_reported": ( + draw(st.sampled_from([0, 0, 0, 0, 3_000])) if age < 66 else 0 + ), } @@ -107,9 +116,19 @@ def situation(families, year, benunit_inputs=None): return {"people": people, "benunits": benunits, "households": households} +def no_passport_branch_left(sim): + """No passport branch remains, at any depth of branching.""" + for name, branch in sim.branches.items(): + if name.startswith(PENSION_CREDIT_PASSPORT_BRANCH): + return False + if not no_passport_branch_left(branch): + return False + return True + + def calculate(sim, year, order=OUTPUTS): values = {variable: sim.calculate(variable, year) for variable in order} - assert PENSION_CREDIT_PASSPORT_BRANCH not in sim.branches + assert no_passport_branch_left(sim) return values @@ -170,13 +189,8 @@ def calculate(sim, year, order=OUTPUTS): # A mixed-age couple: no Pension Credit, no passport. ( [ - {"age": 70, "state_pension": 6_000, "savings_interest_income": 300}, - { - "age": 40, - "employment_income": 2_600, - "weekly_hours": 20, - "working_tax_credit_reported": 1, - }, + {**_PENSIONER, "state_pension": 6_000}, + {"age": 40}, ], [], {}, @@ -189,7 +203,8 @@ def calculate(sim, year, order=OUTPUTS): @given(FAMILIES) @example(YAML_FAMILIES) def test_passport_matches_an_independent_passported_simulation(drawn): - on = calculate(Simulation(situation=situation(drawn, ACTIVE_YEAR)), ACTIVE_YEAR) + sim = Simulation(situation=situation(drawn, ACTIVE_YEAR)) + on = calculate(sim, ACTIVE_YEAR) off = calculate( Simulation(situation=situation(drawn, ACTIVE_YEAR), reform=PASSPORT_OFF), ACTIVE_YEAR, @@ -223,6 +238,18 @@ def test_passport_matches_an_independent_passported_simulation(drawn): lifted["working_tax_credit"][unpassported_pc] > on["working_tax_credit"][unpassported_pc] ).all(), drawn + # 7. The childcare tax credit criteria test gross income, not the nil + # income of a passported award. + gross = sim.calculate("tax_credits_current_year_income", ACTIVE_YEAR) + limit = sim.tax_benefit_system.parameters( + ACTIVE_YEAR + ).gov.dfe.targeted_childcare_entitlement.income_limit.tax_credits + for criterion in [ + "meets_child_tax_credit_criteria_for_targeted_childcare_entitlement", + "meets_working_tax_credit_criteria_for_targeted_childcare_entitlement", + ]: + met = sim.calculate(criterion, ACTIVE_YEAR) + assert (gross[met] <= limit).all(), (criterion, drawn) @SETTINGS @@ -237,8 +264,17 @@ def test_results_do_not_depend_on_request_order(drawn): ACTIVE_YEAR, order=list(reversed(OUTPUTS)), ) + household_first = calculate( + Simulation(situation=situation(drawn, ACTIVE_YEAR)), + ACTIVE_YEAR, + order=["household_net_income"] + OUTPUTS, + ) for variable in OUTPUTS: assert np.allclose(forward[variable], backward[variable]), (variable, drawn) + assert np.allclose(forward[variable], household_first[variable]), ( + variable, + drawn, + ) @SETTINGS @@ -288,9 +324,28 @@ def test_passport_works_in_traced_and_nested_branch_simulations(): assert np.isclose(sim.calculate("pension_credit", year)[0], 568.80) mtr = sim.calculate("marginal_tax_rate", year)[0] assert np.isfinite(mtr) - assert PENSION_CREDIT_PASSPORT_BRANCH not in sim.branches - for branch in sim.branches.values(): - assert PENSION_CREDIT_PASSPORT_BRANCH not in branch.branches + assert no_passport_branch_left(sim) + + +def test_passport_in_a_microsimulation_is_unweighted(): + """In a microsimulation the branch's Pension Credit is a plain array. + + A top-level calculate on a microsimulation returns a weighted series; + reading the branch through its population does not. + """ + year = ACTIVE_YEAR + micro = Microsimulation(situation=situation(YAML_FAMILIES, year)) + plain = Simulation(situation=situation(YAML_FAMILIES, year)) + for variable in OUTPUTS: + assert np.allclose( + np.asarray(micro.calculate(variable, year)), + plain.calculate(variable, year), + ), variable + result = pension_credit_with_passported_tax_credits( + micro.populations["benunit"], year, micro.tax_benefit_system.parameters + ) + assert type(result) is np.ndarray + assert no_passport_branch_left(micro) def test_yaml_families_reach_every_case(): diff --git a/policyengine_uk/variables/gov/dfe/targeted_childcare_entitlement/meets_child_tax_credit_criteria_for_targeted_childcare_entitlement.py b/policyengine_uk/variables/gov/dfe/targeted_childcare_entitlement/meets_child_tax_credit_criteria_for_targeted_childcare_entitlement.py index db25570192..6f621778ea 100644 --- a/policyengine_uk/variables/gov/dfe/targeted_childcare_entitlement/meets_child_tax_credit_criteria_for_targeted_childcare_entitlement.py +++ b/policyengine_uk/variables/gov/dfe/targeted_childcare_entitlement/meets_child_tax_credit_criteria_for_targeted_childcare_entitlement.py @@ -15,10 +15,13 @@ def formula(benunit, period, parameters): p = parameters(period).gov.dfe.targeted_childcare_entitlement child_tax_credit = benunit("child_tax_credit", period) > 0 working_tax_credit = benunit("working_tax_credit", period) > 0 - applicable_income = benunit("tax_credits_applicable_income", period) + # "an annual gross income not exceeding £16,190" (SI 2014/2147 reg + # 1(2), "eligible child"): the income before TCA 2002 s.7(2) lifts the + # tax credit income test, not the nil income the test then applies to. + gross_income = benunit("tax_credits_current_year_income", period) return ( child_tax_credit & ~working_tax_credit - & (applicable_income <= p.income_limit.tax_credits) + & (gross_income <= p.income_limit.tax_credits) ) diff --git a/policyengine_uk/variables/gov/dfe/targeted_childcare_entitlement/meets_working_tax_credit_criteria_for_targeted_childcare_entitlement.py b/policyengine_uk/variables/gov/dfe/targeted_childcare_entitlement/meets_working_tax_credit_criteria_for_targeted_childcare_entitlement.py index 52c475e7d9..dd201eaad5 100644 --- a/policyengine_uk/variables/gov/dfe/targeted_childcare_entitlement/meets_working_tax_credit_criteria_for_targeted_childcare_entitlement.py +++ b/policyengine_uk/variables/gov/dfe/targeted_childcare_entitlement/meets_working_tax_credit_criteria_for_targeted_childcare_entitlement.py @@ -14,6 +14,11 @@ class meets_working_tax_credit_criteria_for_targeted_childcare_entitlement(Varia def formula(benunit, period, parameters): p = parameters(period).gov.dfe.targeted_childcare_entitlement working_tax_credit = benunit("working_tax_credit", period) > 0 - applicable_income = benunit("tax_credits_applicable_income", period) + # "an award which is based on an annual income not exceeding £16,190" + # (SI 2014/2147 reg 1(2), "eligible child"). The model reads this as + # the claim's income before TCA 2002 s.7(2) lifts the income test; + # the nil income a passported award is tested on would let any + # passported award through, whatever the family's income. + income = benunit("tax_credits_current_year_income", period) - return working_tax_credit & (applicable_income <= p.income_limit.tax_credits) + return working_tax_credit & (income <= p.income_limit.tax_credits) diff --git a/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py b/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py index fdaeea7129..4074d4e8f4 100644 --- a/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py +++ b/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py @@ -15,10 +15,18 @@ def pension_credit_with_passported_tax_credits(benunit, period, parameters): where it is payable there, the passport holds, and the simulation's own award and Pension Credit then equal the branch's. - Where Pension Credit is payable only on the income-tested award, no - consistent state exists: lifting the test would end Pension Credit, which - would restore the test. The income test then stays, with the Pension - Credit calculated on that award. + In law Pension Credit never rises with working tax credit: the guarantee + credit falls by the whole of it (s.2(2)), and it is not qualifying income + for the savings credit (SPC Regs 2002 reg 9(a)). So where Pension Credit + is payable only on the income-tested award, no consistent state exists: + lifting the test would end Pension Credit, which would restore the test. + The income test then stays, with the Pension Credit calculated on that + award. (The model's savings credit counts working tax credit as + qualifying income, so it can rise with the award; where both states are + then consistent, the passport is taken.) + + "Entitled" is read as payable: at income exactly equal to the minimum + guarantee, a nil guarantee credit gives no passport. In years with no tax credit awards, working tax credit is nil whether or not the test applies, so Pension Credit is read directly. @@ -26,76 +34,60 @@ def pension_credit_with_passported_tax_credits(benunit, period, parameters): if not parameters(period).gov.dwp.tax_credits.active: return benunit("pension_credit", period) simulation = benunit.simulation - branch = simulation.get_branch(PENSION_CREDIT_PASSPORT_BRANCH) + # get_branch returns an existing branch of that name, or this simulation + # if it has that name, so take a name in use by neither. + name = PENSION_CREDIT_PASSPORT_BRANCH + while name in simulation.branches or name == simulation.branch_name: + name += "_" + branch = simulation.get_branch(name) try: branch.set_input("tax_credits_applicable_income", period, benunit.empty_array()) - return branch.calculate("pension_credit", period) + # Through the population, so a microsimulation returns an + # unweighted array. + return branch.populations[benunit.entity.key]("pension_credit", period) finally: - simulation.branches.pop(PENSION_CREDIT_PASSPORT_BRANCH, None) + simulation.branches.pop(name, None) class tax_credits_applicable_income(Variable): value_type = float entity = BenUnit label = "Applicable income for Tax Credits" + documentation = ( + "The income the tax credit income test applies to: the current year " + "income, or nil while the claimant or either joint claimant is " + "entitled to a benefit prescribed for TCA 2002 s.7(2)." + ) definition_period = YEAR unit = GBP reference = ( - "https://www.legislation.gov.uk/uksi/2002/2006/regulation/3", "https://www.legislation.gov.uk/ukpga/2002/21/section/7", "https://www.legislation.gov.uk/uksi/2002/2008/regulation/4", ) def formula(benunit, period, parameters): - # Members whose income counts: the claimant and partner and, as the model did - # before, the programme's own children or young persons. The regulations count - # only the claimant's and partner's (TCA 2002 s.7); dropping dependants' own - # income is a follow-up. Anyone else in the benefit unit does not count. - person = benunit.members - members = person("is_claimant_or_partner", period) | person( - "is_child_or_qualifying_young_person_for_child_tax_credit", period - ) - TC = parameters(period).gov.dwp.tax_credits - STEP_1_COMPONENTS = [ - "private_pension_income", - "savings_interest_income", - "dividend_income", - "property_income", - ] - income = add_for_members(benunit, period, STEP_1_COMPONENTS, members) - income = max_(income - TC.means_test.non_earned_disregard, 0) - STEP_2_COMPONENTS = [ - "employment_income", - "self_employment_income", - "social_security_income", - "miscellaneous_income", - ] - bi = parameters(period).gov.contrib.ubi_center.basic_income - if bi.interactions.include_in_means_tests: - STEP_2_COMPONENTS.append("basic_income") - income += add_for_members(benunit, period, STEP_2_COMPONENTS, members) + income = benunit("tax_credits_current_year_income", period) # TCA 2002 s.7(2) and SI 2002/2008 reg 4(1): no income test while "the # person, or either of the persons" claiming is entitled to Income # Support, income-based JSA or income-related ESA. That is the claimant's - # or partner's award, not another member's. The reg 4(1)(a) exception, - # Income Support due only under IS Regs 1987 reg 6(2) and (3) (the - # lone parent run-on), has no effect: those paragraphs were omitted - # from 25 October 2004 (SI 2003/1589 reg 2(a)(i)). Reg 4(2), which - # keeps the test for working tax credit in the four-week run-on of - # WTC Regs 2002 reg 7D, is not modelled: a whole-year award has no - # run-on period. + # or partner's award, not another member's. The reg 4(1)(a) and (b) + # exceptions, Income Support due only under IS Regs 1987 reg 6(2) and + # (3) (the lone parent run-on, omitted in Great Britain from 25 October + # 2004 by SI 2003/1589 reg 2(a)(i)) and the Northern Ireland + # equivalent, have no effect: the model has no Income Support run-on. + # Reg 4(2), which keeps the test for working tax credit in the + # four-week run-on of WTC Regs 2002 reg 7D, is not modelled: a + # whole-year award has no run-on period. EXEMPT_BENEFITS = [ "income_support", "claimant_or_partner_esa_income", "claimant_or_partner_jsa_income", ] - on_exempt_benefits = ( - add_for_members(benunit, period, EXEMPT_BENEFITS, members) > 0 - ) + on_exempt_benefits = add(benunit, period, EXEMPT_BENEFITS) > 0 # Reg 4(1)(d): State Pension Credit, which is the guarantee credit, # the savings credit or both (SPCA 2002 s.1(3)). A Pension Credit # award covers the claimant and any partner. - if TC.means_test.pension_credit_passport: + if parameters(period).gov.dwp.tax_credits.means_test.pension_credit_passport: on_exempt_benefits = on_exempt_benefits | ( pension_credit_with_passported_tax_credits(benunit, period, parameters) > 0 diff --git a/policyengine_uk/variables/gov/dwp/tax_credits_current_year_income.py b/policyengine_uk/variables/gov/dwp/tax_credits_current_year_income.py new file mode 100644 index 0000000000..cbddd40cfc --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/tax_credits_current_year_income.py @@ -0,0 +1,51 @@ +from policyengine_uk.model_api import * + + +class tax_credits_current_year_income(Variable): + value_type = float + entity = BenUnit + label = "Current year income for Tax Credits" + documentation = ( + "The claimant's, or joint claimants', income for the tax year " + "(TCA 2002 s.7(4); SI 2002/2006 reg 3), before TCA 2002 s.7(2) lifts " + "the income test for a claimant on a prescribed benefit. The model " + "takes it as the relevant income (s.7(3)). The income test reads " + "tax_credits_applicable_income, which is nil while the test is " + "lifted; the targeted childcare criteria read this gross figure." + ) + definition_period = YEAR + unit = GBP + reference = ( + "https://www.legislation.gov.uk/ukpga/2002/21/section/7", + "https://www.legislation.gov.uk/uksi/2002/2006/regulation/3", + ) + + def formula(benunit, period, parameters): + # Members whose income counts: the claimant and partner and, as the model did + # before, the programme's own children or young persons. The regulations count + # only the claimant's and partner's (TCA 2002 s.7); dropping dependants' own + # income is a follow-up. Anyone else in the benefit unit does not count. + person = benunit.members + members = person("is_claimant_or_partner", period) | person( + "is_child_or_qualifying_young_person_for_child_tax_credit", period + ) + TC = parameters(period).gov.dwp.tax_credits + STEP_1_COMPONENTS = [ + "private_pension_income", + "savings_interest_income", + "dividend_income", + "property_income", + ] + income = add_for_members(benunit, period, STEP_1_COMPONENTS, members) + income = max_(income - TC.means_test.non_earned_disregard, 0) + STEP_2_COMPONENTS = [ + "employment_income", + "self_employment_income", + "social_security_income", + "miscellaneous_income", + ] + bi = parameters(period).gov.contrib.ubi_center.basic_income + if bi.interactions.include_in_means_tests: + STEP_2_COMPONENTS.append("basic_income") + income += add_for_members(benunit, period, STEP_2_COMPONENTS, members) + return income From 29891b0b5cd04d0d27f4c1b099ab700cfce18323 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 07:28:18 -0400 Subject: [PATCH 4/9] Enter gross tax credit income in the childcare criteria tests The targeted childcare tax credit criteria now read tax_credits_current_year_income (the "annual gross income" of SI 2014/2147 reg 1(2)), so their tests enter that instead of the passported tax_credits_applicable_income. Expectations are unchanged. Co-Authored-By: Claude Opus 5.5 --- ..._credit_criteria_for_targeted_childcare_entitlement.yaml | 6 +++--- ..._credit_criteria_for_targeted_childcare_entitlement.yaml | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dfe/targeted_childcare_entitlement/meets_child_tax_credit_criteria_for_targeted_childcare_entitlement.yaml b/policyengine_uk/tests/policy/baseline/gov/dfe/targeted_childcare_entitlement/meets_child_tax_credit_criteria_for_targeted_childcare_entitlement.yaml index 34477085af..869923b5eb 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dfe/targeted_childcare_entitlement/meets_child_tax_credit_criteria_for_targeted_childcare_entitlement.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dfe/targeted_childcare_entitlement/meets_child_tax_credit_criteria_for_targeted_childcare_entitlement.yaml @@ -8,7 +8,7 @@ benunit: members: [person] child_tax_credit: 1 - tax_credits_applicable_income: 16_190 + tax_credits_current_year_income: 16_190 output: meets_child_tax_credit_criteria_for_targeted_childcare_entitlement: true @@ -23,7 +23,7 @@ members: [person] child_tax_credit: 1 working_tax_credit: 1 - tax_credits_applicable_income: 16_190 + tax_credits_current_year_income: 16_190 output: meets_child_tax_credit_criteria_for_targeted_childcare_entitlement: false @@ -37,6 +37,6 @@ benunit: members: [person] child_tax_credit: 1 - tax_credits_applicable_income: 16_191 + tax_credits_current_year_income: 16_191 output: meets_child_tax_credit_criteria_for_targeted_childcare_entitlement: false diff --git a/policyengine_uk/tests/policy/baseline/gov/dfe/targeted_childcare_entitlement/meets_working_tax_credit_criteria_for_targeted_childcare_entitlement.yaml b/policyengine_uk/tests/policy/baseline/gov/dfe/targeted_childcare_entitlement/meets_working_tax_credit_criteria_for_targeted_childcare_entitlement.yaml index 12ee46e9e9..7e1a35bb6c 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dfe/targeted_childcare_entitlement/meets_working_tax_credit_criteria_for_targeted_childcare_entitlement.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dfe/targeted_childcare_entitlement/meets_working_tax_credit_criteria_for_targeted_childcare_entitlement.yaml @@ -8,7 +8,7 @@ benunit: members: [person] working_tax_credit: 1 - tax_credits_applicable_income: 16_190 + tax_credits_current_year_income: 16_190 output: meets_working_tax_credit_criteria_for_targeted_childcare_entitlement: true @@ -22,7 +22,7 @@ benunit: members: [person] child_tax_credit: 1 - tax_credits_applicable_income: 16_190 + tax_credits_current_year_income: 16_190 output: meets_working_tax_credit_criteria_for_targeted_childcare_entitlement: false @@ -36,6 +36,6 @@ benunit: members: [person] working_tax_credit: 1 - tax_credits_applicable_income: 16_191 + tax_credits_current_year_income: 16_191 output: meets_working_tax_credit_criteria_for_targeted_childcare_entitlement: false From 18e2fa3dda9b39f6a00d0305c1f27defe9cb7cd5 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 07:58:48 -0400 Subject: [PATCH 5/9] Review r2: family element and 2025-26 minimum guarantee in the YAML MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The childcare case's grandchild, aged 2, was born after 6 April 2017, so there is no CTC family element (CTC Regs 2002 reg 7(2)(a)): CTC is £3,455. - The 2025-26 case enters the statutory standard minimum guarantee, £227.10 a week (SPC Regs reg 6(1)(b), SI 2025/295 art. 29(2)(b)); the model's value differs (#1937). Guarantee Credit is £3,469.20. - Docstring: the nil-guarantee boundary applies where no savings credit is payable. Co-Authored-By: Claude Opus 5.5 --- .../tax_credits_pension_credit_passport.yaml | 17 ++++++++++++----- .../gov/dwp/tax_credits_applicable_income.py | 3 ++- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits_pension_credit_passport.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits_pension_credit_passport.yaml index a5e7a5fb78..d013059700 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits_pension_credit_passport.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits_pension_credit_passport.yaml @@ -250,6 +250,9 @@ # with a grandchild aged 2: their CTC is passported through the savings # credit, but their gross income is £20,200 and they have no Guarantee # Credit, so the child is not eligible through either route. + # CTC: the family element needs a child "born before 6th April 2017" (CTC + # Regs 2002 reg 7(2)(a)), so it is nil here (entered, as the model pays + # it); the individual element is £3,455 (reg 7(4)(c)). period: 2024 absolute_error_margin: 0.01 input: @@ -274,7 +277,7 @@ would_claim_uc: false pension_credit_earnings: 2_480 child_minimum_guarantee_addition: 0 - CTC_family_element: 545 + CTC_family_element: 0 working_tax_credit_run_on: false extended_childcare_entitlement_eligible: false households: @@ -287,7 +290,7 @@ savings_credit: 123.34 tax_credits_applicable_income: 0 tax_credits_current_year_income: 20_200 - child_tax_credit: 4_000 + child_tax_credit: 3_455 meets_child_tax_credit_criteria_for_targeted_childcare_entitlement: false targeted_childcare_entitlement_eligible: false @@ -332,8 +335,11 @@ - name: With no tax credit awards in payment, Pension Credit still lifts the income test # Tax credits ended on 5 April 2025, so no award is paid in 2025-26 and - # working tax credit is nil either way. Guarantee Credit: - # 221.85 x 52 - (2,340 + 6,000) = £3,196.20. + # working tax credit is nil either way. The standard minimum guarantee for + # a single claimant is £227.10 a week (SPC Regs 2002 reg 6(1)(b), as + # substituted by SI 2025/295 art. 29(2)(b)); it is entered because the + # model's 2025-26 value differs (#1937). Guarantee Credit: + # 227.10 x 52 - (2,340 + 6,000) = 11,809.20 - 8,340 = £3,469.20. period: 2025 absolute_error_margin: 0.01 input: @@ -350,13 +356,14 @@ benunit: members: [pensioner] pension_credit_earnings: 2_340 + standard_minimum_guarantee: 11_809.20 households: household: members: [pensioner] savings: 10_000 output: working_tax_credit: 0 - pension_credit: 3_196.20 + pension_credit: 3_469.20 tax_credits_applicable_income: 0 - name: Without the Pension Credit passport, the first case is income-tested diff --git a/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py b/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py index 4074d4e8f4..4a4ffa7cd9 100644 --- a/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py +++ b/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py @@ -26,7 +26,8 @@ def pension_credit_with_passported_tax_credits(benunit, period, parameters): then consistent, the passport is taken.) "Entitled" is read as payable: at income exactly equal to the minimum - guarantee, a nil guarantee credit gives no passport. + guarantee, where no savings credit is payable, a nil guarantee credit + gives no passport. In years with no tax credit awards, working tax credit is nil whether or not the test applies, so Pension Credit is read directly. From 1127279f8affdb2ce9a6d009cebef86e622edb20 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 12:34:51 -0400 Subject: [PATCH 6/9] Test the passport through the SI 2019/37 mixed-age saving MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1940, now merged in from main, models the SI 2019/37 art. 4 saving, so a mixed-age couple can get Pension Credit and with it the passport. - YAML: the mixed-age case is now the couple without the saving (and asserts there is none); a new case gives the same couple the saving: Guarantee Credit £4,298.40 and the maximum WTC, £4,935, worked from the law in the comments. - Properties: the pinned YAML households include that couple, and the strategy draws the saving, so mixed-age couples reach the passport in the random examples too. Co-Authored-By: Claude Opus 5.5 --- .../tax_credits_pension_credit_passport.yaml | 54 +++++++++++++++++-- ...dits_pension_credit_passport_properties.py | 34 +++++++++--- 2 files changed, 78 insertions(+), 10 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits_pension_credit_passport.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits_pension_credit_passport.yaml index d013059700..f59553b06f 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits_pension_credit_passport.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits_pension_credit_passport.yaml @@ -294,11 +294,12 @@ meets_child_tax_credit_criteria_for_targeted_childcare_entitlement: false targeted_childcare_entitlement_eligible: false -- name: A mixed-age couple cannot get Pension Credit, so the income test applies +- name: A mixed-age couple without the SI 2019/37 saving cannot get Pension Credit, so the income test applies # SPCA 2002 s.4(1A) (from 15 May 2019): no Pension Credit where the other - # member of the couple is under the qualifying age. The SI 2019/37 art. 4 - # saving is not modelled here. A couple both over that age with this income - # would get Guarantee Credit with the passport. + # member of the couple is under the qualifying age. This couple reports + # neither Pension Credit nor Housing Benefit, so they have no SI 2019/37 + # art. 4 saving; the next case has it. A couple both over that age with + # this income would get Guarantee Credit with the passport. # The older partner works 20 hours, which meets the WTC hours condition for # a joint claim with no child: "aged at least 60 and undertakes work for not # less than 16 hours per week" (WTC Regs 2002 reg 4(1), Second condition, @@ -329,10 +330,55 @@ members: [older, younger] savings: 10_000 output: + has_mixed_age_couple_pension_credit_saving: false pension_credit: 0 tax_credits_applicable_income: 8_600 working_tax_credit: 4_670.55 +- name: A mixed-age couple who keep Pension Credit under the SI 2019/37 saving get the passport + # The couple of the previous case, but entitled to Pension Credit as that + # couple on 14 May 2019 and on every day since, so s.4(1A) does not apply + # to them (SI 2019/37 art. 4(1)-(2)). The older partner is entitled to + # Pension Credit, and TCA 2002 s.7(2) lifts the income test for the joint + # claim while "either of the persons" is entitled to it. + # Passported WTC: £2,435 + £2,500 = £4,935. Pension Credit income: earnings + # of 2,600 - 10 x 52 = £2,080 (SPC Regs 2002 Sch VI para 5(b), a claimant + # with a partner), plus £6,000 and £4,935, is £13,015, against a couple's + # guarantee of 332.95 x 52 = £17,313.40. So Guarantee Credit is £4,298.40 + # and the passport holds. No savings credit: neither partner "attained + # pensionable age before 6 April 2016" (SPCA 2002 s.3(1)). + period: 2024 + absolute_error_margin: 0.01 + input: + people: + older: + age: 70 + is_claimant_or_partner: true + state_pension: 6_000 + savings_interest_income: 300 + employment_income: 2_600 + weekly_hours: 20 + working_tax_credit_reported: 1 + younger: + age: 40 + is_claimant_or_partner: true + benunits: + benunit: + members: [older, younger] + would_claim_uc: false + has_mixed_age_couple_pension_credit_saving: true + pension_credit_earnings: 2_080 # 2,600 - 10 x 52 + households: + household: + members: [older, younger] + savings: 10_000 + output: + tax_credits_current_year_income: 8_600 + tax_credits_applicable_income: 0 + working_tax_credit: 4_935 + savings_credit: 0 + pension_credit: 4_298.40 + - name: With no tax credit awards in payment, Pension Credit still lifts the income test # Tax credits ended on 5 April 2025, so no award is paid in 2025-26 and # working tax credit is nil either way. The standard minimum guarantee for diff --git a/policyengine_uk/tests/test_tax_credits_pension_credit_passport_properties.py b/policyengine_uk/tests/test_tax_credits_pension_credit_passport_properties.py index fa6d49c562..5e6a6a86e9 100644 --- a/policyengine_uk/tests/test_tax_credits_pension_credit_passport_properties.py +++ b/policyengine_uk/tests/test_tax_credits_pension_credit_passport_properties.py @@ -84,7 +84,12 @@ def families(draw): children = [ {"age": draw(st.integers(0, 15))} for _ in range(draw(st.integers(0, 2))) ] - benunit = {"would_claim_pc": draw(st.sampled_from([True, True, True, False]))} + benunit = { + "would_claim_pc": draw(st.sampled_from([True, True, True, False])), + # Whether a mixed-age couple keeps Pension Credit under the SI 2019/37 + # art. 4 saving (no effect on any other family). + "has_mixed_age_couple_pension_credit_saving": draw(st.booleans()), + } household = {"savings": draw(st.sampled_from([0, 5_000, 12_000, 20_000]))} return members, children, benunit, household @@ -186,14 +191,28 @@ def calculate(sim, year, order=OUTPUTS): }, {"savings": 10_000}, ), - # A mixed-age couple: no Pension Credit, no passport. + # A mixed-age couple without the SI 2019/37 saving: no Pension Credit, no + # passport. ( [ {**_PENSIONER, "state_pension": 6_000}, {"age": 40}, ], [], - {}, + {"has_mixed_age_couple_pension_credit_saving": False}, + {"savings": 10_000}, + ), + # The same couple with the saving: Guarantee Credit, passported. + ( + [ + {**_PENSIONER, "state_pension": 6_000}, + {"age": 40}, + ], + [], + { + "has_mixed_age_couple_pension_credit_saving": True, + "pension_credit_earnings": 2_080, + }, {"savings": 10_000}, ), ] @@ -359,11 +378,14 @@ def test_yaml_families_reach_every_case(): passported = (on["tax_credits_applicable_income"] == 0) & ( off["tax_credits_applicable_income"] > 0 ) - assert passported.tolist() == [True, False, True, False] - # The passport raises WTC (first) and CTC (third). + assert passported.tolist() == [True, False, True, False, True] + # The passport raises WTC (first and fifth) and CTC (third). assert on["working_tax_credit"][0] > off["working_tax_credit"][0] assert on["child_tax_credit"][2] > off["child_tax_credit"][2] + assert on["working_tax_credit"][4] > off["working_tax_credit"][4] # The third row: Pension Credit paid without the passport. assert on["pension_credit"][1] > 0 and not passported[1] - # The mixed-age couple gets no Pension Credit. + # The mixed-age couple gets Pension Credit, and so the passport, only + # under the saving. assert on["pension_credit"][3] == 0 + assert on["pension_credit"][4] > 0 From b30f6ee4358f7e713b32d802b6ed5592bd93e9cf Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 12:37:09 -0400 Subject: [PATCH 7/9] Cite #2050 for the CTC family element in the passport YAML comments Co-Authored-By: Claude Opus 5.5 --- .../gov/dwp/tax_credits_pension_credit_passport.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits_pension_credit_passport.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits_pension_credit_passport.yaml index f59553b06f..ef964532bc 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits_pension_credit_passport.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits_pension_credit_passport.yaml @@ -205,7 +205,8 @@ # CTC: £545 + £3,455 = £4,000, not 4,000 - 0.41 x 205 = £3,915.95. # SPC Regs 2002 reg 6(6)(d) and (11) give no Schedule IIA child amount to # a person awarded a tax credit, and the CTC family element has been £545 - # since 2011-12; both are entered because the model applies neither. + # since 2011-12; both are entered because the model applies neither + # (#2050 for the family element). period: 2024 absolute_error_margin: 0.01 input: @@ -252,7 +253,7 @@ # Credit, so the child is not eligible through either route. # CTC: the family element needs a child "born before 6th April 2017" (CTC # Regs 2002 reg 7(2)(a)), so it is nil here (entered, as the model pays - # it); the individual element is £3,455 (reg 7(4)(c)). + # it: #2050); the individual element is £3,455 (reg 7(4)(c)). period: 2024 absolute_error_margin: 0.01 input: From c7dc585c4972b18be597872b429ad9643e4f0e72 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 13:11:51 -0400 Subject: [PATCH 8/9] Give the mixed-age fixtures a birth date that fits the saving (review r4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At age 70 the default six months since the last birthday puts the older partner's birth on 6 April 1954, too late to have reached the qualifying age by 14 May 2019, which the SI 2019/37 saving's history needs. Nine months puts it on 6 January 1954. The paired YAML cases and their pinned Hypothesis families now say so; no expected amount changes. The YAML header now describes the savings interest as it is: at least £300 in each case, between the claimants. Co-Authored-By: Claude Opus 5.5 --- .../tax_credits_pension_credit_passport.yaml | 32 ++++++++++++------- ...dits_pension_credit_passport_properties.py | 7 ++-- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits_pension_credit_passport.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits_pension_credit_passport.yaml index ef964532bc..be9a6a7c46 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits_pension_credit_passport.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits_pension_credit_passport.yaml @@ -23,11 +23,13 @@ # Tax credit income (SI 2002/2006 reg 3): State Pension is step-one pension # income (reg 5(1)(a); ITEPA 2003 s.577) and savings interest step-one # investment income (reg 10); step one counts only its excess over £300. -# Earnings are step two. Each pensioner has £300 of interest on £10,000 of -# savings, so step one's excess is the State Pension. (The model counts State -# Pension in step two, which gives the same total once the interest uses the -# £300.) £10,000 of capital gives no Pension Credit tariff income (SPC Regs -# 2002 reg 15(6)), and savings interest is not Pension Credit income. +# Earnings are step two. In each case the claimants have £10,000 of savings +# and at least £300 of savings interest between them, so the interest uses up +# step one's £300 and step one's excess is the State Pension plus any interest +# above £300. (The model counts State Pension in step two, which gives the +# same total once the interest uses the £300.) £10,000 of capital gives no +# Pension Credit tariff income (SPC Regs 2002 reg 15(6)), and savings interest +# is not Pension Credit income. # # Pension Credit earnings are entered net of the SPC Regs 2002 Sch VI para 5 # disregard (£5 a week single, £10 a couple), which the model does not apply. @@ -299,8 +301,11 @@ # SPCA 2002 s.4(1A) (from 15 May 2019): no Pension Credit where the other # member of the couple is under the qualifying age. This couple reports # neither Pension Credit nor Housing Benefit, so they have no SI 2019/37 - # art. 4 saving; the next case has it. A couple both over that age with - # this income would get Guarantee Credit with the passport. + # art. 4 saving; the next case has it. The older partner is 70 and 9 months + # at 6 October 2024, so born on 6 January 1954, early enough to have reached + # the qualifying age (on 6 May 2019) before the saving's 14 May 2019 date; + # it is the missing entitlement that denies the saving. A couple both over + # that age with this income would get Guarantee Credit with the passport. # The older partner works 20 hours, which meets the WTC hours condition for # a joint claim with no child: "aged at least 60 and undertakes work for not # less than 16 hours per week" (WTC Regs 2002 reg 4(1), Second condition, @@ -313,6 +318,7 @@ people: older: age: 70 + months_since_last_birthday: 9 is_claimant_or_partner: true state_pension: 6_000 savings_interest_income: 300 @@ -337,11 +343,12 @@ working_tax_credit: 4_670.55 - name: A mixed-age couple who keep Pension Credit under the SI 2019/37 saving get the passport - # The couple of the previous case, but entitled to Pension Credit as that - # couple on 14 May 2019 and on every day since, so s.4(1A) does not apply - # to them (SI 2019/37 art. 4(1)-(2)). The older partner is entitled to - # Pension Credit, and TCA 2002 s.7(2) lifts the income test for the joint - # claim while "either of the persons" is entitled to it. + # The couple of the previous case (the older partner born on 6 January + # 1954, so over the qualifying age by 6 May 2019), but entitled to Pension + # Credit as that couple on 14 May 2019 and on every day since, so s.4(1A) + # does not apply to them (SI 2019/37 art. 4(1)-(2)). The older partner is + # entitled to Pension Credit, and TCA 2002 s.7(2) lifts the income test for + # the joint claim while "either of the persons" is entitled to it. # Passported WTC: £2,435 + £2,500 = £4,935. Pension Credit income: earnings # of 2,600 - 10 x 52 = £2,080 (SPC Regs 2002 Sch VI para 5(b), a claimant # with a partner), plus £6,000 and £4,935, is £13,015, against a couple's @@ -354,6 +361,7 @@ people: older: age: 70 + months_since_last_birthday: 9 is_claimant_or_partner: true state_pension: 6_000 savings_interest_income: 300 diff --git a/policyengine_uk/tests/test_tax_credits_pension_credit_passport_properties.py b/policyengine_uk/tests/test_tax_credits_pension_credit_passport_properties.py index 5e6a6a86e9..85764ec416 100644 --- a/policyengine_uk/tests/test_tax_credits_pension_credit_passport_properties.py +++ b/policyengine_uk/tests/test_tax_credits_pension_credit_passport_properties.py @@ -192,10 +192,11 @@ def calculate(sim, year, order=OUTPUTS): {"savings": 10_000}, ), # A mixed-age couple without the SI 2019/37 saving: no Pension Credit, no - # passport. + # passport. The older partner was born on 6 January 1954, early enough + # for the saving. ( [ - {**_PENSIONER, "state_pension": 6_000}, + {**_PENSIONER, "state_pension": 6_000, "months_since_last_birthday": 9}, {"age": 40}, ], [], @@ -205,7 +206,7 @@ def calculate(sim, year, order=OUTPUTS): # The same couple with the saving: Guarantee Credit, passported. ( [ - {**_PENSIONER, "state_pension": 6_000}, + {**_PENSIONER, "state_pension": 6_000, "months_since_last_birthday": 9}, {"age": 40}, ], [], From abe8fc766755e2c6eb13e54cc82625d3b2bbfef9 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 20:32:25 -0400 Subject: [PATCH 9/9] Update two YAML comments now that main gives the entered values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1925 holds the CTC family element at £545 and sets the 2025-26 standard minimum guarantee to £227.10, so case 6's family element and case 10's minimum guarantee now equal the model's own values. The inputs stay; the comments no longer say the model differs. Case 7's nil family element is still needed (the model pays it for a child born on or after 6 April 2017). Co-Authored-By: Claude Opus 5.5 --- .../gov/dwp/tax_credits_pension_credit_passport.yaml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits_pension_credit_passport.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits_pension_credit_passport.yaml index be9a6a7c46..14edc7aaaf 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits_pension_credit_passport.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits_pension_credit_passport.yaml @@ -206,9 +206,10 @@ # = £866.64, so £123.34. # CTC: £545 + £3,455 = £4,000, not 4,000 - 0.41 x 205 = £3,915.95. # SPC Regs 2002 reg 6(6)(d) and (11) give no Schedule IIA child amount to - # a person awarded a tax credit, and the CTC family element has been £545 - # since 2011-12; both are entered because the model applies neither - # (#2050 for the family element). + # a person awarded a tax credit; it is entered because the model pays it + # (#2061). The CTC family element has been £545 since 2011-12; it was + # entered when the model uprated it, and since #1925 the model's own value + # is the same. period: 2024 absolute_error_margin: 0.01 input: @@ -392,8 +393,9 @@ # Tax credits ended on 5 April 2025, so no award is paid in 2025-26 and # working tax credit is nil either way. The standard minimum guarantee for # a single claimant is £227.10 a week (SPC Regs 2002 reg 6(1)(b), as - # substituted by SI 2025/295 art. 29(2)(b)); it is entered because the - # model's 2025-26 value differs (#1937). Guarantee Credit: + # substituted by SI 2025/295 art. 29(2)(b)); it was entered when the + # model had £221.85 (#1937), and since #1925 the model's own value is the + # same. Guarantee Credit: # 227.10 x 52 - (2,340 + 6,000) = 11,809.20 - 8,340 = £3,469.20. period: 2025 absolute_error_margin: 0.01