Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions .github/test_outcome_allowlist.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,6 @@ outcome = "xfailed"
reason = "#1947"
why = "Known bug #1947 (Marriage Allowance booked on the recipient), pinned by a strict xfail until it is fixed."

[[allow]]
nodeid = "policyengine_uk/tests/test_uc_income_from_capital_properties.py::test_tax_on_dividends_does_not_remove_the_benefit_cap_earnings_exception"
outcome = "xfailed"
reason = "#1986"
why = "Known bug #1986 (the benefit cap earnings exception nets tax on all income), pinned by a strict xfail until it is fixed."

[[allow]]
nodeid = "policyengine_uk/tests/test_build_metadata.py::test_runtime_metadata_uses_bundle_contract_when_available"
outcome = "skipped"
Expand All @@ -44,6 +38,13 @@ reason = "not running from a git checkout"
modes = ["wheel"]
why = "An installed wheel is not a git checkout, so there is no HEAD to compare with."

[[allow]]
nodeid = "policyengine_uk/tests/test_variable_code_sharing.py::test_a_simulation_leaves_coverage_no_new_code_objects"
outcome = "skipped"
reason = "coverage is not measuring with sys.monitoring"
modes = ["wheel"]
why = "The release workflow tests the installed wheel without coverage, so this check of coverage's code objects has nothing to measure there. It runs and passes against the source tree on every PR, where coverage measures with sys.monitoring."

[[allow]]
nodeid = "policyengine_uk/tests/code_health/*"
outcome = "collect-skipped"
Expand Down
1 change: 1 addition & 0 deletions changelog.d/2397.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Fixed the benefit-cap earnings exemption to count statutory pay and use Universal Credit's actual net earnings before the minimum income floor, with a pinned regression for the release failure.
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
# The benefit cap's earnings exception (UC Regs 2013 reg 82(1)(a)) tests "the
# claimant's earned income or, if the claimant is a member of a couple, the
# couple's combined earned income". Earned income is Universal Credit's
# (reg 52): statutory sick, maternity and paternity pay are employed earnings
# (reg 55(4)), and the deductions are the person's own relievable pension
# contributions and the income tax and primary Class 1 contributions paid "in
# respect of the employment" (reg 55(5)). Income the minimum income floor
# treats a person as having does not count (reg 82(4)). The threshold is the
# model's 10,152 a year (issue #1986). Unless stated otherwise, each family is
# a single claimant aged 40 outside London with an entered award of 18,000
# before the cap, so the cap of 14,753 takes 3,247 unless an exception applies.
# Sources: https://www.legislation.gov.uk/uksi/2013/376/regulation/55
# https://www.legislation.gov.uk/uksi/2013/376/regulation/82
# https://www.gov.uk/guidance/rates-and-thresholds-for-employers-2026-to-2027
# The existing model threshold is retained here; 11,000 and 10,000 also lie
# above and below the 2026 NLW-based threshold of 10,574.72 respectively.

- name: Statutory sick pay is earned income for the earnings exception
period: 2026
input:
age: 40
employment_income: 3_000
statutory_sick_pay: 8_000
universal_credit_pre_benefit_cap: 18_000
region: NORTH_EAST
output:
uc_individual_earned_income_before_mif: 11_000
is_benefit_cap_exempt_earnings: true
benefit_cap_reduction: 0

- name: Statutory paternity pay is earned income for the earnings exception
period: 2026
input:
age: 40
employment_income: 3_000
statutory_paternity_pay: 8_000
universal_credit_pre_benefit_cap: 18_000
region: NORTH_EAST
output:
uc_individual_earned_income_before_mif: 11_000
is_benefit_cap_exempt_earnings: true
benefit_cap_reduction: 0

- name: Statutory maternity pay is earned income for the earnings exception
period: 2026
input:
people:
claimant:
age: 40
employment_income: 3_000
statutory_maternity_pay: 8_000
benunits:
family:
members: [claimant]
universal_credit_pre_benefit_cap: 18_000
households:
home:
members: [claimant]
region: NORTH_EAST
output:
# 3,000 + 8,000 = 11,000, under the personal allowance and the primary
# threshold, so no tax or NI: 11,000 >= 10,152. Without the maternity
# pay the wages alone, 3,000, are under the threshold.
uc_individual_earned_income_before_mif: 11_000
is_benefit_cap_exempt_earnings: true
is_benefit_cap_exempt: true
benefit_cap_reduction: 0

- name: Tax on a private pension does not come off the earnings the exception tests
period: 2026
input:
people:
claimant:
age: 40
employment_income: 11_000
private_pension_income: 10_000
benunits:
family:
members: [claimant]
universal_credit_pre_benefit_cap: 18_000
households:
home:
members: [claimant]
region: NORTH_EAST
output:
# Income tax is (21,000 - 12,570) x 20% = 1,686. The model's convention
# in uc_income_tax_on_earnings allocates wages to the lowest slice,
# covered by the personal allowance, leaving all the tax on the pension.
# Reg 55(5)(b) requires tax in respect of employment to be deducted but
# does not prescribe this allocation between wages and pension income.
# No NI on 11,000. 11,000 >= 10,152. Netting the 1,686 off the wages
# would give 9,314, under the threshold.
income_tax: 1_686
uc_income_tax_on_earnings: 0
uc_individual_earned_income_before_mif: 11_000
is_benefit_cap_exempt_earnings: true
benefit_cap_reduction: 0

