diff --git a/.github/test_outcome_allowlist.toml b/.github/test_outcome_allowlist.toml index 1b50e36c70..757f2b3a49 100644 --- a/.github/test_outcome_allowlist.toml +++ b/.github/test_outcome_allowlist.toml @@ -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" @@ -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" diff --git a/changelog.d/2397.fixed.md b/changelog.d/2397.fixed.md new file mode 100644 index 0000000000..606b24625f --- /dev/null +++ b/changelog.d/2397.fixed.md @@ -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. diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_earnings_exception_earned_income.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_earnings_exception_earned_income.yaml new file mode 100644 index 0000000000..2ad812620a --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_earnings_exception_earned_income.yaml @@ -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 diff --git a/policyengine_uk/tests/test_uc_income_from_capital_properties.py b/policyengine_uk/tests/test_uc_income_from_capital_properties.py index 4a07d757fd..cfeeda4c58 100644 --- a/policyengine_uk/tests/test_uc_income_from_capital_properties.py +++ b/policyengine_uk/tests/test_uc_income_from_capital_properties.py @@ -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 @@ -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"] ) @@ -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 diff --git a/policyengine_uk/tests/test_uc_statutory_pay_properties.py b/policyengine_uk/tests/test_uc_statutory_pay_properties.py index ef2de488c3..fdf11d88df 100644 --- a/policyengine_uk/tests/test_uc_statutory_pay_properties.py +++ b/policyengine_uk/tests/test_uc_statutory_pay_properties.py @@ -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 @@ -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 @@ -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) @@ -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], diff --git a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_earnings.py b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_earnings.py index 76e7dcf27c..c859af32bd 100644 --- a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_earnings.py +++ b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_earnings.py @@ -5,26 +5,33 @@ class is_benefit_cap_exempt_earnings(Variable): value_type = bool entity = BenUnit label = "Exempt from the benefit cap because of earnings" + documentation = ( + "The assessed claimant's or couple's actual net earned income for " + "Universal Credit reaches the earnings threshold. This includes " + "statutory pay and deducts their own relievable pension contributions " + "and tax and National Insurance on earnings. Income imputed by the " + "minimum income floor and a dependant's earnings do not count." + ) definition_period = YEAR - reference = "https://www.gov.uk/benefit-cap/when-youre-not-affected" + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/regulation/82", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/55", + ) def formula(benunit, period, parameters): person = benunit.members # Earnings exemption for UC (£846/month = £10,152/year) - # Note: Only check earned income, not UC amount itself to avoid circular dependency + # Only check earned income, not the UC amount, to avoid a circular dependency. # 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": a dependant's earnings do not count. claimant = person("is_uc_assessed_claimant", period) + # Reg. 82(4) excludes earnings imputed under the minimum income + # floor (reg. 62). Use actual UC earnings, before both that floor + # and the work allowance, with the reg. 55 and 57 deductions. uc_earned = benunit.sum( - ( - person("employment_income", period) - + person("self_employment_income", period) - - person("income_tax", period) - - person("national_insurance", period) - ) - * claimant + person("uc_individual_earned_income_before_mif", period) * claimant ) earnings_threshold = 10_152 return uc_earned >= earnings_threshold