From 5bed5996998eefba4571e8f35162df28ea1a7b88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Sun, 18 Oct 2026 03:15:50 +0100 Subject: [PATCH 1/2] Deduct allowable employment expenses from UC earnings --- changelog.d/1997.fixed.md | 1 + .../programs/gov/dwp/universal-credit.ipynb | 13 +- .../income/uc_employment_expenses.yaml | 61 ++++ .../tests/test_uc_employment_expenses.py | 320 ++++++++++++++++++ .../uc_individual_earned_income_before_mif.py | 14 +- .../deductions/employment_expenses.py | 26 +- 6 files changed, 428 insertions(+), 7 deletions(-) create mode 100644 changelog.d/1997.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_employment_expenses.yaml create mode 100644 policyengine_uk/tests/test_uc_employment_expenses.py diff --git a/changelog.d/1997.fixed.md b/changelog.d/1997.fixed.md new file mode 100644 index 0000000000..7373ca3370 --- /dev/null +++ b/changelog.d/1997.fixed.md @@ -0,0 +1 @@ +Disregard already-allowable employment expenses in Universal Credit employed earnings before the minimum income floor. diff --git a/docs/book/programs/gov/dwp/universal-credit.ipynb b/docs/book/programs/gov/dwp/universal-credit.ipynb index 5a6a07caff..06634b319e 100644 --- a/docs/book/programs/gov/dwp/universal-credit.ipynb +++ b/docs/book/programs/gov/dwp/universal-credit.ipynb @@ -38,7 +38,18 @@ "* Limited Capability for Work and Work-Related Activity amount\n", "* Carer amount\n", "\n", - "The table below shows some of the rates covered by PolicyEngine-UK. " + "The table below shows some of the rates covered by PolicyEngine-UK. ", + "\n", + "\n", + "## Allowable employment expenses\n", + "\n", + "The `employment_expenses` input is the annual amount already allowed under [Chapter 2 of Part 5 of ITEPA](https://www.legislation.gov.uk/ukpga/2003/1/part/5/chapter/2). Universal Credit disregards this amount from each person's employed earnings before the minimum income floor, under [GB regulation 55(3)(a)](https://www.legislation.gov.uk/uksi/2013/376/regulation/55) and the corresponding [Northern Ireland regulation 55(3)(a)](https://www.legislation.gov.uk/nisr/2016/216/regulation/55). The existing income-tax expense deduction also uses this input.\n", + "\n", + "Supply the legally allowed amount after applicable limits for the relevant employment and other deductions; do not supply all raw work costs. Expenses paid on an employee's behalf and reimbursements must meet sections 333 and 334, including the required inclusion in gross earnings. The same costs must not be deducted twice. The person-level annual input does not determine admissibility, job-by-job limits, or special-case exceptions. No aggregate salary cap or new earnings floor is inferred from it.\n", + "\n", + "For example, in the existing 2024 annual model, a single claimant aged 30 with £6,000 employment income and £600 of allowable expenses has £5,400 UC earnings before the minimum income floor. With no tax, NI, pension deductions, work allowance or other award adjustments, the 55% taper increases final UC by £330 relative to the same worker with no expenses. This illustrates the UC award; it does not certify household net-income treatment of the cost itself.\n", + "\n", + "This partial correction leaves payroll giving and other unresolved parts of [issue 1997](https://github.com/PolicyEngine/policyengine-uk/issues/1997) open. It retains the existing annual allocation, pension/trade calculation, minimum income floor, claimant selection and direct-input overrides. It does not establish assessment-period expense/payment timing, special-category admissibility, historical commencement or complete GB/NI history.\n" ] }, { diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_employment_expenses.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_employment_expenses.yaml new file mode 100644 index 0000000000..dc6bd9b846 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_employment_expenses.yaml @@ -0,0 +1,61 @@ +# The expense input is an already-allowable ITEPA Part 5 Chapter 2 amount. +# No raw-cost or per-employment admissibility is inferred by these cases. +- name: Qualified employment expenses reduce earnings and increase final UC cash + period: 2024 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 30 + employment_income: 6_000 + employment_expenses: 600 + benunits: + family: + members: [claimant] + would_claim_uc: true + households: + household: + members: [claimant] + tenure_type: OWNED_OUTRIGHT + region: LONDON + output: + taxable_employment_income: 5_400 + income_tax: 0 + national_insurance: 0 + pension_contributions: 0 + uc_work_allowance: 0 + uc_individual_earned_income_before_mif: 5_400 + uc_earned_income: 5_400 + uc_income_reduction: 2_970 + # Standard allowance 393.45 monthly; no child/housing elements, + # benefit cap reduction or debt deductions. + universal_credit_pre_benefit_cap: 1_751.40 + universal_credit: 1_751.40 + +- name: The same worker without expenses retains the original final UC cash + period: 2024 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 30 + employment_income: 6_000 + benunits: + family: + members: [claimant] + would_claim_uc: true + households: + household: + members: [claimant] + tenure_type: OWNED_OUTRIGHT + region: LONDON + output: + taxable_employment_income: 6_000 + income_tax: 0 + national_insurance: 0 + uc_work_allowance: 0 + uc_individual_earned_income_before_mif: 6_000 + uc_earned_income: 6_000 + uc_income_reduction: 3_300 + universal_credit_pre_benefit_cap: 1_421.40 + universal_credit: 1_421.40 diff --git a/policyengine_uk/tests/test_uc_employment_expenses.py b/policyengine_uk/tests/test_uc_employment_expenses.py new file mode 100644 index 0000000000..f27c0cb397 --- /dev/null +++ b/policyengine_uk/tests/test_uc_employment_expenses.py @@ -0,0 +1,320 @@ +"""UC expense disregards use already-allowable amounts, not raw-cost claims.""" + +import numpy as np +import pytest + +from policyengine_uk import Simulation + +YEAR = 2024 + + +def situation(people=None, *, year=YEAR, benunit=None): + people = people or {"claimant": {"age": 30, "employment_income": 6_000}} + names = list(people) + return { + "people": { + name: {key: {year: value} for key, value in inputs.items()} + for name, inputs in people.items() + }, + "benunits": { + "family": { + "members": names, + "would_claim_uc": {year: True}, + **{key: {year: value} for key, value in (benunit or {}).items()}, + } + }, + "households": { + "household": { + "members": names, + "region": {year: "LONDON"}, + "tenure_type": {year: "OWNED_OUTRIGHT"}, + } + }, + } + + +def simulation(people=None, *, year=YEAR, benunit=None): + return Simulation(situation=situation(people, year=year, benunit=benunit)) + + +def test_allowable_expenses_increase_natural_final_uc_cash(): + ordinary = simulation() + expenses = simulation( + { + "claimant": { + "age": 30, + "employment_income": 6_000, + "employment_expenses": 600, + } + } + ) + for sim, earned in [(ordinary, 6_000), (expenses, 5_400)]: + np.testing.assert_allclose( + sim.calculate("uc_individual_earned_income_before_mif", YEAR), + [earned], + atol=0.01, + ) + assert sim.calculate("is_uc_eligible", YEAR)[0] + assert sim.calculate("claims_universal_credit", YEAR)[0] + assert not sim.calculate("uc_mif_applies", YEAR)[0] + for variable in [ + "income_tax", + "national_insurance", + "pension_contributions", + "uc_work_allowance", + "uc_benefit_cap_reduction", + "uc_deductions", + ]: + np.testing.assert_allclose(sim.calculate(variable, YEAR), [0], atol=0.01) + # Both awards are positive and below the unchanged maximum: the + # 55% earnings taper passes on 600 * .55 = 330, without clipping. + for variable in ["universal_credit_pre_benefit_cap", "universal_credit"]: + low = ordinary.calculate(variable, YEAR) + high = expenses.calculate(variable, YEAR) + assert low[0] > 0 + assert high[0] < expenses.calculate("uc_maximum_amount", YEAR)[0] + np.testing.assert_allclose(high - low, [330], atol=0.01) + + +@pytest.mark.parametrize("year", [2020, 2024, 2026]) +def test_zero_expenses_preserve_the_default(year): + default = simulation(year=year) + zero = simulation( + {"claimant": {"age": 30, "employment_income": 6_000, "employment_expenses": 0}}, + year=year, + ) + for variable in [ + "uc_individual_earned_income_before_mif", + "uc_earned_income", + "universal_credit", + ]: + np.testing.assert_array_equal( + default.calculate(variable, year), zero.calculate(variable, year) + ) + + +@pytest.mark.parametrize("expenses, expected", [(600, 5_400), (6_000, 0)]) +def test_allowed_deduction_and_exact_earnings_limit(expenses, expected): + # The supplied amount has already satisfied per-employment legal limits. + sim = simulation( + { + "claimant": { + "age": 30, + "employment_income": 6_000, + "employment_expenses": expenses, + } + } + ) + np.testing.assert_allclose( + sim.calculate("uc_individual_earned_income_before_mif", YEAR), + [expected], + atol=0.01, + ) + np.testing.assert_allclose( + sim.calculate("taxable_employment_income", YEAR), [expected], atol=0.01 + ) + + +def test_each_claimants_deduction_is_used_once(): + sim = simulation( + { + "claimant": { + "age": 40, + "employment_income": 6_000, + "employment_expenses": 600, + }, + "partner": { + "age": 38, + "employment_income": 4_000, + "employment_expenses": 400, + }, + } + ) + np.testing.assert_allclose( + sim.calculate("uc_individual_earned_income_before_mif", YEAR), + [5_400, 3_600], + atol=0.01, + ) + np.testing.assert_allclose( + sim.calculate("uc_earned_income", YEAR), [9_000], atol=0.01 + ) + + +def test_nonclaimants_expense_does_not_reduce_claimants_earnings(): + sim = simulation( + { + "parent": {"age": 40, "is_parent": True, "employment_income": 10_000}, + "child": { + "age": 17, + "current_education": "UPPER_SECONDARY", + "employment_income": 3_000, + "employment_expenses": 600, + }, + } + ) + np.testing.assert_array_equal( + sim.calculate("is_uc_assessed_claimant", YEAR), [True, False] + ) + np.testing.assert_allclose( + sim.calculate("uc_individual_earned_income_before_mif", YEAR), + [10_000, 2_400], + atol=0.01, + ) + np.testing.assert_allclose( + sim.calculate("uc_earned_income", YEAR), + 10_000 - sim.calculate("uc_work_allowance", YEAR), + atol=0.01, + ) + + +@pytest.mark.parametrize("trade, expected", [(2_000, 7_400), (-2_000, 5_400)]) +def test_trade_earnings_keep_their_separate_nil_floor(trade, expected): + sim = simulation( + { + "claimant": { + "age": 30, + "employment_income": 6_000, + "employment_expenses": 600, + "self_employment_income": trade, + "uc_is_in_startup_period": True, + } + } + ) + np.testing.assert_allclose( + sim.calculate("uc_individual_earned_income_before_mif", YEAR), + [expected], + atol=0.01, + ) + + +def test_mif_still_compares_with_expense_net_actual_earnings(): + # Supplied MIF statuses/threshold are composition controls, not a + # natural claim that every worker is subject to the floor. + sim = simulation( + { + "claimant": { + "age": 30, + "employment_income": 6_000, + "employment_expenses": 600, + "uc_mif_applies": True, + "uc_minimum_income_floor": 8_000, + } + } + ) + np.testing.assert_allclose( + sim.calculate("uc_individual_earned_income_before_mif", YEAR), + [5_400], + atol=0.01, + ) + np.testing.assert_allclose( + sim.calculate("uc_individual_earned_income", YEAR), [8_000], atol=0.01 + ) + + +def test_annual_flow_is_allocated_to_month_without_changing_input(): + sim = simulation( + { + "claimant": { + "age": 30, + "employment_income": 6_000, + "employment_expenses": 600, + } + } + ) + np.testing.assert_allclose( + sim.calculate("employment_expenses", "2024-04"), [50], atol=0.01 + ) + np.testing.assert_allclose( + sim.calculate("uc_individual_earned_income_before_mif", "2024-04"), + [450], + atol=0.01, + ) + + +def test_direct_final_uc_input_remains_an_override(): + sim = simulation( + { + "claimant": { + "age": 30, + "employment_income": 6_000, + "employment_expenses": 600, + } + }, + benunit={"universal_credit": 1_234}, + ) + np.testing.assert_allclose( + sim.calculate("universal_credit", YEAR), [1_234], atol=0.01 + ) + + +def test_multiunit_vector_keeps_expenses_with_their_owner(): + units = [ + situation( + { + "claimant": { + "age": 30, + "employment_income": 6_000, + "employment_expenses": expense, + } + } + ) + for expense in [0, 600] + ] + merged = {entity: {} for entity in ["people", "benunits", "households"]} + for index, unit in enumerate(units): + for entity, rows in unit.items(): + for name, row in rows.items(): + row = dict(row) + if "members" in row: + row["members"] = [f"{member}_{index}" for member in row["members"]] + merged[entity][f"{name}_{index}"] = row + sim = Simulation(situation=merged) + np.testing.assert_allclose( + sim.calculate("uc_individual_earned_income_before_mif", YEAR), + [6_000, 5_400], + atol=0.01, + ) + + +def test_statutory_pay_and_pension_components_are_retained(): + # The pension and statutory-pay inputs exercise existing composition, + # with qualified expenses below the worker's employment earnings. + sim = simulation( + { + "claimant": { + "age": 30, + "employment_income": 6_000, + "employment_expenses": 600, + "statutory_sick_pay": 1_000, + "pension_contributions": 100, + } + } + ) + np.testing.assert_allclose( + sim.calculate("uc_individual_earned_income_before_mif", YEAR), + [6_300], + atol=0.01, + ) + + +def test_only_the_specific_expense_input_is_disregarded(): + # Supplied aggregate override: this checks consumer routing, not + # statutory admissibility of a separate class of deduction. + sim = simulation( + { + "claimant": { + "age": 30, + "employment_income": 6_000, + "employment_expenses": 600, + "employment_deductions": 2_500, + } + } + ) + np.testing.assert_allclose( + sim.calculate("taxable_employment_income", YEAR), [3_500], atol=0.01 + ) + np.testing.assert_allclose( + sim.calculate("uc_individual_earned_income_before_mif", YEAR), + [5_400], + atol=0.01, + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py index f0abee8de3..ac9f2e484c 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py @@ -10,7 +10,7 @@ class uc_individual_earned_income_before_mif(Variable): documentation = ( "The person's actual earned income for Universal Credit: their " "earnings, including statutory sick, maternity and paternity pay, " - "less their own relievable pension contributions and their own " + "less allowable employment expenses, their own relievable pension contributions and their own " "income tax and National Insurance in respect of their employment " "and self-employment. Concurrent trade results include company " "income treated as self-employed earnings under regulation 77; " @@ -23,6 +23,14 @@ class uc_individual_earned_income_before_mif(Variable): definition_period = YEAR unit = GBP reference = [ + dict( + title="Universal Credit Regulations 2013 reg. 55(3)(a)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/55", + ), + dict( + title="Universal Credit Regulations (Northern Ireland) 2016 reg. 55(3)(a)", + href="https://www.legislation.gov.uk/nisr/2016/216/regulation/55", + ), dict( title="Universal Credit Regulations 2013 reg. 55(4) and (5)", href="https://www.legislation.gov.uk/uksi/2013/376/regulation/55", @@ -92,8 +100,12 @@ def formula(person, period, parameters): + person("uc_national_insurance_on_earnings", period) - person("uc_national_insurance_on_trade", period) ) + # Reg. 55(3)(a) disregards expenses allowed under Chapter 2 of + # Part 5 ITEPA. This input is the already-allowable amount, after + # relevant-employment limits, not a claim for arbitrary raw costs. employed = ( add(person, period, earnings_components) + - person("employment_expenses", period) - employment_tax_and_national_insurance ) # Contributions paid after the person reaches 75 are not relievable diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/deductions/employment_expenses.py b/policyengine_uk/variables/gov/hmrc/income_tax/deductions/employment_expenses.py index cffac78c1b..77b4b95b23 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/deductions/employment_expenses.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/deductions/employment_expenses.py @@ -4,10 +4,26 @@ class employment_expenses(Variable): value_type = float entity = Person - label = "Cost of expenses necessarily incurred and reimbursed by employment" - definition_period = YEAR - reference = dict( - title="Income Tax Act (Earnings and Pensions) Act 2003 s. 333", - href="https://www.legislation.gov.uk/ukpga/2003/1/section/333", + label = "Allowable employment expenses" + documentation = ( + "Employment expenses allowed as a deduction under Chapter 2 of Part 5 " + "of ITEPA, after the applicable relevant-employment and other deduction " + "limits. Supply the legally allowed amount, not all raw work costs. " + "Amounts paid on the employee's behalf, and reimbursements, qualify " + "only to the extent required by sections 333 and 334, including their " + "inclusion in earnings. Do not deduct the same costs twice. The model " + "does not determine admissibility or per-employment limits from this " + "person-level total." ) + definition_period = YEAR + reference = [ + dict( + title="Income Tax (Earnings and Pensions) Act 2003 ss. 333 and 334", + href="https://www.legislation.gov.uk/ukpga/2003/1/part/5/chapter/2", + ), + dict( + title="Income Tax (Earnings and Pensions) Act 2003 ss. 328 to 330", + href="https://www.legislation.gov.uk/ukpga/2003/1/part/5/chapter/1", + ), + ] unit = GBP From d08f26ed4a36fd8836a8c1fb25387854aded9e7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:09:54 +0100 Subject: [PATCH 2/2] Fix issues from review: clarify UC expense scope and references --- changelog.d/1997.fixed.md | 2 ++ docs/book/programs/gov/dwp/universal-credit.ipynb | 5 +++-- .../income/uc_individual_earned_income_before_mif.py | 6 +----- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/changelog.d/1997.fixed.md b/changelog.d/1997.fixed.md index 7373ca3370..bcc1b4fd30 100644 --- a/changelog.d/1997.fixed.md +++ b/changelog.d/1997.fixed.md @@ -1 +1,3 @@ Disregard already-allowable employment expenses in Universal Credit employed earnings before the minimum income floor. + +Nonzero employment expenses supplied by a dataset can change Universal Credit microsimulation results. The aggregate effect has not been quantified. diff --git a/docs/book/programs/gov/dwp/universal-credit.ipynb b/docs/book/programs/gov/dwp/universal-credit.ipynb index 06634b319e..c254e52dc8 100644 --- a/docs/book/programs/gov/dwp/universal-credit.ipynb +++ b/docs/book/programs/gov/dwp/universal-credit.ipynb @@ -38,8 +38,7 @@ "* Limited Capability for Work and Work-Related Activity amount\n", "* Carer amount\n", "\n", - "The table below shows some of the rates covered by PolicyEngine-UK. ", - "\n", + "The table below shows some of the rates covered by PolicyEngine-UK. \n", "\n", "## Allowable employment expenses\n", "\n", @@ -49,6 +48,8 @@ "\n", "For example, in the existing 2024 annual model, a single claimant aged 30 with £6,000 employment income and £600 of allowable expenses has £5,400 UC earnings before the minimum income floor. With no tax, NI, pension deductions, work allowance or other award adjustments, the 55% taper increases final UC by £330 relative to the same worker with no expenses. This illustrates the UC award; it does not certify household net-income treatment of the cost itself.\n", "\n", + "Nonzero employment expenses supplied by a dataset can change Universal Credit microsimulation results. The aggregate effect has not been quantified.\n", + "\n", "This partial correction leaves payroll giving and other unresolved parts of [issue 1997](https://github.com/PolicyEngine/policyengine-uk/issues/1997) open. It retains the existing annual allocation, pension/trade calculation, minimum income floor, claimant selection and direct-input overrides. It does not establish assessment-period expense/payment timing, special-category admissibility, historical commencement or complete GB/NI history.\n" ] }, diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py index ac9f2e484c..a17fc4c791 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py @@ -24,17 +24,13 @@ class uc_individual_earned_income_before_mif(Variable): unit = GBP reference = [ dict( - title="Universal Credit Regulations 2013 reg. 55(3)(a)", + title="Universal Credit Regulations 2013 reg. 55(3)(a), (4) and (5)", href="https://www.legislation.gov.uk/uksi/2013/376/regulation/55", ), dict( title="Universal Credit Regulations (Northern Ireland) 2016 reg. 55(3)(a)", href="https://www.legislation.gov.uk/nisr/2016/216/regulation/55", ), - dict( - title="Universal Credit Regulations 2013 reg. 55(4) and (5)", - href="https://www.legislation.gov.uk/uksi/2013/376/regulation/55", - ), dict( title="Universal Credit Regulations 2013 reg. 57(2)", href="https://www.legislation.gov.uk/uksi/2013/376/regulation/57",