- name: Relievable pension contributions come off the earnings the exception tests
period: 2026
input:
people:
claimant:
age: 40
employment_income: 11_000
employee_pension_contributions: 1_000
benunits:
family:
members: [claimant]
universal_credit_pre_benefit_cap: 18_000
households:
home:
members: [claimant]
region: NORTH_EAST
output:
# Reg 55(5)(a): 11,000 - 1,000 = 10,000, with no tax or NI, is under
# 10,152. The cap applies: 18,000 - 14,753 = 3,247.
uc_individual_earned_income_before_mif: 10_000
is_benefit_cap_exempt_earnings: false
is_benefit_cap_exempt: false
benefit_cap_reduction: 3_247

- name: The minimum income floor does not create an earnings exception
period: 2026
input:
age: 40
self_employment_income: 5_000
uc_mif_applies: true
uc_minimum_income_floor: 20_000
universal_credit_pre_benefit_cap: 18_000
region: NORTH_EAST
output:
uc_individual_earned_income_before_mif: 5_000
uc_individual_earned_income: 20_000
is_benefit_cap_exempt_earnings: false
benefit_cap_reduction: 3_247

- name: Means-tested basic income counts toward the couple's earnings exception
period: 2026
input:
gov.contrib.ubi_center.basic_income.interactions.include_in_means_tests: true
people:
claimant:
age: 40
employment_income: 0
basic_income: 5_500
partner:
age: 40
employment_income: 0
basic_income: 5_500
benunits:
family:
members: [claimant, partner]
universal_credit_pre_benefit_cap: 25_000
households:
home:
members: [claimant, partner]
region: NORTH_EAST
output:
# The reform includes basic income in UC earned income, so the shared
# expression in #1820 also counts it here: 5,500 + 5,500 >= 10,152.
# The total also exceeds #1820's 2026 NLW-based earnings threshold.
# Counting basic income in this test is new in this PR: main's formula
# never counted it. Whether reg 82 should count it is an open question
# for #1986 and #1820. The award of 25,000 is above the couple cap
# outside London (22,020), so without the exemption the cap would take
# 2,980; with it, nothing.
uc_individual_earned_income_before_mif: [5_500, 5_500]
is_benefit_cap_exempt_earnings: true
benefit_cap_reduction: 0

