From 975df90059645beb5dcaa05b9bd9847599820609 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 16:39:46 -0400 Subject: [PATCH 1/4] Apply the UC minimum income floor to net earned income against a net threshold UC Regs 2013 reg 62 compares a self-employed claimant's earned income, after their own tax, NI and pension contributions, with the reg 90(2) individual threshold "converted to net ... amounts by ... deducting such amount for income tax and national insurance contributions as the Secretary of State considers appropriate" (reg 62(4)), and treats them as having that net amount. The model took max(gross earnings, gross floor) and then subtracted the tax and NI on the claimant's actual profits, so a claimant under the floor was treated as having the gross threshold less the tax on their actual profits. - uc_minimum_income_floor_gross: minimum wage x expected hours x 52 / 12, rounded down to whole pounds (reg 6(1A)(a)), times 12. - uc_minimum_income_floor_income_tax / _national_insurance: the notional income tax and primary Class 1 NI on the threshold as the person's only pay (the modelling choice for the Secretary of State's discretion; a parameter switches to Class 2 and Class 4). - uc_minimum_income_floor: the net floor. - uc_individual_earned_income_before_mif: actual earned income after the reg 55(5) / 57(2) deductions. - uc_individual_earned_income: reg 62(2) for single claimants and reg 62(3) for couples (the couple threshold is the sum of the claimants' floors). - uc_mif_applies: claimants only. - uc_mif_capped_earned_income is removed. Co-Authored-By: Claude Opus 5.5 --- changelog.d/uc-mif-net-floor.added.md | 1 + changelog.d/uc-mif-net-floor.fixed.md | 1 + changelog.d/uc-mif-net-floor.removed.md | 1 + .../programs/gov/dwp/universal-credit.ipynb | 2 +- .../means_test/minimum_income_floor/README.md | 1 + .../self_employed_national_insurance.yaml | 11 + .../income_floor/uc_MIF_applies copy.yaml | 34 -- .../income_floor/uc_mif_earned_income.yaml | 259 +++++++++++++ .../income_floor/uc_minimum_income_floor.yaml | 107 +++++- .../test_uc_earnings_deductions_properties.py | 18 +- ...test_uc_minimum_income_floor_properties.py | 352 ++++++++++++++++++ .../income/income_floor/uc_mif_applies.py | 9 +- .../uc_mif_capped_earned_income.py | 27 -- .../income_floor/uc_minimum_income_floor.py | 30 +- .../uc_minimum_income_floor_gross.py | 44 +++ .../uc_minimum_income_floor_income_tax.py | 41 ++ ...minimum_income_floor_national_insurance.py | 63 ++++ .../income/uc_income_tax_on_earnings.py | 2 +- .../income/uc_individual_earned_income.py | 57 ++- .../uc_individual_earned_income_before_mif.py | 52 +++ 20 files changed, 1021 insertions(+), 91 deletions(-) create mode 100644 changelog.d/uc-mif-net-floor.added.md create mode 100644 changelog.d/uc-mif-net-floor.fixed.md create mode 100644 changelog.d/uc-mif-net-floor.removed.md create mode 100644 policyengine_uk/parameters/gov/dwp/universal_credit/means_test/minimum_income_floor/README.md create mode 100644 policyengine_uk/parameters/gov/dwp/universal_credit/means_test/minimum_income_floor/self_employed_national_insurance.yaml delete mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_MIF_applies copy.yaml create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_mif_earned_income.yaml create mode 100644 policyengine_uk/tests/test_uc_minimum_income_floor_properties.py delete mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_mif_capped_earned_income.py create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_minimum_income_floor_gross.py create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_minimum_income_floor_income_tax.py create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_minimum_income_floor_national_insurance.py create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py diff --git a/changelog.d/uc-mif-net-floor.added.md b/changelog.d/uc-mif-net-floor.added.md new file mode 100644 index 000000000..b231e4459 --- /dev/null +++ b/changelog.d/uc-mif-net-floor.added.md @@ -0,0 +1 @@ +Add the `gov.dwp.universal_credit.means_test.minimum_income_floor.self_employed_national_insurance` parameter and the `uc_minimum_income_floor_gross`, `uc_minimum_income_floor_income_tax`, `uc_minimum_income_floor_national_insurance` and `uc_individual_earned_income_before_mif` variables; `uc_minimum_income_floor` is now the net floor. diff --git a/changelog.d/uc-mif-net-floor.fixed.md b/changelog.d/uc-mif-net-floor.fixed.md new file mode 100644 index 000000000..6b17a38a7 --- /dev/null +++ b/changelog.d/uc-mif-net-floor.fixed.md @@ -0,0 +1 @@ +Apply the Universal Credit minimum income floor as the law sets it (UC Regs 2013 reg. 62): compare each self-employed claimant's earned income after their own tax, NI and pension contributions with the individual threshold converted to a net amount (reg. 62(4)), rather than the gross threshold less the tax on their actual profits; round the threshold down to whole pounds a month (reg. 6(1A)(a)); apply the couple rule of reg. 62(3); and apply the floor only to claimants, not dependants. diff --git a/changelog.d/uc-mif-net-floor.removed.md b/changelog.d/uc-mif-net-floor.removed.md new file mode 100644 index 000000000..8114f87dc --- /dev/null +++ b/changelog.d/uc-mif-net-floor.removed.md @@ -0,0 +1 @@ +Remove `uc_mif_capped_earned_income`, which applied a gross floor to gross earnings; use `uc_individual_earned_income`. diff --git a/docs/book/programs/gov/dwp/universal-credit.ipynb b/docs/book/programs/gov/dwp/universal-credit.ipynb index c87f8f251..f57d1c8b8 100644 --- a/docs/book/programs/gov/dwp/universal-credit.ipynb +++ b/docs/book/programs/gov/dwp/universal-credit.ipynb @@ -44,7 +44,7 @@ { "cell_type": "markdown", "metadata": {}, - "source": "## How PolicyEngine computes Universal Credit\n\nPolicyEngine computes Universal Credit at the benefit-unit level in three stages:\n\n1. **Maximum entitlement** (`uc_maximum_amount`): sum the elements the benunit qualifies for — `uc_standard_allowance`, `uc_child_element`, `uc_disability_elements`, `uc_carer_element`, `uc_housing_costs_element`, and `uc_childcare_element`.\n2. **Means test**: the maximum entitlement is reduced by the benunit's countable earnings above its work allowance (`uc_work_allowance`) at the published taper rate. Each person's earnings (`uc_individual_earned_income`) are net of their own relievable pension contributions and the income tax and National Insurance they pay on that employment or self-employment (UC Regulations 2013 regs 55(5) and 57(2)): `uc_income_tax_on_earnings` treats earnings as the lowest slice of the person's non-savings income, so tax on pensions, property, savings and dividends is never deducted, and one partner's tax never reduces the other's earnings. The combined earnings above the work allowance are then tapered (the `gov.dwp.universal_credit.means_test.reduction_rate` parameter, currently 55%), plus all of its unearned income (`uc_unearned_income`), pound for pound. Unearned income is the list in `gov.dwp.universal_credit.means_test.income_definitions.unearned`: retirement pension income (State Pension and private pensions, UC Regulations 2013 regs 66(1)(a) and 67), Carer's Allowance, contribution-based JSA, savings, dividend and property income, and the tariff income deemed from capital (reg 72), which replaces the actual yield of the capital it is charged on. Asset rules apply on top of this — capital above the lower threshold deems a tariff income, and capital above the upper threshold disqualifies the benunit entirely.\n3. **Benefit cap**: the post-means-test award (`universal_credit_pre_benefit_cap`) is finally reduced by `benefit_cap_reduction` to produce `universal_credit`. The benefit cap only applies to benunits without a benefit-cap exemption (working enough hours, having a qualifying disability benefit, etc.).\n\nThe take-up step is handled by the input variable `would_claim_uc`, which is populated stochastically when the dataset is built so that PolicyEngine's caseload aggregates match published DWP claimant numbers rather than the full eligible population.\n\nParameters live in `policyengine_uk/parameters/gov/dwp/universal_credit/` and the per-element formulas in `policyengine_uk/variables/gov/dwp/universal_credit/`." + "source": "## How PolicyEngine computes Universal Credit\n\nPolicyEngine computes Universal Credit at the benefit-unit level in three stages:\n\n1. **Maximum entitlement** (`uc_maximum_amount`): sum the elements the benunit qualifies for — `uc_standard_allowance`, `uc_child_element`, `uc_disability_elements`, `uc_carer_element`, `uc_housing_costs_element`, and `uc_childcare_element`.\n2. **Means test**: the maximum entitlement is reduced by the benunit's countable earnings above its work allowance (`uc_work_allowance`) at the published taper rate. Each person's earnings (`uc_individual_earned_income`) are net of their own relievable pension contributions and the income tax and National Insurance they pay on that employment or self-employment (UC Regulations 2013 regs 55(5) and 57(2)): `uc_income_tax_on_earnings` treats earnings as the lowest slice of the person's non-savings income, so tax on pensions, property, savings and dividends is never deducted, and one partner's tax never reduces the other's earnings. A claimant with self-employment income outside a start-up period is subject to the minimum income floor (reg 62): when their earnings after those deductions are below `uc_minimum_income_floor`, they are treated as having that amount instead. The floor is the minimum wage for their age for 35 hours a week, converted to a monthly amount in whole pounds (regs 90(2) and 6(1A)), less the income tax and primary Class 1 National Insurance an employee would pay on it (reg 62(4)(b) leaves this amount to the Secretary of State; the `gov.dwp.universal_credit.means_test.minimum_income_floor.self_employed_national_insurance` parameter switches to Class 2 and Class 4). A member of a couple is lifted only while the couple's combined earnings are below the couple threshold, the sum of both partners' floors, and never above it (reg 62(3)). The combined earnings above the work allowance are then tapered (the `gov.dwp.universal_credit.means_test.reduction_rate` parameter, currently 55%), plus all of its unearned income (`uc_unearned_income`), pound for pound. Unearned income is the list in `gov.dwp.universal_credit.means_test.income_definitions.unearned`: retirement pension income (State Pension and private pensions, UC Regulations 2013 regs 66(1)(a) and 67), Carer's Allowance, contribution-based JSA, savings, dividend and property income, and the tariff income deemed from capital (reg 72), which replaces the actual yield of the capital it is charged on. Asset rules apply on top of this — capital above the lower threshold deems a tariff income, and capital above the upper threshold disqualifies the benunit entirely.\n3. **Benefit cap**: the post-means-test award (`universal_credit_pre_benefit_cap`) is finally reduced by `benefit_cap_reduction` to produce `universal_credit`. The benefit cap only applies to benunits without a benefit-cap exemption (working enough hours, having a qualifying disability benefit, etc.).\n\nThe take-up step is handled by the input variable `would_claim_uc`, which is populated stochastically when the dataset is built so that PolicyEngine's caseload aggregates match published DWP claimant numbers rather than the full eligible population.\n\nParameters live in `policyengine_uk/parameters/gov/dwp/universal_credit/` and the per-element formulas in `policyengine_uk/variables/gov/dwp/universal_credit/`." }, { "cell_type": "code", diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/minimum_income_floor/README.md b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/minimum_income_floor/README.md new file mode 100644 index 000000000..db89852a5 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/minimum_income_floor/README.md @@ -0,0 +1 @@ +# Minimum income floor diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/minimum_income_floor/self_employed_national_insurance.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/minimum_income_floor/self_employed_national_insurance.yaml new file mode 100644 index 000000000..bc8cb8c7a --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/minimum_income_floor/self_employed_national_insurance.yaml @@ -0,0 +1,11 @@ +description: Whether the amount for National Insurance that converts the Universal Credit minimum income floor to a net amount is the Class 2 and Class 4 contributions due on self-employed profits equal to the threshold, rather than the primary Class 1 contributions due on pay equal to it. The regulations leave the amount to the Secretary of State. DWP describes the floor as what an employed person would earn at the minimum wage after tax and National Insurance, so the model uses Class 1; DWP's 2017 guidance on applying the floor deducted Class 2 and Class 4 instead. +values: + 2013-04-29: false +metadata: + unit: bool + label: Universal Credit minimum income floor self-employed National Insurance + reference: + - title: The Universal Credit Regulations 2013 reg. 62(4)(b) + href: https://www.legislation.gov.uk/uksi/2013/376/regulation/62 + - title: "DWP, Universal Credit and self-employment: quick guide" + href: https://www.gov.uk/government/publications/universal-credit-for-the-self-employed/universal-credit-and-self-employment-quick-guide diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_MIF_applies copy.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_MIF_applies copy.yaml deleted file mode 100644 index 931732f8b..000000000 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_MIF_applies copy.yaml +++ /dev/null @@ -1,34 +0,0 @@ -- name: Floor does not apply - period: 2020 - absolute_error_margin: 0 - input: - self_employment_income: 500 - employment_income: 1_000 - uc_mif_applies: false - uc_minimum_income_floor: 2_000 - output: - uc_mif_capped_earned_income: 1_500 - - -- name: Floor applies - period: 2020 - absolute_error_margin: 0 - input: - self_employment_income: 500 - employment_income: 1_000 - uc_mif_applies: true - uc_minimum_income_floor: 2_000 - output: - uc_mif_capped_earned_income: 2_000 - - -- name: Floor applies - below income - period: 2020 - absolute_error_margin: 0 - input: - self_employment_income: 500 - employment_income: 1_000 - uc_mif_applies: true - uc_minimum_income_floor: 1_000 - output: - uc_mif_capped_earned_income: 1_500 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_mif_earned_income.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_mif_earned_income.yaml new file mode 100644 index 000000000..d4b348b26 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_mif_earned_income.yaml @@ -0,0 +1,259 @@ +# UC Regs 2013 reg. 62 compares a claimant's earned income, after the +# deductions for their own income tax, NI and pension contributions (reg. +# 55(5), reg. 57(2)), with the net threshold (reg. 62(4)), and treats them as +# having the net threshold when they are below it: +# - a single claimant: "treated as having earned income equal to that +# threshold" (reg. 62(2)); +# - a member of a couple, only while the couple's combined earned income is +# below the couple threshold: "their individual threshold minus any amount +# by which that amount of earned income combined with their partner's +# earned income would exceed the couple threshold" (reg. 62(3)). +# +# Hand calculations use 2026-27 rates: net floor 20,168.88 for a claimant +# aged 21 or over in England (23,124 gross, see uc_minimum_income_floor.yaml); personal allowance 12,570, basic rate 20%; trading allowance 1,000; +# Class 4 NI 6% above 12,570; primary Class 1 NI 8% above 12,569.96; UC +# standard allowance 424.90 a month (single, 25 or over), 666.97 (couple), +# child element 303.94, work allowance 427 a month with housing costs; taper +# 55%. Marriage Allowance is switched off to keep the tax simple. + +- name: Single self-employed claimant below the floor is treated as having the net floor + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + self_employment_income: 5_000 + households: + household: + members: [person] + region: NORTH_EAST + tenure_type: RENT_FROM_COUNCIL + rent: 9_600 + output: + uc_mif_applies: true + # Taxable profit 4,000 and profits of 5,000 are below the allowance and + # the lower profits limit. + uc_individual_earned_income_before_mif: 5_000 + uc_individual_earned_income: 20_168.88 + uc_earned_income: 20_168.88 + # 424.90 x 12 + 9,600. + uc_maximum_amount: 14_698.80 + # 14,698.80 - 0.55 x 20,168.88. Treating the claimant as having the + # gross threshold of 12.71 x 35 x 52 = 23,132.20, as before this fix, + # gave 1,976.09. + universal_credit: 3_605.92 + +- name: Floor applies when earnings are below the net floor, even above the gross threshold + period: 2026 + absolute_error_margin: 0.01 + input: + age: 30 + region: NORTH_EAST + self_employment_income: 24_000 + pension_contributions: 3_000 + output: + # Taxable profit 23,000 less 3,000 pension relief and the personal + # allowance: 7,430 x 20%. + uc_income_tax_on_earnings: 1_486 + # Class 4: (24,000 - 12,570) x 6%. + uc_national_insurance_on_earnings: 685.80 + # 24,000 - 3,000 - 1,486 - 685.80. Reg. 57(2) deducts the relievable + # pension contributions too, so this is below the net floor. Before this + # fix the gross 24,000 was above the gross threshold, so no floor. + uc_individual_earned_income_before_mif: 18_828.20 + uc_individual_earned_income: 20_168.88 + +- name: Floor does not apply when net earnings are above the net floor, even below the gross threshold + period: 2026 + absolute_error_margin: 0.01 + input: + age: 30 + region: NORTH_EAST + self_employment_income: 23_000 + output: + # (22,000 - 12,570) x 20%. + uc_income_tax_on_earnings: 1_886 + # (23,000 - 12,570) x 6%. + uc_national_insurance_on_earnings: 625.80 + # 23,000 - 1,886 - 625.80 is above 20,168.88, so the actual amount + # counts. Before this fix the gross 23,000 was below the gross threshold + # and the claimant was treated as having 23,132.20 - 1,886 - 625.80 + # = 20,620.40. + uc_individual_earned_income_before_mif: 20_488.20 + uc_individual_earned_income: 20_488.20 + +- name: Floor lifts net earnings below it to the net floor + period: 2026 + absolute_error_margin: 0.01 + input: + age: 30 + region: NORTH_EAST + self_employment_income: 22_000 + output: + # 22,000 - (21,000 - 12,570) x 20% - (22,000 - 12,570) x 6%. + uc_individual_earned_income_before_mif: 19_748.20 + # Before this fix: 23,132.20 - 1,686 - 565.80 = 20,880.40. + uc_individual_earned_income: 20_168.88 + +- name: Floor does not apply in a start-up period + period: 2026 + absolute_error_margin: 0.01 + input: + age: 30 + region: NORTH_EAST + self_employment_income: 5_000 + uc_is_in_startup_period: true + output: + uc_mif_applies: false + uc_individual_earned_income: 5_000 + +- name: Floor does not apply without self-employment + period: 2026 + absolute_error_margin: 0.01 + input: + age: 30 + region: NORTH_EAST + employment_income: 5_000 + output: + uc_mif_applies: false + uc_individual_earned_income: 5_000 + +- name: ADM H4080 example 1, couple where only one partner has earnings + period: 2026 + absolute_error_margin: 0.01 + # The example's monthly amounts times 12. The husband earns 400 a month + # against an individual threshold of 950; the wife has no earned income; + # the couple threshold is 1,900. + input: + people: + husband: + age: 40 + uc_mif_applies: true + uc_individual_earned_income_before_mif: 4_800 + uc_minimum_income_floor: 11_400 + wife: + age: 40 + uc_mif_applies: false + uc_individual_earned_income_before_mif: 0 + uc_minimum_income_floor: 11_400 + output: + # "The husband is treated as having earned income of £950". + uc_individual_earned_income: [11_400, 0] + +- name: ADM H4080 example 2, both partners self-employed + period: 2026 + absolute_error_margin: 0.01 + # Husband 500 a month, wife 1,200; individual thresholds 950 each; couple + # threshold 1,900. The wife is above her threshold, so her actual earnings + # count; the husband's 950 would take the couple to 2,150, 250 over. + input: + people: + husband: + age: 40 + uc_mif_applies: true + uc_individual_earned_income_before_mif: 6_000 + uc_minimum_income_floor: 11_400 + wife: + age: 40 + uc_mif_applies: true + uc_individual_earned_income_before_mif: 14_400 + uc_minimum_income_floor: 11_400 + output: + # "The husband is treated as having earned income of £700." + uc_individual_earned_income: [8_400, 14_400] + uc_earned_income: 22_800 + +- name: Couple where both partners are below their thresholds are each treated as having their own + period: 2026 + absolute_error_margin: 0.01 + # Monthly amounts times 12, as in the ADM examples: 500 and 600 a month + # against thresholds of 950 each. + input: + people: + first: + age: 40 + uc_mif_applies: true + uc_individual_earned_income_before_mif: 6_000 + uc_minimum_income_floor: 11_400 + second: + age: 40 + uc_mif_applies: true + uc_individual_earned_income_before_mif: 7_200 + uc_minimum_income_floor: 11_400 + output: + # Each: 950 - max(0, 950 + partner's earned income - 1,900) = 950. + uc_individual_earned_income: [11_400, 11_400] + uc_earned_income: 22_800 + +- name: Couple at or above the couple threshold, the floor does not apply + period: 2026 + absolute_error_margin: 0.01 + # Monthly amounts times 12: 800 and 1,100 a month against thresholds of + # 950 each. + input: + people: + first: + age: 40 + uc_mif_applies: true + uc_individual_earned_income_before_mif: 9_600 + uc_minimum_income_floor: 11_400 + second: + age: 40 + uc_mif_applies: false + uc_individual_earned_income_before_mif: 13_200 + uc_minimum_income_floor: 11_400 + output: + # 800 is below 950, but 800 + 1,100 is not below 1,900 (reg. 62(3)(b)). + uc_individual_earned_income: [9_600, 13_200] + +- name: Couple from gross incomes, the partner's earnings above their threshold reduce the floor + period: 2026 + absolute_error_margin: 0.01 + input: + people: + self_employed: + age: 30 + self_employment_income: 6_000 + would_claim_marriage_allowance: false + employee: + age: 30 + employment_income: 25_000 + would_claim_marriage_allowance: false + child_1: + age: 5 + child_2: + age: 3 + households: + household: + members: [self_employed, employee, child_1, child_2] + region: NORTH_EAST + tenure_type: RENT_FROM_COUNCIL + rent: 12_000 + output: + uc_mif_applies: [true, false, false, false] + # Employee: 25,000 - (25,000 - 12,570) x 20% - (25,000 - 12,569.96) x 8% + # = 25,000 - 2,486 - 994.40. + uc_individual_earned_income_before_mif: [6_000, 21_519.60, 0, 0] + # 20,168.88 - (20,168.88 + 21,519.60 - 40,337.75) = 18,818.15, so the + # couple has 40,337.75, the couple threshold. + uc_individual_earned_income: [18_818.15, 21_519.60, 0, 0] + # Less the work allowance of 427 x 12 = 5,124. + uc_earned_income: 35_213.75 + # 666.97 x 12 + 2 x 303.94 x 12 + 12,000. + uc_maximum_amount: 27_298.20 + # 27,298.20 - 0.55 x 35,213.75. + universal_credit: 7_930.64 + +- name: Floor does not apply to a dependent child with self-employment income + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + child: + age: 15 + self_employment_income: 500 + output: + uc_mif_applies: [false, false] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_minimum_income_floor.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_minimum_income_floor.yaml index 506165630..ef0b0e2fe 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_minimum_income_floor.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_minimum_income_floor.yaml @@ -1,7 +1,110 @@ -- name: Minimum income floor +# The minimum income floor is the individual threshold of UC Regs 2013 +# reg. 90(2), the National Minimum Wage rate for the person's age times their +# expected hours (35 a week, reg. 88), times 52 / 12 and rounded down to a +# whole pound (reg. 6(1A)(a)), converted to a net amount by deducting "such +# amount for income tax and national insurance contributions as the Secretary +# of State considers appropriate" (reg. 62(4)(b)). The model deducts the +# income tax and primary Class 1 NI the person would pay if the threshold were +# their only income, from employment. Annual amounts are 12 monthly amounts. +# +# Hand calculations use the rates the model holds for each year: +# - 2026: National Living Wage 12.71 an hour (21 and over), 10.85 (18 to 20); +# personal allowance 12,570; rest-of-UK basic rate 20%; Scottish starter +# rate 19% on the first 3,967 of taxable income, then basic rate 20% up to +# 16,956; primary Class 1 NI 8% above the primary threshold of 241.73 a +# week (12,569.96 a year); +# - 2020: National Living Wage 8.72 an hour (25 and over); personal +# allowance 12,500; basic rate 20%; primary Class 1 NI 12% above 183 a +# week (9,516 a year). + +- name: Individual threshold is the minimum wage for 35 hours a week, in whole pounds a month period: 2020 absolute_error_margin: 0 input: minimum_wage: 10 output: - uc_minimum_income_floor: 350 * 52 + # 10 x 35 x 52 / 12 = 1,516.67, rounded down to 1,516, times 12. + uc_minimum_income_floor_gross: 18_192 + +- name: A whole-pound monthly threshold is not rounded down + period: 2020 + absolute_error_margin: 0 + input: + minimum_wage: 12 + output: + # 12 x 35 x 52 / 12 = 1,820 exactly. + uc_minimum_income_floor_gross: 21_840 + +- name: Net floor for a claimant aged 30 in England, 2026 + period: 2026 + absolute_error_margin: 0.01 + input: + age: 30 + region: NORTH_EAST + output: + # 12.71 x 35 x 52 / 12 = 1,927.68, rounded down to 1,927 a month. + uc_minimum_income_floor_gross: 23_124 + # (23,124 - 12,570) x 20%. + uc_minimum_income_floor_income_tax: 2_110.80 + # (23,124 - 12,569.96) x 8%. + uc_minimum_income_floor_national_insurance: 844.32 + # 23,124 - 2,110.80 - 844.32, or 1,680.74 a month. + uc_minimum_income_floor: 20_168.88 + +- name: Net floor for a Scottish taxpayer uses Scottish income tax rates + period: 2026 + absolute_error_margin: 0.01 + input: + age: 30 + region: SCOTLAND + output: + uc_minimum_income_floor_gross: 23_124 + # Taxable 10,554: 3,967 x 19% + (10,554 - 3,967) x 20% + # = 753.73 + 1,317.40. + uc_minimum_income_floor_income_tax: 2_071.13 + uc_minimum_income_floor_national_insurance: 844.32 + # 23,124 - 2,071.13 - 844.32. + uc_minimum_income_floor: 20_208.55 + +- name: Net floor for a claimant aged 19 uses the 18 to 20 minimum wage + period: 2026 + absolute_error_margin: 0.01 + input: + age: 19 + region: NORTH_EAST + output: + # 10.85 x 35 x 52 / 12 = 1,645.58, rounded down to 1,645 a month. + uc_minimum_income_floor_gross: 19_740 + # (19,740 - 12,570) x 20%. + uc_minimum_income_floor_income_tax: 1_434 + # (19,740 - 12,569.96) x 8%. + uc_minimum_income_floor_national_insurance: 573.60 + uc_minimum_income_floor: 17_732.40 + +- name: No Class 1 NI is deducted for a person over State Pension age + period: 2026 + absolute_error_margin: 0.01 + input: + age: 70 + region: NORTH_EAST + output: + uc_minimum_income_floor_gross: 23_124 + uc_minimum_income_floor_income_tax: 2_110.80 + # Primary Class 1 is not payable over pensionable age (SSCBA 1992 s. 6(3)). + uc_minimum_income_floor_national_insurance: 0 + uc_minimum_income_floor: 21_013.20 + +- name: Net floor in 2020 uses that year's rates + period: 2020 + absolute_error_margin: 0.01 + input: + age: 30 + region: NORTH_EAST + output: + # 8.72 x 35 x 52 / 12 = 1,322.53, rounded down to 1,322 a month. + uc_minimum_income_floor_gross: 15_864 + # (15,864 - 12,500) x 20%. + uc_minimum_income_floor_income_tax: 672.80 + # (15,864 - 9,516) x 12%. + uc_minimum_income_floor_national_insurance: 761.76 + uc_minimum_income_floor: 14_429.44 diff --git a/policyengine_uk/tests/test_uc_earnings_deductions_properties.py b/policyengine_uk/tests/test_uc_earnings_deductions_properties.py index 1d68ea2b2..b772b31eb 100644 --- a/policyengine_uk/tests/test_uc_earnings_deductions_properties.py +++ b/policyengine_uk/tests/test_uc_earnings_deductions_properties.py @@ -28,7 +28,10 @@ the personal allowance taper (100,000), no one is old enough for the married couple's allowance, and no one claims Marriage Allowance (see test_uc_state_pension_properties.py for the Marriage Allowance deviation). -Invariant 4 runs with Marriage Allowance claimed. +Invariant 4 runs with Marriage Allowance claimed, and with everyone in a +start-up period: the minimum income floor now compares net earned income with +a net threshold (test_uc_minimum_income_floor_properties.py), which moves +earned income either way, so it is held off here. """ import numpy as np @@ -231,7 +234,11 @@ class uc_earned_income(Variable): unit = GBP def formula(benunit, period, parameters): - gross = add(benunit, period, ["uc_mif_capped_earned_income"]) + gross = add( + benunit, + period, + ["employment_income", "self_employment_income", "miscellaneous_income"], + ) disregards = add( benunit, period, @@ -251,6 +258,13 @@ def formula(benunit, period, parameters): year=st.sampled_from(YEARS), ) def test_fix_only_removes_deductions(units, year): + units = [ + dict( + unit, + adults=[dict(a, uc_is_in_startup_period=True) for a in unit["adults"]], + ) + for unit in units + ] after = calculate(units, year, marriage_allowance=True) before = calculate(units, year, scenario=BEFORE_1942, marriage_allowance=True) assert np.all(after["uc_earned_income"] >= before["uc_earned_income"] - 0.01), units diff --git a/policyengine_uk/tests/test_uc_minimum_income_floor_properties.py b/policyengine_uk/tests/test_uc_minimum_income_floor_properties.py new file mode 100644 index 000000000..e53149b0c --- /dev/null +++ b/policyengine_uk/tests/test_uc_minimum_income_floor_properties.py @@ -0,0 +1,352 @@ +"""Property-based tests for the Universal Credit minimum income floor. + +UC Regs 2013 reg. 62 treats a claimant in gainful self-employment whose +earned income, after the deductions for their own income tax, NI and pension +contributions, is below their individual threshold as having that threshold. +The threshold is the reg. 90(2) amount "converted to net amounts by ... +deducting such amount for income tax and national insurance contributions as +the Secretary of State considers appropriate" (reg. 62(4)). For a member of a +couple the floor applies only while the couple's combined earned income is +below the couple threshold, and is reduced by any amount by which it and the +partner's earned income would exceed that threshold (reg. 62(3)). + +Invariants, for any generated population of single people, couples and +mixed-age couples with employment, self-employment, pension contributions and +start-up periods, in England, Wales and Scotland: + +1. The floor never lowers anyone's earned income, and changes it only for + claimants it applies to. +2. The floor holds: a single claimant it applies to has at least the net + floor. A member of a couple it applies to leaves the couple with at least + the smaller of the couple threshold and their own threshold plus their + partner's earned income, and is never lifted above their own threshold. +3. The couple threshold caps the top-up: whenever the floor lifts anyone, + the couple's combined earned income is at most the couple threshold. +4. Differential against a reference: the model's reg. 62(2)-(3) conditions + equal the closed form max(earned income, threshold - max(0, threshold + + partner's earned income - couple threshold)), written here from the + regulation. +5. Differential against the tax engine: the notional income tax deducted + from the threshold equals the income tax the same person would pay with + the gross threshold as their only income, from employment. The notional + NI equals the primary Class 1 they would pay on it as pay or, under the + self-employed basis, the Class 2 and Class 4 they would pay on it as + profits. So the net floor never exceeds the gross threshold. +6. Monotone: more earnings never lower a benefit unit's earned income, and + never raise UC before the benefit cap. Before this fix, a self-employed + claimant under the floor was treated as having the gross threshold less + the tax on their actual profits, so UC rose with their profits. + +Marriage Allowance is switched off throughout: a transfer can lower one +partner's earned income when the other's earnings rise, which is a separate, +lawful, non-monotonicity. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +PROPERTY_SETTINGS = settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +# 2020 has 12% Class 1 NI and the temporary UC uplift; 2026 current rates; +# 2027 the Finance Act 2026 rates. +YEARS = [2020, 2026, 2027] +TENURES = ["RENT_FROM_COUNCIL", "RENT_PRIVATELY", "OWNED_OUTRIGHT"] +REGIONS = ["LONDON", "NORTH_EAST", "WALES", "SCOTLAND"] +WORKING_AGE = st.integers(18, 64) +PENSION_AGE = st.integers(67, 80) +SHAPES = { + "single": [WORKING_AGE], + "couple": [WORKING_AGE, WORKING_AGE], + "mixed_age": [PENSION_AGE, WORKING_AGE], +} +# Profits and pay straddle the floor (about 16,000-23,000 gross). +self_employment = st.one_of(st.just(0.0), st.floats(0, 40_000)) +employment = st.one_of(st.just(0.0), st.floats(0, 40_000)) +pension = st.one_of(st.just(0.0), st.floats(0, 4_000)) +bumps = st.floats(1, 10_000) +PERSON_VARIABLES = [ + "age", + "is_uc_claimant", + "uc_mif_applies", + "uc_individual_earned_income_before_mif", + "uc_individual_earned_income", + "uc_minimum_income_floor", + "uc_minimum_income_floor_gross", + "uc_minimum_income_floor_income_tax", + "uc_minimum_income_floor_national_insurance", + "pays_scottish_income_tax", +] +BENUNIT_VARIABLES = ["uc_earned_income", "universal_credit_pre_benefit_cap"] + + +@st.composite +def families(draw): + shape = draw(st.sampled_from(list(SHAPES))) + adults = [ + dict( + age=draw(age), + self_employment_income=draw(self_employment), + employment_income=draw(employment), + pension_contributions=draw(pension), + uc_is_in_startup_period=draw(st.booleans()), + ) + for age in SHAPES[shape] + ] + return dict( + adults=adults, + children=[draw(st.integers(0, 15)) for _ in range(draw(st.integers(0, 2)))], + tenure=draw(st.sampled_from(TENURES)), + rent=draw(st.floats(0, 15_000)), + region=draw(st.sampled_from(REGIONS)), + ) + + +populations = st.lists(families(), min_size=1, max_size=8) + + +def situation(units, year, bump=None): + """One simulation holding every family. + + ``bump`` is (family index, adult index, variable, amount) to add. + """ + people, benunits, households = {}, {}, {} + for i, unit in enumerate(units): + names = [] + for j, adult in enumerate(unit["adults"]): + name = f"p{i}_{j}" + person = {k: {year: v} for k, v in adult.items()} + if bump is not None and bump[:2] == (i, j): + variable, amount = bump[2], bump[3] + person[variable] = {year: adult[variable] + amount} + person["would_claim_marriage_allowance"] = {year: False} + people[name] = person + names.append(name) + for k, age in enumerate(unit["children"]): + name = f"c{i}_{k}" + people[name] = {"age": {year: age}} + names.append(name) + benunits[f"b{i}"] = {"members": names} + households[f"h{i}"] = { + "members": names, + "rent": {year: unit["rent"]}, + "tenure_type": {year: unit["tenure"]}, + "region": {year: unit["region"]}, + } + return {"people": people, "benunits": benunits, "households": households} + + +def calculate(units, year, reform=None, **kwargs): + sim = Simulation(situation=situation(units, year, **kwargs), reform=reform) + values = {v: np.asarray(sim.calculate(v, year)) for v in PERSON_VARIABLES} + for v in BENUNIT_VARIABLES: + values[v] = np.asarray(sim.calculate(v, year)) + values[f"person_{v}"] = np.asarray(sim.calculate(v, year, map_to="person")) + claimant = values["is_uc_claimant"].astype(bool) + before = values["uc_individual_earned_income_before_mif"] + after = values["uc_individual_earned_income"] + threshold = values["uc_minimum_income_floor"] + # Benefit-unit sums over the claimants, mapped back to each person. + values["claimant_before_sum"] = np.asarray( + sim.map_result( + sim.map_result(before * claimant, "person", "benunit"), + "benunit", + "person", + ) + ) + values["claimant_after_sum"] = np.asarray( + sim.map_result( + sim.map_result(after * claimant, "person", "benunit"), + "benunit", + "person", + ) + ) + values["couple_threshold"] = np.asarray( + sim.map_result( + sim.map_result(threshold * claimant, "person", "benunit"), + "benunit", + "person", + ) + ) + values["lifted_in_unit"] = np.asarray( + sim.map_result( + sim.map_result( + ((after > before + 0.01) & claimant).astype(float), + "person", + "benunit", + ), + "benunit", + "person", + ) + ) + values["claimants_in_unit"] = np.asarray( + sim.map_result( + sim.map_result(claimant.astype(float), "person", "benunit"), + "benunit", + "person", + ) + ) + return values + + +@PROPERTY_SETTINGS +@given(units=populations, year=st.sampled_from(YEARS)) +def test_floor_never_lowers_earned_income(units, year): + v = calculate(units, year) + before = v["uc_individual_earned_income_before_mif"] + after = v["uc_individual_earned_income"] + assert np.all(after >= before - 0.01), units + unaffected = ~v["uc_mif_applies"].astype(bool) + np.testing.assert_allclose( + after[unaffected], before[unaffected], atol=0.01, err_msg=str(units) + ) + + +@PROPERTY_SETTINGS +@given(units=populations, year=st.sampled_from(YEARS)) +def test_floor_holds_for_singles_and_couples(units, year): + v = calculate(units, year) + applies = v["uc_mif_applies"].astype(bool) + before = v["uc_individual_earned_income_before_mif"] + after = v["uc_individual_earned_income"] + threshold = v["uc_minimum_income_floor"] + single = v["claimants_in_unit"] == 1 + couple = v["claimants_in_unit"] == 2 + # Single claimants reach the net floor. + assert np.all(after[applies & single] >= threshold[applies & single] - 0.01), units + # Couples reach the smaller of the couple threshold and the person's own + # threshold plus their partner's earned income. + partner = v["claimant_before_sum"] - before + target = np.minimum(v["couple_threshold"], threshold + partner) + combined = after + partner + m = applies & couple + assert np.all(combined[m] >= target[m] - 0.01), units + # Nobody is lifted above their own threshold. + assert np.all(after <= np.maximum(before, threshold) + 0.01), units + + +@PROPERTY_SETTINGS +@given(units=populations, year=st.sampled_from(YEARS)) +def test_top_up_never_takes_couple_above_couple_threshold(units, year): + v = calculate(units, year) + lifted_unit = v["lifted_in_unit"] > 0 + assert np.all( + v["claimant_after_sum"][lifted_unit] + <= v["couple_threshold"][lifted_unit] + 0.01 + ), units + + +@PROPERTY_SETTINGS +@given(units=populations, year=st.sampled_from(YEARS)) +def test_matches_closed_form_of_regulation_62(units, year): + v = calculate(units, year) + before = v["uc_individual_earned_income_before_mif"] + threshold = v["uc_minimum_income_floor"] + claimant = v["is_uc_claimant"].astype(bool) + partner = v["claimant_before_sum"] - before * claimant + floor = threshold - np.maximum(0, threshold + partner - v["couple_threshold"]) + expected = np.where( + v["uc_mif_applies"].astype(bool), np.maximum(before, floor), before + ) + np.testing.assert_allclose( + v["uc_individual_earned_income"], expected, atol=0.01, err_msg=str(units) + ) + + +SELF_EMPLOYED_NI = ( + "gov.dwp.universal_credit.means_test.minimum_income_floor." + "self_employed_national_insurance" +) + + +@PROPERTY_SETTINGS +@given( + units=populations, + year=st.sampled_from(YEARS), + self_employed=st.booleans(), +) +def test_notional_deductions_equal_tax_on_threshold_as_only_income( + units, year, self_employed +): + reform = {SELF_EMPLOYED_NI: {"2013-01-01.2100-12-31": self_employed}} + v = calculate(units, year, reform=reform) + adults = v["age"] >= 16 + gross = v["uc_minimum_income_floor_gross"][adults] + scottish = v["pays_scottish_income_tax"][adults].astype(bool) + ages = v["age"][adults] + # Each adult alone twice: paid their gross threshold from employment, and + # with profits of that amount. + people, benunits, households = {}, {}, {} + for k, (amount, age, in_scotland) in enumerate(zip(gross, ages, scottish)): + for kind, income in [ + ("e", "employment_income"), + ("s", "self_employment_income"), + ]: + name = f"{kind}{k}" + people[name] = { + "age": {year: int(age)}, + income: {year: float(amount)}, + "would_claim_marriage_allowance": {year: False}, + } + benunits[f"b{name}"] = {"members": [name]} + households[f"h{name}"] = { + "members": [name], + "region": {year: "SCOTLAND" if in_scotland else "NORTH_EAST"}, + } + alone = Simulation( + situation={"people": people, "benunits": benunits, "households": households} + ) + employee = np.arange(len(people)) % 2 == 0 + np.testing.assert_allclose( + v["uc_minimum_income_floor_income_tax"][adults], + np.asarray(alone.calculate("income_tax", year))[employee], + atol=0.01, + err_msg=str(units), + ) + if self_employed: + expected_ni = ( + np.asarray(alone.calculate("ni_class_2", year)) + + np.asarray(alone.calculate("ni_class_4", year)) + )[~employee] + else: + expected_ni = np.asarray(alone.calculate("ni_class_1_employee", year))[employee] + np.testing.assert_allclose( + v["uc_minimum_income_floor_national_insurance"][adults], + expected_ni, + atol=0.01, + err_msg=str(units), + ) + net = v["uc_minimum_income_floor"] + assert np.all(net <= v["uc_minimum_income_floor_gross"] + 0.01) + assert np.all(net >= 0) + + +@st.composite +def bumped(draw): + units = draw(populations) + i = draw(st.integers(0, len(units) - 1)) + j = draw(st.integers(0, len(units[i]["adults"]) - 1)) + variable = draw(st.sampled_from(["self_employment_income", "employment_income"])) + return units, (i, j, variable, draw(bumps)) + + +@PROPERTY_SETTINGS +@given(case=bumped(), year=st.sampled_from(YEARS)) +def test_more_earnings_never_lower_earned_income_or_raise_uc(case, year): + units, bump = case + low = calculate(units, year) + high = calculate(units, year, bump=bump) + assert np.all(high["uc_earned_income"] >= low["uc_earned_income"] - 0.01), ( + bump, + units, + ) + assert np.all( + high["universal_credit_pre_benefit_cap"] + <= low["universal_credit_pre_benefit_cap"] + 0.01 + ), (bump, units) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_mif_applies.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_mif_applies.py index 89874240e..5214792b6 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_mif_applies.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_mif_applies.py @@ -6,12 +6,15 @@ class uc_mif_applies(Variable): entity = Person label = "Universal Credit minimum income floor applies" documentation = ( - "Whether the Minimum Income Floor should be used to determine UC entitlement" + "Whether the minimum income floor applies to this person: a claimant " + "with self-employment income, outside a start-up period." ) - reference = "https://www.legislation.gov.uk/uksi/2013/376/regulation/62/2021-04-06" + reference = "https://www.legislation.gov.uk/uksi/2013/376/regulation/62" definition_period = YEAR def formula(person, period, parameters): + # Reg. 62(1) applies to a claimant, not a dependant. + claimant = person("is_uc_claimant", period) has_self_empl_income = person("self_employment_income", period) > 0 in_startup_period = person("uc_is_in_startup_period", period) - return has_self_empl_income & ~in_startup_period + return claimant & has_self_empl_income & ~in_startup_period diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_mif_capped_earned_income.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_mif_capped_earned_income.py deleted file mode 100644 index 03c7deb25..000000000 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_mif_capped_earned_income.py +++ /dev/null @@ -1,27 +0,0 @@ -from policyengine_uk.model_api import * - - -class uc_mif_capped_earned_income(Variable): - value_type = float - entity = Person - label = "Universal Credit gross earned income (incl. MIF)" - documentation = "Gross earned income for UC, with MIF applied where applicable" - definition_period = YEAR - unit = GBP - - def formula(person, period, parameters): - INCOME_COMPONENTS = [ - "employment_income", - "self_employment_income", - "miscellaneous_income", - ] - bi = parameters(period).gov.contrib.ubi_center.basic_income - if bi.interactions.include_in_means_tests: - INCOME_COMPONENTS.append("basic_income") - personal_gross_earned_income = add(person, period, INCOME_COMPONENTS) - floor = where( - person("uc_mif_applies", period), - person("uc_minimum_income_floor", period), - -inf, - ) - return max_(personal_gross_earned_income, floor) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_minimum_income_floor.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_minimum_income_floor.py index f450756b6..3ce2a82f8 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_minimum_income_floor.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_minimum_income_floor.py @@ -5,11 +5,33 @@ class uc_minimum_income_floor(Variable): value_type = float entity = Person label = "Universal Credit minimum income floor" + documentation = ( + "The person's individual threshold converted to a net amount: the " + "gross threshold less the amounts for income tax and National " + "Insurance. A claimant to whom the floor applies is treated as having " + "this much earned income, after deductions, when their own is lower." + ) definition_period = YEAR unit = GBP + reference = [ + dict( + title="Universal Credit Regulations 2013 reg. 62(4)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/62", + ), + dict( + title="Advice for Decision Making, chapter H4, H4078-H4079", + href="https://www.gov.uk/government/publications/advice-for-decision-making-staff-guide", + ), + ] def formula(person, period, parameters): - expected_hours = parameters( - period - ).gov.dwp.universal_credit.work_requirements.default_expected_hours - return person("minimum_wage", period) * expected_hours * WEEKS_IN_YEAR + gross = person("uc_minimum_income_floor_gross", period) + deductions = add( + person, + period, + [ + "uc_minimum_income_floor_income_tax", + "uc_minimum_income_floor_national_insurance", + ], + ) + return max_(0, gross - deductions) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_minimum_income_floor_gross.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_minimum_income_floor_gross.py new file mode 100644 index 000000000..a0a0bdbc9 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_minimum_income_floor_gross.py @@ -0,0 +1,44 @@ +from policyengine_uk.model_api import * + + +class uc_minimum_income_floor_gross(Variable): + value_type = float + entity = Person + label = "Universal Credit minimum income floor individual threshold (gross)" + documentation = ( + "The person's individual threshold before the deductions for income " + "tax and National Insurance: the National Minimum Wage hourly rate " + "for their age times their expected hours of work each week, " + "converted to a monthly amount in whole pounds, over a year." + ) + definition_period = YEAR + unit = GBP + reference = [ + dict( + title="Universal Credit Regulations 2013 reg. 90(2)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/90", + ), + dict( + title="Universal Credit Regulations 2013 reg. 88", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/88", + ), + dict( + title="Universal Credit Regulations 2013 reg. 6(1A)(a)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/6", + ), + ] + + def formula(person, period, parameters): + expected_hours = parameters( + period + ).gov.dwp.universal_credit.work_requirements.default_expected_hours + hourly_rate = person("minimum_wage", period).astype(np.float64) + # Reg. 90(2) converts the weekly amount "to a monthly amount by + # multiplying by 52 and dividing by 12", and reg. 6(1A)(a) + # disregards any fraction of a pound in a reg. 90 threshold (ADM + # H4079: 234.50 x 52 / 12 = 1,016). Rounding to a millionth first + # keeps a whole-pound result whole in floating point. + monthly = np.floor( + np.round(hourly_rate * expected_hours * WEEKS_IN_YEAR / MONTHS_IN_YEAR, 6) + ) + return monthly * MONTHS_IN_YEAR diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_minimum_income_floor_income_tax.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_minimum_income_floor_income_tax.py new file mode 100644 index 000000000..e50c9536f --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_minimum_income_floor_income_tax.py @@ -0,0 +1,41 @@ +from policyengine_uk.model_api import * + + +class uc_minimum_income_floor_income_tax(Variable): + value_type = float + entity = Person + label = "Universal Credit minimum income floor deduction for income tax" + documentation = ( + "The amount for income tax deducted from the person's gross " + "individual threshold to give their net minimum income floor. The " + "regulations leave it to the Secretary of State; the model takes the " + "income tax the person would pay if the threshold were their only " + "income, from employment: the threshold less the standard personal " + "allowance, at the person's own rest-of-UK or Scottish rates." + ) + definition_period = YEAR + unit = GBP + reference = [ + dict( + title="Universal Credit Regulations 2013 reg. 62(4)(b)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/62", + ), + dict( + title="Advice for Decision Making, chapter H4, H4078-H4079", + href="https://www.gov.uk/government/publications/advice-for-decision-making-staff-guide", + ), + ] + + def formula(person, period, parameters): + # Reg. 62(4)(b) deducts "such amount for income tax ... as the + # Secretary of State considers appropriate". The model treats the + # threshold as stand-alone employment income: only the standard + # personal allowance applies, and no other income or relief. + income_tax = parameters(period).gov.hmrc.income_tax + threshold = person("uc_minimum_income_floor_gross", period) + taxable = max_(0, threshold - income_tax.allowances.personal_allowance.amount) + return where( + person("pays_scottish_income_tax", period), + income_tax.rates.scotland.rates.calc(taxable), + income_tax.rates.uk.calc(taxable), + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_minimum_income_floor_national_insurance.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_minimum_income_floor_national_insurance.py new file mode 100644 index 000000000..1d7de80b8 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_minimum_income_floor_national_insurance.py @@ -0,0 +1,63 @@ +from policyengine_uk.model_api import * + + +class uc_minimum_income_floor_national_insurance(Variable): + value_type = float + entity = Person + label = "Universal Credit minimum income floor deduction for National Insurance" + documentation = ( + "The amount for National Insurance deducted from the person's gross " + "individual threshold to give their net minimum income floor. The " + "regulations leave it to the Secretary of State. The model takes the " + "contributions the person would pay if the threshold were their only " + "earnings: primary Class 1 on pay of that amount, or, where the " + "parameter selects self-employed contributions, Class 2 and Class 4 " + "on profits of that amount." + ) + definition_period = YEAR + unit = GBP + reference = [ + dict( + title="Universal Credit Regulations 2013 reg. 62(4)(b)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/62", + ), + dict( + title="Social Security Contributions and Benefits Act 1992 ss. 8, 11 and 15", + href="https://www.legislation.gov.uk/ukpga/1992/4/part/I", + ), + ] + + def formula(person, period, parameters): + p = parameters(period) + ni = p.gov.hmrc.national_insurance + threshold = person("uc_minimum_income_floor_gross", period) + # Primary Class 1 on pay equal to the threshold, with the annual + # thresholds (weekly amounts times 52) the model uses for employees. + class_1 = ni.class_1 + primary_threshold = class_1.thresholds.primary_threshold * WEEKS_IN_YEAR + upper_earnings_limit = class_1.thresholds.upper_earnings_limit * WEEKS_IN_YEAR + employee = class_1.rates.employee.main * max_( + 0, min_(threshold, upper_earnings_limit) - primary_threshold + ) + class_1.rates.employee.additional * max_( + 0, threshold - upper_earnings_limit + ) + # Class 2 and Class 4 on profits equal to the threshold. + class_2 = ( + (threshold >= ni.class_2.small_profits_threshold) + * ni.class_2.flat_rate + * WEEKS_IN_YEAR + ) + class_4 = ni.class_4 + class_4_amount = class_4.rates.main * max_( + 0, + min_(threshold, class_4.thresholds.upper_profits_limit) + - class_4.thresholds.lower_profits_limit, + ) + class_4.rates.additional * max_( + 0, threshold - class_4.thresholds.upper_profits_limit + ) + self_employed = class_2 + class_4_amount + mif = p.gov.dwp.universal_credit.means_test.minimum_income_floor + amount = where(mif.self_employed_national_insurance, self_employed, employee) + # No primary Class 1, Class 2 or Class 4 is due from anyone the model + # treats as not liable (under 16 or over State Pension age). + return person("ni_liable", period) * amount diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_income_tax_on_earnings.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_income_tax_on_earnings.py index 630b5320c..1f2cf00ec 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_income_tax_on_earnings.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_income_tax_on_earnings.py @@ -48,7 +48,7 @@ def formula(person, period, parameters): "taxable_self_employment_income", "taxable_miscellaneous_income", ] - # Match the gross earnings in uc_mif_capped_earned_income. + # Match the gross earnings in uc_individual_earned_income_before_mif. bi = p.gov.contrib.ubi_center.basic_income.interactions if bi.include_in_means_tests and bi.include_in_taxable_income: earnings_components.append("basic_income") diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income.py index 7995416bc..60e8211a0 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income.py @@ -6,10 +6,10 @@ class uc_individual_earned_income(Variable): entity = Person label = "Universal Credit earned income of the person" documentation = ( - "The person's earned income for Universal Credit, after the " - "deductions for their own relievable pension contributions and their " - "own income tax and National Insurance in respect of their employment " - "and self-employment, before the work allowance." + "The person's earned income for Universal Credit before the work " + "allowance: their earnings less their own pension contributions, " + "income tax and National Insurance, or the amount the minimum income " + "floor treats them as having where that applies." ) definition_period = YEAR unit = GBP @@ -22,20 +22,43 @@ class uc_individual_earned_income(Variable): title="Universal Credit Regulations 2013 reg. 57(2)", href="https://www.legislation.gov.uk/uksi/2013/376/regulation/57", ), + dict( + title="Universal Credit Regulations 2013 reg. 62(2) and (3)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/62", + ), ] def formula(person, period, parameters): - # The deductions are the person's own and come off only their own - # earnings, so one partner's tax, NI or pension contributions never - # reduce the other partner's earned income. - gross_earnings = person("uc_mif_capped_earned_income", period) - deductions = add( - person, - period, - [ - "pension_contributions", - "uc_income_tax_on_earnings", - "uc_national_insurance_on_earnings", - ], + earned_income = person("uc_individual_earned_income_before_mif", period) + # Reg. 62 compares earned income, after the deductions, with the + # net threshold. A single claimant below their threshold is treated + # as having the threshold (reg. 62(2)). A member of a couple is + # treated as having it only while the couple's combined earned + # income is also below the couple threshold, less any amount by + # which it and the partner's earned income would exceed that + # threshold (reg. 62(3)). The couple threshold is the sum of the + # joint claimants' individual thresholds (reg. 90(3)(a)); for a + # single claimant it is their own, and reg. 62(3) reduces to + # reg. 62(2). Partners' earned income is their actual earned income: + # when both are below their thresholds each is treated as having + # their own, whichever partner's floor is applied first. + claimant = person("is_uc_claimant", period) + claimant_earned_income = earned_income * claimant + partner_earned_income = ( + person.benunit.sum(claimant_earned_income) - claimant_earned_income + ) + individual_threshold = person("uc_minimum_income_floor", period) + couple_threshold = person.benunit.sum(individual_threshold * claimant) + below_individual_threshold = earned_income < individual_threshold + below_couple_threshold = ( + earned_income + partner_earned_income < couple_threshold + ) + floor = individual_threshold - max_( + 0, individual_threshold + partner_earned_income - couple_threshold + ) + floor_applies = ( + person("uc_mif_applies", period) + & below_individual_threshold + & below_couple_threshold ) - return max_(0, gross_earnings - deductions) + return where(floor_applies, floor, earned_income) 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 new file mode 100644 index 000000000..f971c2cd6 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py @@ -0,0 +1,52 @@ +from policyengine_uk.model_api import * + + +class uc_individual_earned_income_before_mif(Variable): + value_type = float + entity = Person + label = ( + "Universal Credit earned income of the person, before the minimum income floor" + ) + documentation = ( + "The person's actual earned income for Universal Credit: their " + "earnings less their own relievable pension contributions and their " + "own income tax and National Insurance in respect of their employment " + "and self-employment. The minimum income floor compares this with " + "the net threshold." + ) + definition_period = YEAR + unit = GBP + reference = [ + dict( + title="Universal Credit Regulations 2013 reg. 55(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", + ), + ] + + def formula(person, period, parameters): + # The deductions are the person's own and come off only their own + # earnings, so one partner's tax, NI or pension contributions never + # reduce the other partner's earned income. + earnings_components = [ + "employment_income", + "self_employment_income", + "miscellaneous_income", + ] + bi = parameters(period).gov.contrib.ubi_center.basic_income + if bi.interactions.include_in_means_tests: + earnings_components.append("basic_income") + earnings = add(person, period, earnings_components) + deductions = add( + person, + period, + [ + "pension_contributions", + "uc_income_tax_on_earnings", + "uc_national_insurance_on_earnings", + ], + ) + return max_(0, earnings - deductions) From ee16391c4a5f89723b263f3a3e4cafd7f0f62d50 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 18:25:43 -0400 Subject: [PATCH 2/4] Deduct self-employed NI from the UC floor threshold, as DWP does; fix review findings - The notional NI now defaults to Class 2 and Class 4 on profits equal to the threshold. DWP's 2017 guidance on applying the floor says so, and the floors DWP has given claimants match it to the penny (1,144.15 a month in 2019-20, 1,556.30 in 2024-25); the parameter switches to primary Class 1. - The threshold keeps its pence: reg 6(1A)(a) rounds amounts calculated for reg 90 itself, and DWP's floors are unrounded. - The threshold uses the minimum wage for the person's age, never the apprenticeship rate (reg 90(2) names NMW Regs reg 4 and 4A(1)(a)-(c)). - A trading loss counts as self-employment for the floor (reg 64, ADM H4503) and is not set against employed earnings (reg 57(2)). - The couple is at most the two eldest claimants, so an adult child flagged as a claimant does not join the couple threshold. Co-Authored-By: Claude Opus 5.5 --- changelog.d/uc-mif-net-floor.fixed.md | 2 +- .../programs/gov/dwp/universal-credit.ipynb | 2 +- .../self_employed_national_insurance.yaml | 8 +- .../income/income_floor/uc_MIF_applies.yaml | 8 ++ .../income_floor/uc_mif_earned_income.yaml | 119 +++++++++++++--- .../income_floor/uc_minimum_income_floor.yaml | 130 ++++++++++-------- ...test_uc_minimum_income_floor_properties.py | 9 +- .../income/income_floor/uc_mif_applies.py | 8 +- .../uc_minimum_income_floor_gross.py | 32 ++--- .../uc_minimum_income_floor_income_tax.py | 8 +- ...minimum_income_floor_national_insurance.py | 6 +- .../income/uc_individual_earned_income.py | 11 +- .../uc_individual_earned_income_before_mif.py | 11 +- 13 files changed, 236 insertions(+), 118 deletions(-) diff --git a/changelog.d/uc-mif-net-floor.fixed.md b/changelog.d/uc-mif-net-floor.fixed.md index 6b17a38a7..ed59a2c07 100644 --- a/changelog.d/uc-mif-net-floor.fixed.md +++ b/changelog.d/uc-mif-net-floor.fixed.md @@ -1 +1 @@ -Apply the Universal Credit minimum income floor as the law sets it (UC Regs 2013 reg. 62): compare each self-employed claimant's earned income after their own tax, NI and pension contributions with the individual threshold converted to a net amount (reg. 62(4)), rather than the gross threshold less the tax on their actual profits; round the threshold down to whole pounds a month (reg. 6(1A)(a)); apply the couple rule of reg. 62(3); and apply the floor only to claimants, not dependants. +Apply the Universal Credit minimum income floor as the law sets it (UC Regs 2013 reg. 62): compare each self-employed claimant's earned income after their own tax, NI and pension contributions with the individual threshold converted to a net amount (reg. 62(4)) by deducting the income tax and Class 2 and Class 4 NI due on it, as DWP does, rather than the gross threshold less the tax on their actual profits; apply the couple rule of reg. 62(3); use the minimum wage for the person's age, never the apprenticeship rate (reg. 90(2)); apply the floor to claimants only, including those with a trading loss; and stop setting a trading loss against employed earnings (reg. 57(2)). diff --git a/docs/book/programs/gov/dwp/universal-credit.ipynb b/docs/book/programs/gov/dwp/universal-credit.ipynb index f57d1c8b8..dd182e4bc 100644 --- a/docs/book/programs/gov/dwp/universal-credit.ipynb +++ b/docs/book/programs/gov/dwp/universal-credit.ipynb @@ -44,7 +44,7 @@ { "cell_type": "markdown", "metadata": {}, - "source": "## How PolicyEngine computes Universal Credit\n\nPolicyEngine computes Universal Credit at the benefit-unit level in three stages:\n\n1. **Maximum entitlement** (`uc_maximum_amount`): sum the elements the benunit qualifies for — `uc_standard_allowance`, `uc_child_element`, `uc_disability_elements`, `uc_carer_element`, `uc_housing_costs_element`, and `uc_childcare_element`.\n2. **Means test**: the maximum entitlement is reduced by the benunit's countable earnings above its work allowance (`uc_work_allowance`) at the published taper rate. Each person's earnings (`uc_individual_earned_income`) are net of their own relievable pension contributions and the income tax and National Insurance they pay on that employment or self-employment (UC Regulations 2013 regs 55(5) and 57(2)): `uc_income_tax_on_earnings` treats earnings as the lowest slice of the person's non-savings income, so tax on pensions, property, savings and dividends is never deducted, and one partner's tax never reduces the other's earnings. A claimant with self-employment income outside a start-up period is subject to the minimum income floor (reg 62): when their earnings after those deductions are below `uc_minimum_income_floor`, they are treated as having that amount instead. The floor is the minimum wage for their age for 35 hours a week, converted to a monthly amount in whole pounds (regs 90(2) and 6(1A)), less the income tax and primary Class 1 National Insurance an employee would pay on it (reg 62(4)(b) leaves this amount to the Secretary of State; the `gov.dwp.universal_credit.means_test.minimum_income_floor.self_employed_national_insurance` parameter switches to Class 2 and Class 4). A member of a couple is lifted only while the couple's combined earnings are below the couple threshold, the sum of both partners' floors, and never above it (reg 62(3)). The combined earnings above the work allowance are then tapered (the `gov.dwp.universal_credit.means_test.reduction_rate` parameter, currently 55%), plus all of its unearned income (`uc_unearned_income`), pound for pound. Unearned income is the list in `gov.dwp.universal_credit.means_test.income_definitions.unearned`: retirement pension income (State Pension and private pensions, UC Regulations 2013 regs 66(1)(a) and 67), Carer's Allowance, contribution-based JSA, savings, dividend and property income, and the tariff income deemed from capital (reg 72), which replaces the actual yield of the capital it is charged on. Asset rules apply on top of this — capital above the lower threshold deems a tariff income, and capital above the upper threshold disqualifies the benunit entirely.\n3. **Benefit cap**: the post-means-test award (`universal_credit_pre_benefit_cap`) is finally reduced by `benefit_cap_reduction` to produce `universal_credit`. The benefit cap only applies to benunits without a benefit-cap exemption (working enough hours, having a qualifying disability benefit, etc.).\n\nThe take-up step is handled by the input variable `would_claim_uc`, which is populated stochastically when the dataset is built so that PolicyEngine's caseload aggregates match published DWP claimant numbers rather than the full eligible population.\n\nParameters live in `policyengine_uk/parameters/gov/dwp/universal_credit/` and the per-element formulas in `policyengine_uk/variables/gov/dwp/universal_credit/`." + "source": "## How PolicyEngine computes Universal Credit\n\nPolicyEngine computes Universal Credit at the benefit-unit level in three stages:\n\n1. **Maximum entitlement** (`uc_maximum_amount`): sum the elements the benunit qualifies for — `uc_standard_allowance`, `uc_child_element`, `uc_disability_elements`, `uc_carer_element`, `uc_housing_costs_element`, and `uc_childcare_element`.\n2. **Means test**: the maximum entitlement is reduced by the benunit's countable earnings above its work allowance (`uc_work_allowance`) at the published taper rate. Each person's earnings (`uc_individual_earned_income`) are net of their own relievable pension contributions and the income tax and National Insurance they pay on that employment or self-employment (UC Regulations 2013 regs 55(5) and 57(2)): `uc_income_tax_on_earnings` treats earnings as the lowest slice of the person's non-savings income, so tax on pensions, property, savings and dividends is never deducted, and one partner's tax never reduces the other's earnings. A claimant with a self-employment profit or loss outside a start-up period is subject to the minimum income floor (reg 62): when their earnings after those deductions are below `uc_minimum_income_floor`, they are treated as having that amount instead. The floor is the minimum wage for their age for 35 hours a week (reg 90(2)), less the income tax and the Class 2 and Class 4 National Insurance a self-employed person would pay on it (reg 62(4)(b) leaves this amount to the Secretary of State; this is the basis of DWP's guidance and figures, and setting the `gov.dwp.universal_credit.means_test.minimum_income_floor.self_employed_national_insurance` parameter to false deducts primary Class 1 instead). A member of a couple is lifted only while the couple's combined earnings are below the couple threshold, the sum of both partners' floors, and never above it (reg 62(3)). The combined earnings above the work allowance are then tapered (the `gov.dwp.universal_credit.means_test.reduction_rate` parameter, currently 55%), plus all of its unearned income (`uc_unearned_income`), pound for pound. Unearned income is the list in `gov.dwp.universal_credit.means_test.income_definitions.unearned`: retirement pension income (State Pension and private pensions, UC Regulations 2013 regs 66(1)(a) and 67), Carer's Allowance, contribution-based JSA, savings, dividend and property income, and the tariff income deemed from capital (reg 72), which replaces the actual yield of the capital it is charged on. Asset rules apply on top of this — capital above the lower threshold deems a tariff income, and capital above the upper threshold disqualifies the benunit entirely.\n3. **Benefit cap**: the post-means-test award (`universal_credit_pre_benefit_cap`) is finally reduced by `benefit_cap_reduction` to produce `universal_credit`. The benefit cap only applies to benunits without a benefit-cap exemption (working enough hours, having a qualifying disability benefit, etc.).\n\nThe take-up step is handled by the input variable `would_claim_uc`, which is populated stochastically when the dataset is built so that PolicyEngine's caseload aggregates match published DWP claimant numbers rather than the full eligible population.\n\nParameters live in `policyengine_uk/parameters/gov/dwp/universal_credit/` and the per-element formulas in `policyengine_uk/variables/gov/dwp/universal_credit/`." }, { "cell_type": "code", diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/minimum_income_floor/self_employed_national_insurance.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/minimum_income_floor/self_employed_national_insurance.yaml index bc8cb8c7a..50b0aaebc 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/minimum_income_floor/self_employed_national_insurance.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/minimum_income_floor/self_employed_national_insurance.yaml @@ -1,11 +1,15 @@ -description: Whether the amount for National Insurance that converts the Universal Credit minimum income floor to a net amount is the Class 2 and Class 4 contributions due on self-employed profits equal to the threshold, rather than the primary Class 1 contributions due on pay equal to it. The regulations leave the amount to the Secretary of State. DWP describes the floor as what an employed person would earn at the minimum wage after tax and National Insurance, so the model uses Class 1; DWP's 2017 guidance on applying the floor deducted Class 2 and Class 4 instead. +description: Whether the amount for National Insurance that converts the Universal Credit minimum income floor to a net amount is the Class 2 and Class 4 contributions due on self-employed profits equal to the threshold, rather than the primary Class 1 contributions due on pay equal to it. The regulations leave the amount to the Secretary of State. DWP's 2017 guidance on applying the floor deducts Class 2 and Class 4, and the floors DWP has worked through or given claimants match that basis (the ADM example of 946 in 2015-16, 1,144.15 a month in 2019-20 and 1,556.30 in 2024-25); DWP's quick guide describes the floor as what an employed person would earn after tax and National Insurance, which false models instead. values: - 2013-04-29: false + 2013-04-29: true metadata: unit: bool label: Universal Credit minimum income floor self-employed National Insurance reference: - title: The Universal Credit Regulations 2013 reg. 62(4)(b) href: https://www.legislation.gov.uk/uksi/2013/376/regulation/62 + - title: DWP, Applying the Minimum Income Floor, version 9 (3 April 2017, deposited paper DEP2017-0556) + href: https://data.parliament.uk/DepositedPapers/Files/DEP2017-0556/6_Applying_the_minimum_income_floor_V9.0.pdf + - title: DWP, Advice for Decision Making, chapter H4, H4079 + href: https://www.gov.uk/government/publications/advice-for-decision-making-staff-guide - title: "DWP, Universal Credit and self-employment: quick guide" href: https://www.gov.uk/government/publications/universal-credit-for-the-self-employed/universal-credit-and-self-employment-quick-guide diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_MIF_applies.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_MIF_applies.yaml index ec879a24e..3ed15e32c 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_MIF_applies.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_MIF_applies.yaml @@ -32,3 +32,11 @@ uc_is_in_startup_period: true output: uc_mif_applies: false + +- name: Self-employment loss (MIF applies) + period: 2026 + absolute_error_margin: 0 + input: + self_employment_income: -1 + output: + uc_mif_applies: true diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_mif_earned_income.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_mif_earned_income.yaml index d4b348b26..5fe4881cb 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_mif_earned_income.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_mif_earned_income.yaml @@ -9,9 +9,11 @@ # by which that amount of earned income combined with their partner's # earned income would exceed the couple threshold" (reg. 62(3)). # -# Hand calculations use 2026-27 rates: net floor 20,168.88 for a claimant -# aged 21 or over in England (23,124 gross, see uc_minimum_income_floor.yaml); personal allowance 12,570, basic rate 20%; trading allowance 1,000; -# Class 4 NI 6% above 12,570; primary Class 1 NI 8% above 12,569.96; UC +# Hand calculations use 2026-27 rates: net floor 20,386.03 for a claimant +# aged 21 or over in England (23,132.20 gross, see +# uc_minimum_income_floor.yaml); personal allowance 12,570, basic rate 20%; +# trading allowance 1,000; Class 4 NI 6% above 12,570; primary Class 1 NI 8% +# above 12,569.96; UC # standard allowance 424.90 a month (single, 25 or over), 666.97 (couple), # child element 303.94, work allowance 427 a month with housing costs; taper # 55%. Marriage Allowance is switched off to keep the tax simple. @@ -35,14 +37,37 @@ # Taxable profit 4,000 and profits of 5,000 are below the allowance and # the lower profits limit. uc_individual_earned_income_before_mif: 5_000 - uc_individual_earned_income: 20_168.88 - uc_earned_income: 20_168.88 + uc_individual_earned_income: 20_386.03 + uc_earned_income: 20_386.03 # 424.90 x 12 + 9,600. uc_maximum_amount: 14_698.80 - # 14,698.80 - 0.55 x 20,168.88. Treating the claimant as having the - # gross threshold of 12.71 x 35 x 52 = 23,132.20, as before this fix, - # gave 1,976.09. - universal_credit: 3_605.92 + # 14,698.80 - 0.55 x 20,386.03. Treating the claimant as having the + # gross threshold of 23,132.20, as before this fix, gave 1,976.09. + universal_credit: 3_486.48 + +- name: Higher profits under the floor do not change UC + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + self_employment_income: 20_000 + households: + household: + members: [person] + region: NORTH_EAST + tenure_type: RENT_FROM_COUNCIL + rent: 9_600 + output: + # 20,000 - (19,000 - 12,570) x 20% - (20,000 - 12,570) x 6% = 18,268.20, + # below the floor, so the same UC as with profits of 5,000. Before this + # fix the claimant was treated as having 23,132.20 - 1,286 - 445.80 = + # 21,400.40, and UC was 2,928.58 against 1,976.09 at 5,000 and 2,213.58 + # at 15,000: UC rose with profits. + uc_individual_earned_income_before_mif: 18_268.20 + uc_individual_earned_income: 20_386.03 + universal_credit: 3_486.48 - name: Floor applies when earnings are below the net floor, even above the gross threshold period: 2026 @@ -62,7 +87,7 @@ # pension contributions too, so this is below the net floor. Before this # fix the gross 24,000 was above the gross threshold, so no floor. uc_individual_earned_income_before_mif: 18_828.20 - uc_individual_earned_income: 20_168.88 + uc_individual_earned_income: 20_386.03 - name: Floor does not apply when net earnings are above the net floor, even below the gross threshold period: 2026 @@ -76,7 +101,7 @@ uc_income_tax_on_earnings: 1_886 # (23,000 - 12,570) x 6%. uc_national_insurance_on_earnings: 625.80 - # 23,000 - 1,886 - 625.80 is above 20,168.88, so the actual amount + # 23,000 - 1,886 - 625.80 is above 20,386.03, so the actual amount # counts. Before this fix the gross 23,000 was below the gross threshold # and the claimant was treated as having 23,132.20 - 1,886 - 625.80 # = 20,620.40. @@ -94,7 +119,7 @@ # 22,000 - (21,000 - 12,570) x 20% - (22,000 - 12,570) x 6%. uc_individual_earned_income_before_mif: 19_748.20 # Before this fix: 23,132.20 - 1,686 - 565.80 = 20,880.40. - uc_individual_earned_income: 20_168.88 + uc_individual_earned_income: 20_386.03 - name: Floor does not apply in a start-up period period: 2026 @@ -235,15 +260,17 @@ # Employee: 25,000 - (25,000 - 12,570) x 20% - (25,000 - 12,569.96) x 8% # = 25,000 - 2,486 - 994.40. uc_individual_earned_income_before_mif: [6_000, 21_519.60, 0, 0] - # 20,168.88 - (20,168.88 + 21,519.60 - 40,337.75) = 18,818.15, so the - # couple has 40,337.75, the couple threshold. - uc_individual_earned_income: [18_818.15, 21_519.60, 0, 0] + # The couple threshold is 2 x 20,386.028 = 40,772.056. The employee is + # 1,133.57 over their own floor, so the self-employed partner is treated + # as having 20,386.03 - 1,133.57 = 40,772.056 - 21,519.597 = 19,252.46, + # and the couple has the couple threshold. + uc_individual_earned_income: [19_252.46, 21_519.60, 0, 0] # Less the work allowance of 427 x 12 = 5,124. - uc_earned_income: 35_213.75 + uc_earned_income: 35_648.06 # 666.97 x 12 + 2 x 303.94 x 12 + 12,000. uc_maximum_amount: 27_298.20 - # 27,298.20 - 0.55 x 35,213.75. - universal_credit: 7_930.64 + # 27,298.20 - 0.55 x 35,648.06. + universal_credit: 7_691.77 - name: Floor does not apply to a dependent child with self-employment income period: 2026 @@ -257,3 +284,59 @@ self_employment_income: 500 output: uc_mif_applies: [false, false] + +- name: A trading loss counts as self-employment, and the claimant gets the floor + period: 2026 + absolute_error_margin: 0.01 + input: + age: 30 + region: NORTH_EAST + self_employment_income: -3_000 + output: + # Gainful self-employment is a trade carried on in expectation of profit + # (reg. 64); a loss makes self-employed earnings nil (reg. 57(2)), and the + # floor then applies (ADM H4503). + uc_mif_applies: true + uc_individual_earned_income_before_mif: 0 + uc_individual_earned_income: 20_386.03 + +- name: A trading loss is not set against employed earnings + period: 2026 + absolute_error_margin: 0.01 + input: + age: 30 + region: NORTH_EAST + employment_income: 30_000 + self_employment_income: -3_000 + output: + # 30,000 - (30,000 - 12,570) x 20% - (30,000 - 12,569.96) x 8%, the same + # as with no loss, and above the floor. + uc_individual_earned_income_before_mif: 25_119.60 + uc_individual_earned_income: 25_119.60 + +- name: A third adult flagged as a claimant does not join the couple threshold + period: 2026 + absolute_error_margin: 0.01 + input: + people: + self_employed: + age: 40 + self_employment_income: 1_000 + would_claim_marriage_allowance: false + employee: + age: 40 + employment_income: 35_000 + would_claim_marriage_allowance: false + adult_child: + age: 25 + would_claim_marriage_allowance: false + households: + household: + members: [self_employed, employee, adult_child] + region: NORTH_EAST + output: + # A claim has at most two claimants, so the couple is the two eldest. + # Employee: 35,000 - (35,000 - 12,570) x 20% - (35,000 - 12,569.96) x 8% + # = 28,719.60. Floor: 40,772.056 - 28,719.597 = 12,052.46. Adding the + # adult child's floor to the couple threshold would give 20,386.03. + uc_individual_earned_income: [12_052.46, 28_719.60, 0] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_minimum_income_floor.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_minimum_income_floor.yaml index ef0b0e2fe..bdb1beb5e 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_minimum_income_floor.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_minimum_income_floor.yaml @@ -1,39 +1,42 @@ # The minimum income floor is the individual threshold of UC Regs 2013 -# reg. 90(2), the National Minimum Wage rate for the person's age times their -# expected hours (35 a week, reg. 88), times 52 / 12 and rounded down to a -# whole pound (reg. 6(1A)(a)), converted to a net amount by deducting "such -# amount for income tax and national insurance contributions as the Secretary -# of State considers appropriate" (reg. 62(4)(b)). The model deducts the -# income tax and primary Class 1 NI the person would pay if the threshold were -# their only income, from employment. Annual amounts are 12 monthly amounts. +# reg. 90(2), the National Minimum Wage rate for the person's age (never the +# apprenticeship rate) times their expected hours (35 a week, reg. 88), +# converted to a net amount by deducting "such amount for income tax and +# national insurance contributions as the Secretary of State considers +# appropriate" (reg. 62(4)(b)). The model deducts the income tax, and the +# Class 2 and Class 4 NI, the person would pay if the threshold were their +# only income, from self-employment: the basis of DWP's guidance and of the +# floors DWP has given claimants (1,556.30 a month in 2024-25). # # Hand calculations use the rates the model holds for each year: # - 2026: National Living Wage 12.71 an hour (21 and over), 10.85 (18 to 20); # personal allowance 12,570; rest-of-UK basic rate 20%; Scottish starter # rate 19% on the first 3,967 of taxable income, then basic rate 20% up to -# 16,956; primary Class 1 NI 8% above the primary threshold of 241.73 a -# week (12,569.96 a year); +# 16,956; Class 2 nil; Class 4 6% above the lower profits limit of 12,570; # - 2020: National Living Wage 8.72 an hour (25 and over); personal -# allowance 12,500; basic rate 20%; primary Class 1 NI 12% above 183 a -# week (9,516 a year). +# allowance 12,500; basic rate 20%; Class 2 3.05 a week; Class 4 9% above +# 9,500. -- name: Individual threshold is the minimum wage for 35 hours a week, in whole pounds a month - period: 2020 - absolute_error_margin: 0 +- name: Individual threshold is the minimum wage for the person's age for 35 hours a week + period: 2026 + absolute_error_margin: 0.01 input: - minimum_wage: 10 + age: 30 output: - # 10 x 35 x 52 / 12 = 1,516.67, rounded down to 1,516, times 12. - uc_minimum_income_floor_gross: 18_192 + # 12.71 x 35 x 52, or 1,927.68 a month (12.71 x 35 x 52 / 12). + uc_minimum_income_floor_gross: 23_132.20 -- name: A whole-pound monthly threshold is not rounded down - period: 2020 - absolute_error_margin: 0 +- name: An apprentice's threshold uses the rate for their age, not the apprenticeship rate + period: 2026 + absolute_error_margin: 0.01 input: - minimum_wage: 12 + age: 30 + is_apprentice: true output: - # 12 x 35 x 52 / 12 = 1,820 exactly. - uc_minimum_income_floor_gross: 21_840 + # Reg. 90(2) names NMW Regs reg. 4 and 4A(1)(a) to (c); the + # apprenticeship rate of 8.00 (reg. 4A(1)(d)) would give 14,560. + minimum_wage: 8 + uc_minimum_income_floor_gross: 23_132.20 - name: Net floor for a claimant aged 30 in England, 2026 period: 2026 @@ -42,14 +45,13 @@ age: 30 region: NORTH_EAST output: - # 12.71 x 35 x 52 / 12 = 1,927.68, rounded down to 1,927 a month. - uc_minimum_income_floor_gross: 23_124 - # (23,124 - 12,570) x 20%. - uc_minimum_income_floor_income_tax: 2_110.80 - # (23,124 - 12,569.96) x 8%. - uc_minimum_income_floor_national_insurance: 844.32 - # 23,124 - 2,110.80 - 844.32, or 1,680.74 a month. - uc_minimum_income_floor: 20_168.88 + uc_minimum_income_floor_gross: 23_132.20 + # (23,132.20 - 12,570) x 20%. + uc_minimum_income_floor_income_tax: 2_112.44 + # Class 4: (23,132.20 - 12,570) x 6%. + uc_minimum_income_floor_national_insurance: 633.73 + # 23,132.20 - 2,112.44 - 633.73, or 1,698.84 a month. + uc_minimum_income_floor: 20_386.03 - name: Net floor for a Scottish taxpayer uses Scottish income tax rates period: 2026 @@ -58,13 +60,13 @@ age: 30 region: SCOTLAND output: - uc_minimum_income_floor_gross: 23_124 - # Taxable 10,554: 3,967 x 19% + (10,554 - 3,967) x 20% - # = 753.73 + 1,317.40. - uc_minimum_income_floor_income_tax: 2_071.13 - uc_minimum_income_floor_national_insurance: 844.32 - # 23,124 - 2,071.13 - 844.32. - uc_minimum_income_floor: 20_208.55 + uc_minimum_income_floor_gross: 23_132.20 + # Taxable 10,562.20: 3,967 x 19% + (10,562.20 - 3,967) x 20% + # = 753.73 + 1,319.04. + uc_minimum_income_floor_income_tax: 2_072.77 + uc_minimum_income_floor_national_insurance: 633.73 + # 23,132.20 - 2,072.77 - 633.73. + uc_minimum_income_floor: 20_425.70 - name: Net floor for a claimant aged 19 uses the 18 to 20 minimum wage period: 2026 @@ -73,38 +75,50 @@ age: 19 region: NORTH_EAST output: - # 10.85 x 35 x 52 / 12 = 1,645.58, rounded down to 1,645 a month. - uc_minimum_income_floor_gross: 19_740 - # (19,740 - 12,570) x 20%. - uc_minimum_income_floor_income_tax: 1_434 - # (19,740 - 12,569.96) x 8%. - uc_minimum_income_floor_national_insurance: 573.60 - uc_minimum_income_floor: 17_732.40 + # 10.85 x 35 x 52. + uc_minimum_income_floor_gross: 19_747 + # (19,747 - 12,570) x 20%. + uc_minimum_income_floor_income_tax: 1_435.40 + # (19,747 - 12,570) x 6%. + uc_minimum_income_floor_national_insurance: 430.62 + uc_minimum_income_floor: 17_880.98 -- name: No Class 1 NI is deducted for a person over State Pension age +- name: No NI is deducted for a person over State Pension age period: 2026 absolute_error_margin: 0.01 input: age: 70 region: NORTH_EAST output: - uc_minimum_income_floor_gross: 23_124 - uc_minimum_income_floor_income_tax: 2_110.80 - # Primary Class 1 is not payable over pensionable age (SSCBA 1992 s. 6(3)). + uc_minimum_income_floor_gross: 23_132.20 + uc_minimum_income_floor_income_tax: 2_112.44 + # Class 4 is not due from anyone over pension age at the start of the + # tax year (SI 2001/1004 reg. 91(a)). uc_minimum_income_floor_national_insurance: 0 - uc_minimum_income_floor: 21_013.20 + uc_minimum_income_floor: 21_019.76 -- name: Net floor in 2020 uses that year's rates +- name: Net floor in 2020 deducts Class 2 as well as Class 4 period: 2020 absolute_error_margin: 0.01 input: age: 30 region: NORTH_EAST output: - # 8.72 x 35 x 52 / 12 = 1,322.53, rounded down to 1,322 a month. - uc_minimum_income_floor_gross: 15_864 - # (15,864 - 12,500) x 20%. - uc_minimum_income_floor_income_tax: 672.80 - # (15,864 - 9,516) x 12%. - uc_minimum_income_floor_national_insurance: 761.76 - uc_minimum_income_floor: 14_429.44 + # 8.72 x 35 x 52. + uc_minimum_income_floor_gross: 15_870.40 + # (15,870.40 - 12,500) x 20%. + uc_minimum_income_floor_income_tax: 674.08 + # Class 2: 3.05 x 52 = 158.60, as the profits are above the small + # profits threshold. Class 4: (15,870.40 - 9,500) x 9% = 573.34. + uc_minimum_income_floor_national_insurance: 731.94 + uc_minimum_income_floor: 14_464.38 + +- name: The 2016 threshold uses that year's National Living Wage + period: 2016 + absolute_error_margin: 0.01 + input: + age: 30 + region: NORTH_EAST + output: + # 7.20 x 35 x 52. + uc_minimum_income_floor_gross: 13_104 diff --git a/policyengine_uk/tests/test_uc_minimum_income_floor_properties.py b/policyengine_uk/tests/test_uc_minimum_income_floor_properties.py index e53149b0c..b4569ccc3 100644 --- a/policyengine_uk/tests/test_uc_minimum_income_floor_properties.py +++ b/policyengine_uk/tests/test_uc_minimum_income_floor_properties.py @@ -28,10 +28,11 @@ regulation. 5. Differential against the tax engine: the notional income tax deducted from the threshold equals the income tax the same person would pay with - the gross threshold as their only income, from employment. The notional - NI equals the primary Class 1 they would pay on it as pay or, under the - self-employed basis, the Class 2 and Class 4 they would pay on it as - profits. So the net floor never exceeds the gross threshold. + the gross threshold as their only income, as pay (no trading + allowance). The notional NI equals the Class 2 and Class 4 they would + pay on it as profits (the default basis) or, with the parameter + switched, the primary Class 1 they would pay on it as pay. So the net + floor never exceeds the gross threshold. 6. Monotone: more earnings never lower a benefit unit's earned income, and never raise UC before the benefit cap. Before this fix, a self-employed claimant under the floor was treated as having the gross threshold less diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_mif_applies.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_mif_applies.py index 5214792b6..d4ddb8a5c 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_mif_applies.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_mif_applies.py @@ -7,14 +7,16 @@ class uc_mif_applies(Variable): label = "Universal Credit minimum income floor applies" documentation = ( "Whether the minimum income floor applies to this person: a claimant " - "with self-employment income, outside a start-up period." + "with a self-employment profit or loss, outside a start-up period." ) reference = "https://www.legislation.gov.uk/uksi/2013/376/regulation/62" definition_period = YEAR def formula(person, period, parameters): - # Reg. 62(1) applies to a claimant, not a dependant. + # Reg. 62(1) applies to a claimant, not a dependant, in gainful + # self-employment: a trade carried on in expectation of profit (reg. + # 64), so a trading loss counts (ADM H4503). claimant = person("is_uc_claimant", period) - has_self_empl_income = person("self_employment_income", period) > 0 + has_self_empl_income = person("self_employment_income", period) != 0 in_startup_period = person("uc_is_in_startup_period", period) return claimant & has_self_empl_income & ~in_startup_period diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_minimum_income_floor_gross.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_minimum_income_floor_gross.py index a0a0bdbc9..9947fd42b 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_minimum_income_floor_gross.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_minimum_income_floor_gross.py @@ -8,8 +8,8 @@ class uc_minimum_income_floor_gross(Variable): documentation = ( "The person's individual threshold before the deductions for income " "tax and National Insurance: the National Minimum Wage hourly rate " - "for their age times their expected hours of work each week, " - "converted to a monthly amount in whole pounds, over a year." + "for their age (never the apprenticeship rate) times their expected " + "hours of work each week, over a year." ) definition_period = YEAR unit = GBP @@ -23,22 +23,22 @@ class uc_minimum_income_floor_gross(Variable): href="https://www.legislation.gov.uk/uksi/2013/376/regulation/88", ), dict( - title="Universal Credit Regulations 2013 reg. 6(1A)(a)", - href="https://www.legislation.gov.uk/uksi/2013/376/regulation/6", + title="National Minimum Wage Regulations 2015 regs. 4 and 4A", + href="https://www.legislation.gov.uk/uksi/2015/621/regulation/4A", ), ] def formula(person, period, parameters): - expected_hours = parameters( - period - ).gov.dwp.universal_credit.work_requirements.default_expected_hours - hourly_rate = person("minimum_wage", period).astype(np.float64) - # Reg. 90(2) converts the weekly amount "to a monthly amount by - # multiplying by 52 and dividing by 12", and reg. 6(1A)(a) - # disregards any fraction of a pound in a reg. 90 threshold (ADM - # H4079: 234.50 x 52 / 12 = 1,016). Rounding to a millionth first - # keeps a whole-pound result whole in floating point. - monthly = np.floor( - np.round(hourly_rate * expected_hours * WEEKS_IN_YEAR / MONTHS_IN_YEAR, 6) + p = parameters(period) + expected_hours = ( + p.gov.dwp.universal_credit.work_requirements.default_expected_hours ) - return monthly * MONTHS_IN_YEAR + # Reg. 90(2) uses the rate "a person of the same age as the claimant + # would be paid" under NMW Regs reg. 4 or 4A(1)(a) to (c): the rate + # for their age, never the apprenticeship rate of reg. 4A(1)(d). + hourly_rate = p.gov.hmrc.minimum_wage.non_apprentice.calc(person("age", period)) + # Reg. 6(1A)(a) disregards fractions of a pound only in amounts + # calculated for reg. 90 itself. The floors DWP gives claimants keep + # the pence (for example 1,556.30 a month in 2024-25, from 11.44 x 35 + # x 52 / 12 = 1,735.07), so the threshold here is not rounded. + return hourly_rate * expected_hours * WEEKS_IN_YEAR diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_minimum_income_floor_income_tax.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_minimum_income_floor_income_tax.py index e50c9536f..c570e6eb1 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_minimum_income_floor_income_tax.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_minimum_income_floor_income_tax.py @@ -10,8 +10,8 @@ class uc_minimum_income_floor_income_tax(Variable): "individual threshold to give their net minimum income floor. The " "regulations leave it to the Secretary of State; the model takes the " "income tax the person would pay if the threshold were their only " - "income, from employment: the threshold less the standard personal " - "allowance, at the person's own rest-of-UK or Scottish rates." + "income: the threshold less the standard personal allowance, at the " + "person's own rest-of-UK or Scottish rates." ) definition_period = YEAR unit = GBP @@ -29,8 +29,8 @@ class uc_minimum_income_floor_income_tax(Variable): def formula(person, period, parameters): # Reg. 62(4)(b) deducts "such amount for income tax ... as the # Secretary of State considers appropriate". The model treats the - # threshold as stand-alone employment income: only the standard - # personal allowance applies, and no other income or relief. + # threshold as the person's only income: only the standard personal + # allowance applies, and no other allowance or relief. income_tax = parameters(period).gov.hmrc.income_tax threshold = person("uc_minimum_income_floor_gross", period) taxable = max_(0, threshold - income_tax.allowances.personal_allowance.amount) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_minimum_income_floor_national_insurance.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_minimum_income_floor_national_insurance.py index 1d7de80b8..4b3f4bff0 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_minimum_income_floor_national_insurance.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_minimum_income_floor_national_insurance.py @@ -10,9 +10,9 @@ class uc_minimum_income_floor_national_insurance(Variable): "individual threshold to give their net minimum income floor. The " "regulations leave it to the Secretary of State. The model takes the " "contributions the person would pay if the threshold were their only " - "earnings: primary Class 1 on pay of that amount, or, where the " - "parameter selects self-employed contributions, Class 2 and Class 4 " - "on profits of that amount." + "earnings: Class 2 and Class 4 on self-employed profits of that " + "amount, as in DWP's figures, or primary Class 1 on pay of that " + "amount where the parameter selects it." ) definition_period = YEAR unit = GBP diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income.py index 60e8211a0..ba90cccf7 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income.py @@ -41,8 +41,14 @@ def formula(person, period, parameters): # single claimant it is their own, and reg. 62(3) reduces to # reg. 62(2). Partners' earned income is their actual earned income: # when both are below their thresholds each is treated as having - # their own, whichever partner's floor is applied first. - claimant = person("is_uc_claimant", period) + # their own, whichever partner's floor is applied first. A claim has + # at most two claimants; where the data flag more (an adult child in + # the parents' benefit unit), the two eldest are the couple. + age = person("age", period) + is_claimant = person("is_uc_claimant", period) + claimant = is_claimant & ( + person.get_rank(person.benunit, -age, condition=is_claimant) < 2 + ) claimant_earned_income = earned_income * claimant partner_earned_income = ( person.benunit.sum(claimant_earned_income) - claimant_earned_income @@ -58,6 +64,7 @@ def formula(person, period, parameters): ) floor_applies = ( person("uc_mif_applies", period) + & claimant & below_individual_threshold & below_couple_threshold ) 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 f971c2cd6..c7ed38c13 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 @@ -31,15 +31,14 @@ def formula(person, period, parameters): # The deductions are the person's own and come off only their own # earnings, so one partner's tax, NI or pension contributions never # reduce the other partner's earned income. - earnings_components = [ - "employment_income", - "self_employment_income", - "miscellaneous_income", - ] + earnings_components = ["employment_income", "miscellaneous_income"] bi = parameters(period).gov.contrib.ubi_center.basic_income if bi.interactions.include_in_means_tests: earnings_components.append("basic_income") - earnings = add(person, period, earnings_components) + # A trading loss makes self-employed earnings nil (reg. 57(2)); it + # is not set against employed earnings. + self_employed = max_(0, person("self_employment_income", period)) + earnings = add(person, period, earnings_components) + self_employed deductions = add( person, period, From baff2d793284a43f23557347242af16bf091539a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 22:10:22 -0400 Subject: [PATCH 3/4] Cite the DWP-supplied 2025-26 floor; test losses and the break-even edge - The no-rounding evidence is the net floor of 1,642.72 a month that DWP supplied for 2025-26 on a threshold of 1,851.85 (University of Bath IPR, "Going it alone", 2025), and the floor set on a claimant's award at 1,144.15 a month in 2019-20. The 1,556.30 figure for 2024-25 came from a forum user, not DWP, so it no longer appears as DWP's. - Property tests now draw trading losses. Monotonicity excludes the one intended exception, a loss raised to exactly zero, which the model reads as no self-employment; a YAML test pins that edge. Co-Authored-By: Claude Opus 5.5 --- .../self_employed_national_insurance.yaml | 14 ++++++++++++- .../income_floor/uc_mif_earned_income.yaml | 16 +++++++++++++++ .../income_floor/uc_minimum_income_floor.yaml | 3 ++- ...test_uc_minimum_income_floor_properties.py | 20 +++++++++++++------ .../uc_minimum_income_floor_gross.py | 8 +++++--- 5 files changed, 50 insertions(+), 11 deletions(-) diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/minimum_income_floor/self_employed_national_insurance.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/minimum_income_floor/self_employed_national_insurance.yaml index 50b0aaebc..1a1093af6 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/minimum_income_floor/self_employed_national_insurance.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/minimum_income_floor/self_employed_national_insurance.yaml @@ -1,4 +1,14 @@ -description: Whether the amount for National Insurance that converts the Universal Credit minimum income floor to a net amount is the Class 2 and Class 4 contributions due on self-employed profits equal to the threshold, rather than the primary Class 1 contributions due on pay equal to it. The regulations leave the amount to the Secretary of State. DWP's 2017 guidance on applying the floor deducts Class 2 and Class 4, and the floors DWP has worked through or given claimants match that basis (the ADM example of 946 in 2015-16, 1,144.15 a month in 2019-20 and 1,556.30 in 2024-25); DWP's quick guide describes the floor as what an employed person would earn after tax and National Insurance, which false models instead. +description: >- + Whether the amount for National Insurance that converts the Universal Credit + minimum income floor to a net amount is the Class 2 and Class 4 contributions + due on self-employed profits equal to the threshold, rather than the primary + Class 1 contributions due on pay equal to it. The regulations leave the amount + to the Secretary of State. DWP's 2017 guidance on applying the floor deducts + Class 2 and Class 4, and DWP's own figures match that basis (the ADM example of + 946 a month in 2015-16, to the pound; a floor set on a claimant's award at + 1,144.15 a month in 2019-20; and 1,642.72 a month that DWP supplied for + 2025-26). DWP's quick guide describes the floor as what an employed person + would earn after tax and National Insurance, which false models instead. values: 2013-04-29: true metadata: @@ -11,5 +21,7 @@ metadata: href: https://data.parliament.uk/DepositedPapers/Files/DEP2017-0556/6_Applying_the_minimum_income_floor_V9.0.pdf - title: DWP, Advice for Decision Making, chapter H4, H4079 href: https://www.gov.uk/government/publications/advice-for-decision-making-staff-guide + - title: "Griffiths and Wood, Going it alone: experiences of self-employed Universal Credit claimants, University of Bath IPR, July 2025, note 3 (a net floor of 1,642.72 supplied by DWP)" + href: https://www.bath.ac.uk/publications/going-it-alone-experiences-of-self-employed-universal-credit-claimants/attachments/going-it-alone-report.pdf - title: "DWP, Universal Credit and self-employment: quick guide" href: https://www.gov.uk/government/publications/universal-credit-for-the-self-employed/universal-credit-and-self-employment-quick-guide diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_mif_earned_income.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_mif_earned_income.yaml index 5fe4881cb..61a86ddfc 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_mif_earned_income.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_mif_earned_income.yaml @@ -340,3 +340,19 @@ # = 28,719.60. Floor: 40,772.056 - 28,719.597 = 12,052.46. Adding the # adult child's floor to the couple threshold would give 20,386.03. uc_individual_earned_income: [12_052.46, 28_719.60, 0] + +- name: Break-even reads as no self-employment, so the floor stops at exactly zero profit + period: 2026 + absolute_error_margin: 0.01 + input: + age: 30 + region: NORTH_EAST + self_employment_income: 0 + output: + # The model has no self-employment status input, so zero profit is no + # self-employment. In law a break-even trader in gainful self-employment + # gets the floor like a loss-maker (reg. 57(2)); the Enhanced FRS has no + # negative profits, so only hand-built households see the difference + # from the -3,000 case above. + uc_mif_applies: false + uc_individual_earned_income: 0 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_minimum_income_floor.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_minimum_income_floor.yaml index bdb1beb5e..827cb3912 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_minimum_income_floor.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_minimum_income_floor.yaml @@ -6,7 +6,8 @@ # appropriate" (reg. 62(4)(b)). The model deducts the income tax, and the # Class 2 and Class 4 NI, the person would pay if the threshold were their # only income, from self-employment: the basis of DWP's guidance and of the -# floors DWP has given claimants (1,556.30 a month in 2024-25). +# floor DWP supplied for 2025-26 (1,642.72 a month on a threshold of +# 1,851.85). # # Hand calculations use the rates the model holds for each year: # - 2026: National Living Wage 12.71 an hour (21 and over), 10.85 (18 to 20); diff --git a/policyengine_uk/tests/test_uc_minimum_income_floor_properties.py b/policyengine_uk/tests/test_uc_minimum_income_floor_properties.py index b4569ccc3..6c36ba094 100644 --- a/policyengine_uk/tests/test_uc_minimum_income_floor_properties.py +++ b/policyengine_uk/tests/test_uc_minimum_income_floor_properties.py @@ -34,9 +34,13 @@ switched, the primary Class 1 they would pay on it as pay. So the net floor never exceeds the gross threshold. 6. Monotone: more earnings never lower a benefit unit's earned income, and - never raise UC before the benefit cap. Before this fix, a self-employed - claimant under the floor was treated as having the gross threshold less - the tax on their actual profits, so UC rose with their profits. + never raise UC before the benefit cap, with one intended exception: the + model reads self-employment income of exactly zero as no + self-employment, so a trading loss (which gets the floor, reg. 57(2) and + ADM H4503) raised to exactly break-even loses it. Before this fix, a + self-employed claimant under the floor was treated as having the gross + threshold less the tax on their actual profits, so UC rose with their + profits. Marriage Allowance is switched off throughout: a transfer can lower one partner's earned income when the other's earnings rise, which is a separate, @@ -44,7 +48,7 @@ """ import numpy as np -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, assume, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation @@ -68,7 +72,8 @@ "mixed_age": [PENSION_AGE, WORKING_AGE], } # Profits and pay straddle the floor (about 16,000-23,000 gross). -self_employment = st.one_of(st.just(0.0), st.floats(0, 40_000)) +# Profits and losses: a trading loss is nil self-employed earnings. +self_employment = st.one_of(st.just(0.0), st.floats(-10_000, 40_000)) employment = st.one_of(st.just(0.0), st.floats(0, 40_000)) pension = st.one_of(st.just(0.0), st.floats(0, 4_000)) bumps = st.floats(1, 10_000) @@ -334,7 +339,10 @@ def bumped(draw): i = draw(st.integers(0, len(units) - 1)) j = draw(st.integers(0, len(units[i]["adults"]) - 1)) variable = draw(st.sampled_from(["self_employment_income", "employment_income"])) - return units, (i, j, variable, draw(bumps)) + amount = draw(bumps) + # The intended exception to monotonicity: a loss raised to exactly zero. + assume(units[i]["adults"][j][variable] + amount != 0) + return units, (i, j, variable, amount) @PROPERTY_SETTINGS diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_minimum_income_floor_gross.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_minimum_income_floor_gross.py index 9947fd42b..4786b7920 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_minimum_income_floor_gross.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_minimum_income_floor_gross.py @@ -38,7 +38,9 @@ def formula(person, period, parameters): # for their age, never the apprenticeship rate of reg. 4A(1)(d). hourly_rate = p.gov.hmrc.minimum_wage.non_apprentice.calc(person("age", period)) # Reg. 6(1A)(a) disregards fractions of a pound only in amounts - # calculated for reg. 90 itself. The floors DWP gives claimants keep - # the pence (for example 1,556.30 a month in 2024-25, from 11.44 x 35 - # x 52 / 12 = 1,735.07), so the threshold here is not rounded. + # calculated for reg. 90 itself. Floors DWP has issued keep the pence: + # 1,642.72 a month for 2025-26 from a threshold of 1,851.85 (12.21 x + # 35 x 52 / 12; University of Bath IPR, "Going it alone", 2025, + # "information supplied by the DWP"), where a whole-pound threshold + # would give 1,642.09. So the threshold here is not rounded. return hourly_rate * expected_hours * WEEKS_IN_YEAR From 932529fe057c63dd9d043772885fdda639421920 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 10:35:30 -0400 Subject: [PATCH 4/4] Cover three-claimant benefit units in the floor property tests The generated populations now include a couple with an adult child the model also flags as a claimant, and the test reference takes the couple as the two eldest claimants, as the model does. Co-Authored-By: Claude Opus 5.5 --- ...test_uc_minimum_income_floor_properties.py | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/policyengine_uk/tests/test_uc_minimum_income_floor_properties.py b/policyengine_uk/tests/test_uc_minimum_income_floor_properties.py index 6c36ba094..40a9587cf 100644 --- a/policyengine_uk/tests/test_uc_minimum_income_floor_properties.py +++ b/policyengine_uk/tests/test_uc_minimum_income_floor_properties.py @@ -10,8 +10,9 @@ below the couple threshold, and is reduced by any amount by which it and the partner's earned income would exceed that threshold (reg. 62(3)). -Invariants, for any generated population of single people, couples and -mixed-age couples with employment, self-employment, pension contributions and +Invariants, for any generated population of single people, couples, +mixed-age couples and couples with an adult child in their benefit unit, with +employment, self-employment profits and losses, pension contributions and start-up periods, in England, Wales and Scotland: 1. The floor never lowers anyone's earned income, and changes it only for @@ -66,10 +67,17 @@ REGIONS = ["LONDON", "NORTH_EAST", "WALES", "SCOTLAND"] WORKING_AGE = st.integers(18, 64) PENSION_AGE = st.integers(67, 80) +# The last shape has an adult child in the parents' benefit unit, whom the +# model also flags as a claimant: the couple is the two eldest claimants. SHAPES = { "single": [WORKING_AGE], "couple": [WORKING_AGE, WORKING_AGE], "mixed_age": [PENSION_AGE, WORKING_AGE], + "couple_with_adult_child": [ + st.integers(40, 64), + st.integers(40, 64), + st.integers(18, 24), + ], } # Profits and pay straddle the floor (about 16,000-23,000 gross). # Profits and losses: a trading loss is nil self-employed earnings. @@ -154,7 +162,16 @@ def calculate(units, year, reform=None, **kwargs): for v in BENUNIT_VARIABLES: values[v] = np.asarray(sim.calculate(v, year)) values[f"person_{v}"] = np.asarray(sim.calculate(v, year, map_to="person")) - claimant = values["is_uc_claimant"].astype(bool) + # The couple: at most the two eldest claimants of each benefit unit. + flagged = values["is_uc_claimant"].astype(bool) + unit = np.asarray(sim.populations["benunit"].members_entity_id) + claimant = np.zeros(len(flagged), dtype=bool) + for u in np.unique(unit): + members = np.flatnonzero((unit == u) & flagged) + eldest = members[np.argsort(-values["age"][members], kind="stable")][:2] + claimant[eldest] = True + values["in_couple"] = claimant + values["floor_can_apply"] = values["uc_mif_applies"].astype(bool) & claimant before = values["uc_individual_earned_income_before_mif"] after = values["uc_individual_earned_income"] threshold = values["uc_minimum_income_floor"] @@ -208,7 +225,7 @@ def test_floor_never_lowers_earned_income(units, year): before = v["uc_individual_earned_income_before_mif"] after = v["uc_individual_earned_income"] assert np.all(after >= before - 0.01), units - unaffected = ~v["uc_mif_applies"].astype(bool) + unaffected = ~v["floor_can_apply"] np.testing.assert_allclose( after[unaffected], before[unaffected], atol=0.01, err_msg=str(units) ) @@ -218,7 +235,7 @@ def test_floor_never_lowers_earned_income(units, year): @given(units=populations, year=st.sampled_from(YEARS)) def test_floor_holds_for_singles_and_couples(units, year): v = calculate(units, year) - applies = v["uc_mif_applies"].astype(bool) + applies = v["floor_can_apply"] before = v["uc_individual_earned_income_before_mif"] after = v["uc_individual_earned_income"] threshold = v["uc_minimum_income_floor"] @@ -254,12 +271,10 @@ def test_matches_closed_form_of_regulation_62(units, year): v = calculate(units, year) before = v["uc_individual_earned_income_before_mif"] threshold = v["uc_minimum_income_floor"] - claimant = v["is_uc_claimant"].astype(bool) + claimant = v["in_couple"] partner = v["claimant_before_sum"] - before * claimant floor = threshold - np.maximum(0, threshold + partner - v["couple_threshold"]) - expected = np.where( - v["uc_mif_applies"].astype(bool), np.maximum(before, floor), before - ) + expected = np.where(v["floor_can_apply"], np.maximum(before, floor), before) np.testing.assert_allclose( v["uc_individual_earned_income"], expected, atol=0.01, err_msg=str(units) )