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 0000000000..b231e44594 --- /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 0000000000..ed59a2c075 --- /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)) 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/changelog.d/uc-mif-net-floor.removed.md b/changelog.d/uc-mif-net-floor.removed.md new file mode 100644 index 0000000000..8114f87dca --- /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 42c2b6d6d5..d06c75ce64 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**: 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 maximum entitlement is then reduced by 55% (the `gov.dwp.universal_credit.means_test.reduction_rate` parameter) of the benunit's combined earnings above its work allowance (`uc_work_allowance`), and by all of its unearned income (`uc_unearned_income`), pound for pound. Unearned income is the list in `gov.dwp.universal_credit.means_test.income_definitions.unearned`: retirement pension income (State Pension and private pensions, UC Regulations 2013 regs 66(1)(a) and 67), Carer's Allowance, carer support payment (up to the Carer's Allowance rate, from November 2023), contribution-based JSA and ESA, maternity allowance, industrial injuries benefit, and the tariff income deemed from capital (reg 72). Actual savings interest, dividends and ordinary rental income are excluded from UC unearned income at every capital level; capital counts through its tariff income instead. Asset rules apply on top of this — capital above the lower threshold deems a tariff income, and capital above the upper threshold disqualifies the benunit entirely.\n3. **Benefit cap**: the post-means-test award (`universal_credit_pre_benefit_cap`) is finally reduced by `benefit_cap_reduction` to produce `universal_credit`. The benefit cap only applies to benunits without a benefit-cap exemption (working enough hours, having a qualifying disability benefit, etc.).\n\nThe take-up step is handled by the input variable `would_claim_uc`, which is populated stochastically when the dataset is built so that PolicyEngine's caseload aggregates match published DWP claimant numbers rather than the full eligible population.\n\nParameters live in `policyengine_uk/parameters/gov/dwp/universal_credit/` and the per-element formulas in `policyengine_uk/variables/gov/dwp/universal_credit/`." + "source": "## How PolicyEngine computes Universal Credit\n\nPolicyEngine computes Universal Credit at the benefit-unit level in three stages:\n\n1. **Maximum entitlement** (`uc_maximum_amount`): sum the elements the benunit qualifies for — `uc_standard_allowance`, `uc_child_element`, `uc_disability_elements`, `uc_carer_element`, `uc_housing_costs_element`, and `uc_childcare_element`.\n2. **Means test**: 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 maximum entitlement is then reduced by 55% (the `gov.dwp.universal_credit.means_test.reduction_rate` parameter) of the benunit's combined earnings above its work allowance (`uc_work_allowance`), and by all of its unearned income (`uc_unearned_income`), pound for pound. Unearned income is the list in `gov.dwp.universal_credit.means_test.income_definitions.unearned`: retirement pension income (State Pension and private pensions, UC Regulations 2013 regs 66(1)(a) and 67), Carer's Allowance, carer support payment (up to the Carer's Allowance rate, from November 2023), contribution-based JSA and ESA, maternity allowance, industrial injuries benefit, and the tariff income deemed from capital (reg 72). Actual savings interest, dividends and ordinary rental income are excluded from UC unearned income at every capital level; capital counts through its tariff income instead. Asset rules apply on top of this — capital above the lower threshold deems a tariff income, and capital above the upper threshold disqualifies the benunit entirely.\n3. **Benefit cap**: the post-means-test award (`universal_credit_pre_benefit_cap`) is finally reduced by `benefit_cap_reduction` to produce `universal_credit`. The benefit cap only applies to benunits without a benefit-cap exemption (working enough hours, having a qualifying disability benefit, etc.).\n\nThe take-up step is handled by the input variable `would_claim_uc`, which is populated stochastically when the dataset is built so that PolicyEngine's caseload aggregates match published DWP claimant numbers rather than the full eligible population.\n\nParameters live in `policyengine_uk/parameters/gov/dwp/universal_credit/` and the per-element formulas in `policyengine_uk/variables/gov/dwp/universal_credit/`." }, { "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 0000000000..db89852a5b --- /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 0000000000..1a1093af6a --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/minimum_income_floor/self_employed_national_insurance.yaml @@ -0,0 +1,27 @@ +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: + 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: "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_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 931732f8b5..0000000000 --- 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_applies.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_MIF_applies.yaml index ec879a24e6..3ed15e32c0 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 new file mode 100644 index 0000000000..94208f5c6c --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/income_floor/uc_mif_earned_income.yaml @@ -0,0 +1,369 @@ +# 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,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%; +# no trading allowance on a profit above 1,000 unless gross receipts show +# expenses below 1,000 (ITTOIA 2005 ss. 783AF and 783AI); 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 + # Profits of 5,000 are below the personal allowance and the lower + # profits limit. + uc_individual_earned_income_before_mif: 5_000 + 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,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 - (20,000 - 12,570) x 20% - (20,000 - 12,570) x 6% = 18,068.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,486 - 445.80 = + # 21,200.40, and UC was 3,038.58 against 1,976.09 at 5,000 and 2,323.58 + # at 15,000: UC rose with profits. + uc_individual_earned_income_before_mif: 18_068.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 + absolute_error_margin: 0.01 + input: + age: 30 + region: NORTH_EAST + self_employment_income: 24_000 + pension_contributions: 3_000 + output: + # Taxable profit 24,000 less 3,000 pension relief and the personal + # allowance: 8,430 x 20%. + uc_income_tax_on_earnings: 1_686 + # Class 4: (24,000 - 12,570) x 6%. + uc_national_insurance_on_earnings: 685.80 + # 24,000 - 3,000 - 1,686 - 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_628.20 + 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 + absolute_error_margin: 0.01 + input: + age: 30 + region: NORTH_EAST + self_employment_income: 23_000 + # Expenses of 200, so the claimant takes the trading allowance instead + # (ITTOIA 2005 s. 783AI): taxable profit is 23,200 - 1,000 = 22,200. + self_employment_gross_receipts: 23_200 + output: + # (22,200 - 12,570) x 20%. + uc_income_tax_on_earnings: 1_926 + # Class 4 on the same profit: (22,200 - 12,570) x 6%. + uc_national_insurance_on_earnings: 577.80 + # 23,000 - 1,926 - 577.80 is above 20,386.03, so the actual amount + # counts. Without the allowance this claimant's tax and Class 4 would be + # those the net floor deducts, at the same rates, so a profit below the + # gross threshold would leave them below the net floor. Before this fix + # the gross 23,000 was below the gross threshold and the claimant was + # treated as having 23,132.20 - 1,926 - 577.80 = 20,628.40. + uc_individual_earned_income_before_mif: 20_496.20 + uc_individual_earned_income: 20_496.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 - (22,000 - 12,570) x 20% - (22,000 - 12,570) x 6%. + uc_individual_earned_income_before_mif: 19_548.20 + # Before this fix: 23,132.20 - 1,886 - 565.80 = 20,680.40. + uc_individual_earned_income: 20_386.03 + +- 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] + # 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_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,648.06. + universal_credit: 7_691.77 + +- 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] + +- 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 + # is_uc_claimant follows is_claimant_or_partner, which never flags more + # than two people, so the third claimant here comes from the inputs. + input: + people: + self_employed: + age: 40 + self_employment_income: 1_000 + would_claim_marriage_allowance: false + is_uc_claimant: true + employee: + age: 40 + employment_income: 35_000 + would_claim_marriage_allowance: false + is_uc_claimant: true + adult_child: + age: 25 + would_claim_marriage_allowance: false + is_uc_claimant: true + 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] + +- 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 5061656308..e326e7e110 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,142 @@ -- 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 (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 +# 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); +# 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; 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%; Class 2 3.05 a week; Class 4 9% above +# 9,500. + +- 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: + age: 30 + output: + # 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: An apprentice's threshold uses the rate for their age, not the apprenticeship rate + period: 2026 + absolute_error_margin: 0.01 + input: + age: 30 + is_apprentice: true + output: + # 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 + absolute_error_margin: 0.01 + input: + age: 30 + region: NORTH_EAST + output: + 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 + absolute_error_margin: 0.01 + input: + age: 30 + region: SCOTLAND + output: + 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 + absolute_error_margin: 0.01 + input: + age: 19 + region: NORTH_EAST + output: + # 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 NI is deducted for a person over State Pension age at the start of the tax year + period: 2026 + absolute_error_margin: 0.01 + input: + age: 70 + region: NORTH_EAST + output: + 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_019.76 + +- name: Class 4 is deducted all year for a person who reaches State Pension age after 6 April + period: 2026 + absolute_error_margin: 0.01 + input: + # A whole-number age of 66 puts the birthday on 6 April 1960, so State + # Pension age (66 and 1 month) is reached on 6 May 2026, after the start + # of the tax year. Class 4 is due for all of 2026-27 (SI 2001/1004 reg. + # 91(a)), as on the person's actual profits. + age: 66 + region: NORTH_EAST + output: + is_SP_age: true + ni_class_4_liable: true + # Class 4: (23,132.20 - 12,570) x 6%. + uc_minimum_income_floor_national_insurance: 633.73 + uc_minimum_income_floor: 20_386.03 + +- name: Net floor in 2020 deducts Class 2 as well as Class 4 period: 2020 - absolute_error_margin: 0 + absolute_error_margin: 0.01 + input: + age: 30 + region: NORTH_EAST + output: + # 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: - minimum_wage: 10 + age: 30 + region: NORTH_EAST output: - uc_minimum_income_floor: 350 * 52 + # 7.20 x 35 x 52. + uc_minimum_income_floor_gross: 13_104 diff --git a/policyengine_uk/tests/test_uc_earnings_deductions_properties.py b/policyengine_uk/tests/test_uc_earnings_deductions_properties.py index 5dae910538..bc961fe791 100644 --- a/policyengine_uk/tests/test_uc_earnings_deductions_properties.py +++ b/policyengine_uk/tests/test_uc_earnings_deductions_properties.py @@ -30,7 +30,10 @@ (see test_uc_state_pension_properties.py for the Marriage Allowance deviation). Tax reductions are covered: adults born before 6 April 1935 can have a married couple's allowance, which comes off the tax on earnings -first. Invariant 4 runs with Marriage Allowance claimed. +first. 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 @@ -284,7 +287,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, @@ -304,6 +311,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 0000000000..efb2dd4f19 --- /dev/null +++ b/policyengine_uk/tests/test_uc_minimum_income_floor_properties.py @@ -0,0 +1,412 @@ +"""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, +couples with an older partner aged 65 to 80 (mixed-age once past State +Pension age) 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 + 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, 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, 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, +lawful, non-monotonicity. +""" + +import numpy as np +from hypothesis import HealthCheck, assume, example, 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) +# From 65, so the draws include people who reach State Pension age during +# the tax year: Class 4 stays due for that year (SI 2001/1004 reg. 91(a)), +# but primary Class 1 and Class 2 stop. +PENSION_AGE = st.integers(65, 80) +# The last shape has an adult child in the parents' benefit unit. The model +# flags at most two claimants (is_claimant_or_partner), here the parents; the +# couple is the two eldest claimants. The YAML tests flag a third by input. +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. +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) +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")) + # 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"] + # 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["floor_can_apply"] + 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["floor_can_apply"] + 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["in_couple"] + partner = v["claimant_before_sum"] - before * claimant + floor = threshold - np.maximum(0, threshold + partner - v["couple_threshold"]) + 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) + ) + + +SELF_EMPLOYED_NI = ( + "gov.dwp.universal_credit.means_test.minimum_income_floor." + "self_employed_national_insurance" +) + + +# A 66-year-old in 2026 reaches State Pension age (66 and 1 month) after 6 +# April: Class 4 stays due for the year, primary Class 1 and Class 2 do not. +# The generated draws reach this case only by chance. +SPA_DURING_2026 = [ + dict( + adults=[ + dict( + age=66, + self_employment_income=5_000.0, + employment_income=0.0, + pension_contributions=0.0, + uc_is_in_startup_period=False, + ), + dict( + age=40, + self_employment_income=0.0, + employment_income=15_000.0, + pension_contributions=0.0, + uc_is_in_startup_period=False, + ), + ], + children=[], + tenure="RENT_FROM_COUNCIL", + rent=9_600.0, + region="NORTH_EAST", + ) +] + + +@PROPERTY_SETTINGS +@given( + units=populations, + year=st.sampled_from(YEARS), + self_employed=st.booleans(), +) +@example(units=SPA_DURING_2026, year=2026, self_employed=True) +@example(units=SPA_DURING_2026, year=2026, self_employed=False) +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"])) + 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 +@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 89874240e8..d4ddb8a5c1 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,17 @@ 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 a self-employment profit or loss, 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): - has_self_empl_income = person("self_employment_income", period) > 0 + # 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 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 03c7deb25c..0000000000 --- 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 f450756b63..3ce2a82f82 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 0000000000..4786b79206 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_minimum_income_floor_gross.py @@ -0,0 +1,46 @@ +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 (never the apprenticeship rate) times their expected " + "hours of work each week, 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="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): + p = parameters(period) + expected_hours = ( + p.gov.dwp.universal_credit.work_requirements.default_expected_hours + ) + # 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. 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 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 0000000000..c570e6eb1a --- /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: 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 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) + 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 0000000000..88bc10ec15 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_minimum_income_floor_national_insurance.py @@ -0,0 +1,69 @@ +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: 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 + 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 + ) + # Each class is due only from those the model treats as liable for + # it, as for the person's actual contributions. Primary Class 1 and + # Class 2 stop at State Pension age (ni_liable). Class 4 is due for + # the whole tax year from anyone not over it on 6 April (SI 2001/1004 + # reg. 91(a), ni_class_4_liable). No class is due from anyone under 16. + liable = person("ni_liable", period) + class_4_liable = person("ni_class_4_liable", period) + self_employed = liable * class_2 + class_4_liable * class_4_amount + mif = p.gov.dwp.universal_credit.means_test.minimum_income_floor + return where( + mif.self_employed_national_insurance, self_employed, liable * employee + ) 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 49dff530cc..9f53b210f2 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 @@ -59,7 +59,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 201eedbb24..ac7fb31548 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 @@ -23,32 +23,49 @@ class uc_individual_earned_income(Variable): href="https://www.legislation.gov.uk/uksi/2013/376/regulation/57", ), dict( - title="Finance Act 2004 s. 188 (relievable pension contributions)", - href="https://www.legislation.gov.uk/ukpga/2004/12/section/188", + 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) - # Contributions paid after the person reaches 75 are not relievable - # (Finance Act 2004 s. 188(3)(a)). - age_limit = parameters( - period - ).gov.hmrc.pensions.pension_contributions_relief_age_limit - relievable_pension_contributions = person("pension_contributions", period) * ( - person("age", period) < age_limit + 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. A claim has + # at most two claimants, and is_uc_claimant flags at most two; where + # inputs flag more, 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 ) - tax_and_national_insurance = add( - person, - period, - ["uc_income_tax_on_earnings", "uc_national_insurance_on_earnings"], + claimant_earned_income = earned_income * claimant + partner_earned_income = ( + person.benunit.sum(claimant_earned_income) - claimant_earned_income ) - return max_( - 0, - gross_earnings - - relievable_pension_contributions - - tax_and_national_insurance, + 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) + & claimant + & below_individual_threshold + & below_couple_threshold + ) + 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 0000000000..73a93702a1 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py @@ -0,0 +1,62 @@ +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", + ), + dict( + title="Finance Act 2004 s. 188 (relievable pension contributions)", + href="https://www.legislation.gov.uk/ukpga/2004/12/section/188", + ), + ] + + 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", "miscellaneous_income"] + bi = parameters(period).gov.contrib.ubi_center.basic_income + if bi.interactions.include_in_means_tests: + earnings_components.append("basic_income") + # 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 + # Contributions paid after the person reaches 75 are not relievable + # (Finance Act 2004 s. 188(3)(a)), so reg. 55(5)(a) does not deduct + # them. + age_limit = parameters( + period + ).gov.hmrc.pensions.pension_contributions_relief_age_limit + relievable_pension_contributions = person("pension_contributions", period) * ( + person("age", period) < age_limit + ) + tax_and_national_insurance = add( + person, + period, + ["uc_income_tax_on_earnings", "uc_national_insurance_on_earnings"], + ) + return max_( + 0, earnings - relievable_pension_contributions - tax_and_national_insurance + )