- name: Without the basic income reform toggle, basic income does not count toward the earnings exception
period: 2026
input:
people:
claimant:
age: 40
employment_income: 0
basic_income: 5_500
partner:
age: 40
employment_income: 0
basic_income: 5_500
benunits:
family:
members: [claimant, partner]
universal_credit_pre_benefit_cap: 25_000
households:
home:
members: [claimant, partner]
region: NORTH_EAST
output:
# The control for the case above. By default basic income is not UC
# earned income, so the couple has no earned income, no exemption
# applies and the couple cap outside London (22,020) takes
# 25,000 - 22,020 = 2,980.
uc_individual_earned_income_before_mif: [0, 0]
is_benefit_cap_exempt_earnings: false
benefit_cap_reduction: 2_980
22 changes: 9 additions & 13 deletions policyengine_uk/tests/test_uc_income_from_capital_properties.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,12 @@
s. 35), which raises the tax a person pays on their earnings and so changes
the deduction from them (see test_uc_earnings_deductions_properties.py).
The award after the benefit cap is compared only where the cap exemption is
also unchanged: the model's earnings exception to the cap still nets tax on
all of a person's income off their earnings
(PolicyEngine/policyengine-uk#1986), so capital income can switch the cap on.
A strict xfail pins that case.
also unchanged. The cap's earnings exception now reads each claimant's
Universal Credit earned income (uc_individual_earned_income_before_mif), which
deducts only the tax on their earnings (uc_income_tax_on_earnings), so
interest, dividends and rent leave it alone too, apart from the allowance
effects above. test_tax_on_dividends_does_not_remove_the_benefit_cap_earnings_exception
pins the dividend case.
"""

import numpy as np
Expand Down Expand Up @@ -253,8 +255,9 @@ def test_interest_dividends_and_rent_leave_the_award_unchanged(units, scale, yea
unchanged = np.isclose(
scaled["personal_allowances"], base["personal_allowances"], atol=0.01
)
# The cap's earnings exception can change with the income (#1986), so
# the capped award is compared only where the exemption is the same.
# Compare the capped award only where the exemption is the same:
# capital income can change another exemption route, such as Working
# Tax Credit or the pension-age Housing Benefit rules.
same_cap = unchanged & (
scaled["is_benefit_cap_exempt"] == base["is_benefit_cap_exempt"]
)
Expand Down Expand Up @@ -323,13 +326,6 @@ def test_tax_on_dividends_does_not_raise_a_working_familys_award():
assert values["uc_earned_income"][0] == pytest.approx(10_000, abs=0.01)


@pytest.mark.xfail(
strict=True,
reason=(
"PolicyEngine/policyengine-uk#1986: the benefit cap earnings exception "
"nets tax on all of a person's income off their earnings"
),
)
def test_tax_on_dividends_does_not_remove_the_benefit_cap_earnings_exception():
# 2026: single claimant aged 30 in London, council rent 20,000, earning
# 11,000 with no tax or NI on it. Without dividends the model applies no
Expand Down
72 changes: 62 additions & 10 deletions policyengine_uk/tests/test_uc_statutory_pay_properties.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,20 +41,17 @@
82(1)(a) threshold, and the payments are earned income (reg. 55(4)). So
a pound of pay can lift the cap and raise the final award. The award
after the cap may therefore rise only where the family becomes exempt
(is_benefit_cap_exempt); otherwise it obeys the same bound. Today's
is_benefit_cap_exempt_earnings reads employment income without
statutory pay, so this exception does not yet arise in the model.
(is_benefit_cap_exempt); otherwise it obeys the same bound. The
earnings exemption uses actual UC net earned income before the
minimum income floor, which reg. 82(4) excludes.

2. Same as wages: statutory sick or maternity pay of s on top of wages of e
gives the same income tax, National Insurance, earned income and award
before the benefit cap as wages of e + s. This holds for Marriage Allowance
before and after the benefit cap as wages of e + s. This holds for Marriage Allowance
claimants too. Two differences in the model's income tax are left out
(counts_as_wages): it charges no income tax on statutory paternity pay, and
it caps pension contribution relief at employment and self-employment
income without statutory pay. The award after the benefit cap is not
compared, because the cap's earnings exception
(is_benefit_cap_exempt_earnings) reads employment income without statutory
pay.
income without statutory pay.

3. Accounting: a person's earned income before the minimum income floor is
their wages, statutory pay and trading profit (a loss counts as nil) less
Expand All @@ -81,7 +78,7 @@
"""

import numpy as np
from hypothesis import HealthCheck, given, settings
from hypothesis import HealthCheck, example, given, settings
from hypothesis import strategies as st

from policyengine_uk import CountryTaxBenefitSystem, Simulation
Expand Down Expand Up @@ -265,7 +262,57 @@ def recipient(self, variable, i, version):
return self.person(variable, i, version, self.population[i]["recipient"])


# Release run 38366204407: explicit coverage is independent of Hypothesis's
# local source-constant pool, which differs for source and site-packages.
STATUTORY_SICK_PAY_CAP_REGRESSION = {
"adults": [
{
"age": 22,
"wages": 12_570.0,
"profit": 0.0,
"startup": False,
"pension_contributions": 0.0,
"pension_income": 6_000.0,
"dividends": 0.0,
"lcwra": False,
"claims_marriage_allowance": False,
}
],
"child_ages": [1, 1],
"childcare": 0.0,
"region": "NORTH_EAST",
"tenure": "RENT_FROM_COUNCIL",
"rent": 0.0,
"recipient": 0,
"payment": "statutory_sick_pay",
"low": 0.0,
"high": 6_000.0,
}


def test_statutory_sick_pay_preserves_benefit_cap_earnings_exemption():
"""Reg. 55(4) pay cannot end this family's reg. 82(1)(a) exception."""
results = Results([STATUTORY_SICK_PAY_CAP_REGRESSION])
for version, expected_earnings in [(LOW, 12_570), (HIGH, 16_890)]:
# The private pension absorbs the tax on unearned income. At HIGH,
# actual earnings are 12,570 + 6,000 - 1,200 tax - 480 Class 1 NI.
np.testing.assert_allclose(
results.recipient("uc_individual_earned_income_before_mif", 0, version),
expected_earnings,
atol=TOLERANCE,
rtol=0,
)
assert results.families("is_benefit_cap_exempt_earnings", version)[0]
assert results.families("is_benefit_cap_exempt", version)[0]
assert results.families("benefit_cap_reduction", version)[0] == 0
assert (
results.families("universal_credit", HIGH)[0]
<= results.families("universal_credit", LOW)[0] + TOLERANCE
)


@PROPERTY_SETTINGS
@example(population=[STATUTORY_SICK_PAY_CAP_REGRESSION])
@given(population=populations())
def test_award_never_rises_with_a_claimants_statutory_pay(population):
results = Results(population)
Expand Down Expand Up @@ -392,7 +439,12 @@ def test_statutory_pay_counts_as_wages_and_earned_income_adds_up(population):
atol=TOLERANCE,
err_msg=f"{variable}: {family}",
)
for variable in ["uc_earned_income", "universal_credit_pre_benefit_cap"]:
for variable in [
"uc_earned_income",
"universal_credit_pre_benefit_cap",
"is_benefit_cap_exempt_earnings",
"universal_credit",
]:
taxed = [counts_as_wages(family) for family in population]
np.testing.assert_allclose(
results.families(variable, HIGH)[taxed],
Expand Down
Loading
Loading