From 574a39fe73537cfe7fea3c1d4e34aacc2dcbac77 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 16:43:42 -0400 Subject: [PATCH 1/6] Model Universal Credit reg 77: a company analogous to a partnership or one person business Co-Authored-By: Claude Opus 5.5 --- .../uc_company_analogous_business.yaml | 349 ++++++++++++++++ .../tests/test_uc_company_owner_properties.py | 375 ++++++++++++++++++ .../company/uc_company_capital.py | 44 ++ .../uc_company_gainful_self_employment.py | 32 ++ .../company/uc_company_holding_disregard.py | 26 ++ .../uc_company_owner_treatment_applies.py | 36 ++ .../uc_company_self_employed_earnings.py | 37 ++ .../income/income_floor/uc_mif_applies.py | 15 +- .../uc_mif_capped_earned_income.py | 4 + .../universal_credit/uc_assessable_capital.py | 14 +- .../variables/input/owned_company/README.md | 11 + .../is_engaged_in_owned_company_trade.py | 25 ++ .../owned_company/owned_company_capital.py | 30 ++ ...ed_company_carries_on_property_business.py | 25 ++ .../owned_company_carries_on_trade.py | 25 ++ .../owned_company_holding_value.py | 22 + ...ncome_is_intermediary_employed_earnings.py | 29 ++ .../owned_company_income_share.py | 31 ++ .../owned_company_is_main_employment.py | 18 + .../owned_company_trade_assets.py | 20 + ...nds_as_sole_owner_or_partner_of_company.py | 30 ++ 21 files changed, 1194 insertions(+), 4 deletions(-) create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/company/uc_company_analogous_business.yaml create mode 100644 policyengine_uk/tests/test_uc_company_owner_properties.py create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_capital.py create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_gainful_self_employment.py create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_holding_disregard.py create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_owner_treatment_applies.py create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_self_employed_earnings.py create mode 100644 policyengine_uk/variables/input/owned_company/README.md create mode 100644 policyengine_uk/variables/input/owned_company/is_engaged_in_owned_company_trade.py create mode 100644 policyengine_uk/variables/input/owned_company/owned_company_capital.py create mode 100644 policyengine_uk/variables/input/owned_company/owned_company_carries_on_property_business.py create mode 100644 policyengine_uk/variables/input/owned_company/owned_company_carries_on_trade.py create mode 100644 policyengine_uk/variables/input/owned_company/owned_company_holding_value.py create mode 100644 policyengine_uk/variables/input/owned_company/owned_company_income_is_intermediary_employed_earnings.py create mode 100644 policyengine_uk/variables/input/owned_company/owned_company_income_share.py create mode 100644 policyengine_uk/variables/input/owned_company/owned_company_is_main_employment.py create mode 100644 policyengine_uk/variables/input/owned_company/owned_company_trade_assets.py create mode 100644 policyengine_uk/variables/input/owned_company/stands_as_sole_owner_or_partner_of_company.py diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/company/uc_company_analogous_business.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/company/uc_company_analogous_business.yaml new file mode 100644 index 0000000000..39fe6c7674 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/company/uc_company_analogous_business.yaml @@ -0,0 +1,349 @@ +# The Universal Credit Regulations 2013 reg. 77 (company analogous to a +# partnership or one person business), as explained in DWP's Advice for +# Decision Making Chapter H4, paras. H4360-H4377. Expected values are worked +# by hand from the regulation; the capital cases use the same figures as the +# Axiom encoding's tests (rulespec-uk uk/regulations/uksi/2013/376/77.test.yaml). +# Company earnings are not taxable income, so no income tax or National +# Insurance comes off them in these cases. + +- name: Sole owner of a trading company whose main employment it is has the floor applied to the company income + # H4374-H4375 and Example 2 (Claudia): the company's income is the owner's + # self-employed earnings (reg. 77(3)(b)) and, as the trade is their main + # employment, they are treated as gainfully self-employed so the minimum + # income floor applies (reg. 77(3)(c)). max(6,000, 20,000) = 20,000. + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + stands_as_sole_owner_or_partner_of_company: true + owned_company_carries_on_trade: true + owned_company_is_main_employment: true + owned_company_income_share: 6_000 + uc_minimum_income_floor: 20_000 + output: + uc_company_owner_treatment_applies: true + uc_company_self_employed_earnings: 6_000 + uc_company_gainful_self_employment: true + uc_mif_applies: true + uc_mif_capped_earned_income: 20_000 + +- name: A partner-like shareholder in a start-up period has only their share of the company income counted + # H4376 Example 1 (Ezgjan): a 50% owner with meaningful influence, working + # 60 hours a week with no salary, in a start-up period. Reg. 62(5) keeps + # the floor off during the start-up period; their half of 24,000 counts. + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + stands_as_sole_owner_or_partner_of_company: true + owned_company_carries_on_trade: true + owned_company_is_main_employment: true + owned_company_income_share: 24_000 * 0.5 + uc_is_in_startup_period: true + uc_minimum_income_floor: 20_000 + output: + uc_company_gainful_self_employment: true + uc_mif_applies: false + uc_mif_capped_earned_income: 12_000 + +- name: Company income is counted in addition to the owner's pay as its director + # Reg. 77(4): 9,000 of director's pay (employed earnings) plus 15,000 of + # company income is 24,000, above the 20,000 floor. + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + employment_income: 9_000 + stands_as_sole_owner_or_partner_of_company: true + owned_company_carries_on_trade: true + owned_company_is_main_employment: true + owned_company_income_share: 15_000 + uc_minimum_income_floor: 20_000 + output: + uc_company_self_employed_earnings: 15_000 + uc_mif_capped_earned_income: 24_000 + +- name: When the company is not the owner's main employment its income counts but no floor applies + # Reg. 77(3)(b) applies to any owner of a trading company; reg. 77(3)(c) + # only when the trade is the main employment. + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + employment_income: 18_000 + stands_as_sole_owner_or_partner_of_company: true + owned_company_carries_on_trade: true + owned_company_is_main_employment: false + owned_company_income_share: 1_000 + uc_minimum_income_floor: 20_000 + output: + uc_company_gainful_self_employment: false + uc_mif_applies: false + uc_mif_capped_earned_income: 19_000 + +- name: The company income is tapered like other earnings + # A single claimant with no children and no limited capability for work has + # no work allowance (reg. 22). Earned income is the 8,000 floor (above the + # 6,000 of company income) and the taper takes 55% of it (reg. 22(1)(b)), + # which is less than the maximum amount, so the reduction is not capped. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + stands_as_sole_owner_or_partner_of_company: true + owned_company_carries_on_trade: true + owned_company_is_main_employment: true + owned_company_income_share: 6_000 + uc_minimum_income_floor: 8_000 + benunits: + benunit: + members: person + households: + household: + members: person + output: + uc_earned_income: 8_000 + uc_income_reduction: 8_000 * 0.55 + +- name: A property company gives capital but no earnings + # H4365-H4367 (Pablo): a major shareholder in a company letting a seaside + # cottage. The shares (90,000, held within corporate_wealth) are disregarded + # and the company's capital (the 120,000 cottage) is counted instead + # (reg. 77(2)). Reg. 77(3), including the trade asset disregard and the + # earnings rule, applies only to a trade (H4376). 2,000 + 90,000 - 90,000 + # + 120,000 = 122,000. + period: 2026 + absolute_error_margin: 0 + input: + people: + person: + age: 40 + stands_as_sole_owner_or_partner_of_company: true + owned_company_carries_on_property_business: true + is_engaged_in_owned_company_trade: true + owned_company_is_main_employment: true + owned_company_income_share: 10_000 + owned_company_capital: 120_000 + owned_company_trade_assets: 120_000 + owned_company_holding_value: 90_000 + benunits: + benunit: + members: person + households: + household: + members: person + savings: 2_000 + corporate_wealth: 90_000 + output: + uc_company_owner_treatment_applies: [true] + uc_company_self_employed_earnings: [0] + uc_company_gainful_self_employment: [false] + uc_company_capital: [120_000] + uc_company_holding_disregard: [90_000] + uc_mif_capped_earned_income: [0] + uc_assessable_capital: 122_000 + +- name: Trade assets are disregarded while the owner works in the trade + # Reg. 77(2) and (3)(a); Axiom test trade_company_main_employment_treats_capital_income_and_mif. + # Company capital 100,000 less 40,000 of assets used wholly and + # exclusively for the trade = 60,000; the 25,000 holding is disregarded. + # 3,000 + 25,000 - 25,000 + 60,000 = 63,000. + period: 2026 + absolute_error_margin: 0 + input: + people: + person: + age: 40 + stands_as_sole_owner_or_partner_of_company: true + owned_company_carries_on_trade: true + is_engaged_in_owned_company_trade: true + owned_company_capital: 100_000 + owned_company_trade_assets: 40_000 + owned_company_holding_value: 25_000 + benunits: + benunit: + members: person + households: + household: + members: person + savings: 3_000 + corporate_wealth: 25_000 + output: + uc_company_capital: [60_000] + uc_company_holding_disregard: [25_000] + uc_assessable_capital: 63_000 + +- name: Trade assets count when the owner does not work in the trade + # Reg. 77(3)(a) disregards trade assets only "while they are engaged in + # activities in the course of that trade"; Axiom test + # trade_assets_not_disregarded_when_person_not_engaged_in_trade_activities. + # 90,000 counted; 20,000 holding disregarded. 20,000 - 20,000 + 90,000. + period: 2026 + absolute_error_margin: 0 + input: + people: + person: + age: 40 + stands_as_sole_owner_or_partner_of_company: true + owned_company_carries_on_trade: true + is_engaged_in_owned_company_trade: false + owned_company_capital: 90_000 + owned_company_trade_assets: 30_000 + owned_company_holding_value: 20_000 + benunits: + benunit: + members: person + households: + household: + members: person + corporate_wealth: 20_000 + output: + uc_company_capital: [90_000] + uc_assessable_capital: 90_000 + +- name: Income that is employed earnings through an intermediary from the main employment takes the owner out of the rule + # Reg. 77(5): nothing in reg. 77 applies, so the shares count as ordinary + # capital and the company income is not self-employed earnings. Axiom test + # intermediary_employed_earnings_main_employment_excludes_regulation. + period: 2026 + absolute_error_margin: 0 + input: + people: + person: + age: 40 + stands_as_sole_owner_or_partner_of_company: true + owned_company_carries_on_trade: true + owned_company_income_is_intermediary_employed_earnings: true + owned_company_is_main_employment: true + is_engaged_in_owned_company_trade: true + owned_company_income_share: 36_000 + owned_company_capital: 100_000 + owned_company_trade_assets: 40_000 + owned_company_holding_value: 25_000 + benunits: + benunit: + members: person + households: + household: + members: person + corporate_wealth: 25_000 + output: + uc_company_owner_treatment_applies: [false] + uc_company_self_employed_earnings: [0] + uc_company_gainful_self_employment: [false] + uc_mif_applies: [false] + uc_company_capital: [0] + uc_company_holding_disregard: [0] + uc_assessable_capital: 25_000 + +- name: An investor in a company with many shareholders is not treated as its owner + # H4364: such a shareholder has no influence over the running of the + # company, so reg. 77 does not apply and the shares are ordinary capital + # (reg. 46). Company facts recorded for them have no effect. + period: 2026 + absolute_error_margin: 0 + input: + people: + person: + age: 40 + stands_as_sole_owner_or_partner_of_company: false + owned_company_carries_on_trade: true + owned_company_is_main_employment: true + owned_company_income_share: 50_000 + owned_company_capital: 1_000_000 + owned_company_holding_value: 12_000 + benunits: + benunit: + members: person + households: + household: + members: person + corporate_wealth: 12_000 + output: + uc_company_owner_treatment_applies: [false] + uc_mif_capped_earned_income: [0] + uc_assessable_capital: 12_000 + +- name: A company carrying on neither a trade nor a property business is outside the rule + # Reg. 77(1) needs a company "carrying on a trade or a property business". + period: 2026 + absolute_error_margin: 0 + input: + people: + person: + age: 40 + stands_as_sole_owner_or_partner_of_company: true + owned_company_capital: 50_000 + owned_company_holding_value: 10_000 + benunits: + benunit: + members: person + households: + household: + members: person + corporate_wealth: 10_000 + output: + uc_company_owner_treatment_applies: [false] + uc_assessable_capital: 10_000 + +- name: Each partner's company is attributed to the couple's capital + # Couples' capital is combined (reg. 18(2)). Two 50% owners of the same + # trading company, neither working in it: each is treated as holding half + # of the 30,000 company capital and each 5,000 holding is disregarded. + # 10,000 + 15,000 + 15,000 - 5,000 - 5,000 = 30,000. + period: 2026 + absolute_error_margin: 0 + input: + people: + a: + age: 40 + stands_as_sole_owner_or_partner_of_company: true + owned_company_carries_on_trade: true + owned_company_capital: 15_000 + owned_company_holding_value: 5_000 + b: + age: 40 + stands_as_sole_owner_or_partner_of_company: true + owned_company_carries_on_trade: true + owned_company_capital: 15_000 + owned_company_holding_value: 5_000 + benunits: + benunit: + members: [a, b] + households: + household: + members: [a, b] + corporate_wealth: 10_000 + output: + uc_assessable_capital: 30_000 + +- name: The company's capital can take the owner over the capital limit + # 1,000 of savings and a disregarded 1,000 holding, but the company holds + # 20,000 of cash not used wholly and exclusively for its trade: assessable + # capital 1,000 + 1,000 - 1,000 + 20,000 = 21,000 exceeds the 16,000 limit + # (reg. 18(1)), so no award. + period: 2026 + absolute_error_margin: 0 + input: + people: + person: + age: 40 + stands_as_sole_owner_or_partner_of_company: true + owned_company_carries_on_trade: true + is_engaged_in_owned_company_trade: true + owned_company_capital: 20_000 + owned_company_holding_value: 1_000 + benunits: + benunit: + members: person + households: + household: + members: person + savings: 1_000 + corporate_wealth: 1_000 + output: + uc_assessable_capital: 21_000 + is_uc_eligible: false + universal_credit: 0 diff --git a/policyengine_uk/tests/test_uc_company_owner_properties.py b/policyengine_uk/tests/test_uc_company_owner_properties.py new file mode 100644 index 0000000000..082672877b --- /dev/null +++ b/policyengine_uk/tests/test_uc_company_owner_properties.py @@ -0,0 +1,375 @@ +"""Property-based tests for Universal Credit's company owner rule. + +The Universal Credit Regulations 2013 reg. 77 treat a person who stands in a +position analogous to a sole owner or partner of a company carrying on a trade +or a property business as that sole owner or partner: their holding in the +company is disregarded and they are treated as possessing the company's +capital (or their share of it), less assets used wholly and exclusively for a +trade while they work in it (reg. 77(2), (3)(a)); a trading company's income +is their self-employed earnings, in addition to any pay from it (reg. 77(3)(b), +(4)); and if the trade is their main employment the minimum income floor +applies (reg. 77(3)(c)). None of it applies where their income from the +company is employed earnings under the intermediaries rules from their main +employment (reg. 77(5)). + +Invariants, for any generated benefit units: + +1. Inert: when the person is not a company's sole owner or partner, or reg. + 77(5) applies, or the company carries on neither a trade nor a property + business, no company fact changes Universal Credit, its capital or its + earned income. +2. Capital identity: assessable capital equals the capital without the rule, + plus each owner's company capital net of disregarded trade assets, less + each owner's holding, floored at zero. +3. Earnings identity: without the floor, gross earned income is pay plus + self-employment profit plus the company income share for a trading + company, and nothing for a property-only company. +4. Floor: a trading-company owner whose main employment it is and who is not + in a start-up period has earned income of at least the floor. +5. Monotone: Universal Credit is non-increasing in the company income share + and in the company's capital, and non-decreasing in the value of the + disregarded holding and of disregarded trade assets. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +YEAR = 2026 +PROPERTY_SETTINGS = settings( + max_examples=6, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +COMPANY_FACTS = [ + "stands_as_sole_owner_or_partner_of_company", + "owned_company_carries_on_trade", + "owned_company_carries_on_property_business", + "owned_company_income_is_intermediary_employed_earnings", + "owned_company_is_main_employment", + "is_engaged_in_owned_company_trade", + "owned_company_income_share", + "owned_company_capital", + "owned_company_trade_assets", + "owned_company_holding_value", +] +VARIABLES = [ + "universal_credit", + "uc_assessable_capital", + "uc_earned_income", +] +PERSON_VARIABLES = [ + "uc_mif_capped_earned_income", + "uc_minimum_income_floor", + "uc_mif_applies", +] + +money = st.floats(0, 60_000, allow_nan=False, allow_infinity=False) + + +def close(a, b): + # Simulation outputs are float32: allow for its rounding at these sizes. + return abs(a - b) <= 1e-2 + 1e-6 * abs(b) + + +capital = st.one_of(st.just(0.0), st.floats(0, 40_000)) + + +@st.composite +def companies(draw): + company_capital = draw(capital) + return dict( + stands_as_sole_owner_or_partner_of_company=draw(st.booleans()), + owned_company_carries_on_trade=draw(st.booleans()), + owned_company_carries_on_property_business=draw(st.booleans()), + owned_company_income_is_intermediary_employed_earnings=draw( + st.sampled_from([False, False, False, True]) + ), + owned_company_is_main_employment=draw(st.booleans()), + is_engaged_in_owned_company_trade=draw(st.booleans()), + owned_company_income_share=draw(money), + owned_company_capital=company_capital, + owned_company_trade_assets=draw(st.floats(0, 1)) * company_capital, + owned_company_holding_value=draw(capital), + ) + + +@st.composite +def adults(draw): + return dict( + age=draw(st.integers(25, 60)), + employment_income=draw(st.one_of(st.just(0.0), money)), + self_employment_income=draw(st.one_of(st.just(0.0), money)), + uc_is_in_startup_period=draw(st.booleans()), + company=draw(companies()), + ) + + +@st.composite +def units(draw): + return dict( + adults=draw(st.lists(adults(), min_size=1, max_size=2)), + children=draw(st.integers(0, 2)), + rent=draw(st.floats(0, 15_000)), + savings=draw(capital), + corporate_wealth=draw(capital), + ) + + +def situation(variants): + """One benefit unit per (unit, company-fact overrides) pair.""" + people, benunits, households = {}, {}, {} + for i, (unit, overrides) in enumerate(variants): + names = [] + for j, adult in enumerate(unit["adults"]): + name = f"p{i}_{j}" + facts = {**adult["company"], **overrides.get(j, {})} + people[name] = { + "age": {YEAR: adult["age"]}, + "employment_income": {YEAR: adult["employment_income"]}, + "self_employment_income": {YEAR: adult["self_employment_income"]}, + "uc_is_in_startup_period": {YEAR: adult["uc_is_in_startup_period"]}, + **{k: {YEAR: v} for k, v in facts.items()}, + } + names.append(name) + for k in range(unit["children"]): + name = f"c{i}_{k}" + people[name] = {"age": {YEAR: 5 + k}} + names.append(name) + benunits[f"b{i}"] = {"members": names, "would_claim_uc": {YEAR: True}} + households[f"h{i}"] = { + "members": names, + "tenure_type": {YEAR: "RENT_PRIVATELY"}, + "rent": {YEAR: unit["rent"]}, + "savings": {YEAR: unit["savings"]}, + "corporate_wealth": {YEAR: unit["corporate_wealth"]}, + } + return {"people": people, "benunits": benunits, "households": households} + + +def calculate(variants): + sim = Simulation(situation=situation(variants)) + out = {v: np.asarray(sim.calculate(v, YEAR)) for v in VARIABLES} + for v in PERSON_VARIABLES: + out[v] = np.asarray(sim.calculate(v, YEAR)) + for v in COMPANY_FACTS + [ + "employment_income", + "self_employment_income", + "uc_is_in_startup_period", + "uc_company_owner_treatment_applies", + ]: + out[v] = np.asarray(sim.calculate(v, YEAR)) + # People are laid out unit by unit; record each adult's position. + positions, cursor = [], 0 + for unit, _ in variants: + positions.append(list(range(cursor, cursor + len(unit["adults"])))) + cursor += len(unit["adults"]) + unit["children"] + out["adult_positions"] = positions + return out + + +def no_company(unit): + return { + j: { + k: (False if isinstance(v, bool) else 0.0) + for k, v in adult["company"].items() + } + for j, adult in enumerate(unit["adults"]) + } + + +def applies(company): + return ( + company["stands_as_sole_owner_or_partner_of_company"] + and ( + company["owned_company_carries_on_trade"] + or company["owned_company_carries_on_property_business"] + ) + and not company["owned_company_income_is_intermediary_employed_earnings"] + ) + + +@PROPERTY_SETTINGS +@given(st.lists(units(), min_size=2, max_size=4)) +def test_rule_is_inert_when_it_does_not_apply(unit_list): + # Force every adult outside reg. 77 in one of three ways, then compare + # with the same units carrying no company facts at all. + variants, baselines = [], [] + for i, unit in enumerate(unit_list): + excluded = {} + for j, adult in enumerate(unit["adults"]): + way = (i + j) % 3 + if way == 0: + excluded[j] = {"stands_as_sole_owner_or_partner_of_company": False} + elif way == 1: + excluded[j] = { + "owned_company_income_is_intermediary_employed_earnings": True + } + else: + excluded[j] = { + "owned_company_carries_on_trade": False, + "owned_company_carries_on_property_business": False, + } + variants.append((unit, excluded)) + baselines.append((unit, no_company(unit))) + got, base = calculate(variants), calculate(baselines) + assert not got["uc_company_owner_treatment_applies"].any() + for v in VARIABLES + ["uc_mif_capped_earned_income", "uc_mif_applies"]: + np.testing.assert_allclose(got[v], base[v], rtol=0, atol=1e-6, err_msg=v) + + +@PROPERTY_SETTINGS +@given(st.lists(units(), min_size=2, max_size=4)) +def test_capital_and_earnings_identities(unit_list): + got = calculate([(u, {}) for u in unit_list]) + base = calculate([(u, no_company(u)) for u in unit_list]) + for i, unit in enumerate(unit_list): + net_company_capital = 0.0 + for j, adult in enumerate(unit["adults"]): + c = adult["company"] + k = got["adult_positions"][i][j] + assert got["uc_company_owner_treatment_applies"][k] == applies(c) + if applies(c): + disregarded = ( + c["owned_company_trade_assets"] + if c["owned_company_carries_on_trade"] + and c["is_engaged_in_owned_company_trade"] + else 0.0 + ) + net_company_capital += ( + max(0.0, c["owned_company_capital"] - disregarded) + - c["owned_company_holding_value"] + ) + # Earnings identity, compared before the floor. + company_earnings = ( + c["owned_company_income_share"] + if applies(c) and c["owned_company_carries_on_trade"] + else 0.0 + ) + gross = ( + adult["employment_income"] + + adult["self_employment_income"] + + company_earnings + ) + capped = got["uc_mif_capped_earned_income"][k] + if got["uc_mif_applies"][k]: + floor = got["uc_minimum_income_floor"][k] + assert close(capped, max(gross, floor)) + else: + assert close(capped, gross) + # Floor: reg. 77(3)(c) with reg. 62(5). + trade_main = ( + applies(c) + and c["owned_company_carries_on_trade"] + and c["owned_company_is_main_employment"] + ) + if trade_main and not adult["uc_is_in_startup_period"]: + assert got["uc_mif_applies"][k] + assert capped >= got["uc_minimum_income_floor"][k] - 1e-2 + if adult["uc_is_in_startup_period"]: + assert not got["uc_mif_applies"][k] + expected = max(0.0, base["uc_assessable_capital"][i] + net_company_capital) + assert close(got["uc_assessable_capital"][i], expected) + + +def _owner(unit, **facts): + """Make the first adult a sole owner of a trading company.""" + company = { + "stands_as_sole_owner_or_partner_of_company": True, + "owned_company_carries_on_trade": True, + "owned_company_income_is_intermediary_employed_earnings": False, + **facts, + } + return {0: company} + + +@PROPERTY_SETTINGS +@given( + st.lists(units(), min_size=1, max_size=3), + st.floats(1, 30_000), + st.floats(1, 30_000), +) +def test_universal_credit_monotone_in_company_facts(unit_list, bump, asset_bump): + variants = [] + for unit in unit_list: + c = unit["adults"][0]["company"] + low = _owner(unit) + variants += [ + (unit, low), + ( + unit, + _owner( + unit, + owned_company_income_share=c["owned_company_income_share"] + bump, + ), + ), + ( + unit, + _owner(unit, owned_company_capital=c["owned_company_capital"] + bump), + ), + ( + unit, + _owner( + unit, + owned_company_holding_value=c["owned_company_holding_value"] + bump, + ), + ), + ( + unit, + _owner( + unit, + is_engaged_in_owned_company_trade=True, + owned_company_capital=c["owned_company_capital"] + asset_bump, + owned_company_trade_assets=c["owned_company_trade_assets"] + + asset_bump, + ), + ), + (unit, _owner(unit, is_engaged_in_owned_company_trade=True)), + ] + uc = calculate(variants)["universal_credit"].reshape(-1, 6) + tol = 1e-6 + base, more_income, more_capital, more_holding, more_assets, engaged = uc.T + assert (more_income <= base + tol).all() + assert (more_capital <= base + tol).all() + assert (more_holding >= base - tol).all() + # Extra capital that is itself a disregarded trade asset changes nothing + # once the owner works in the trade. + np.testing.assert_allclose(more_assets, engaged, rtol=0, atol=1e-6) + + +@PROPERTY_SETTINGS +@given(st.lists(units(), min_size=1, max_size=3), money) +def test_property_company_gives_no_earnings(unit_list, share): + variants = [] + for unit in unit_list: + facts = dict( + owned_company_carries_on_trade=False, + owned_company_carries_on_property_business=True, + ) + variants += [ + ( + unit, + _owner( + unit, + **facts, + owned_company_income_share=0.0, + owned_company_is_main_employment=False, + ), + ), + ( + unit, + _owner( + unit, + **facts, + owned_company_income_share=share, + owned_company_is_main_employment=True, + ), + ), + ] + got = calculate(variants) + for v in ["universal_credit", "uc_earned_income"]: + a, b = got[v].reshape(-1, 2).T + np.testing.assert_allclose(a, b, rtol=0, atol=1e-6, err_msg=v) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_capital.py new file mode 100644 index 0000000000..b51419aa61 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_capital.py @@ -0,0 +1,44 @@ +from policyengine_uk.model_api import * + + +class uc_company_capital(Variable): + value_type = float + entity = Person + label = "Universal Credit capital treated as held from an owned company" + documentation = ( + "The value of the company's capital, or the person's share of it, " + "which Universal Credit treats the person as possessing when they " + "stand as the company's sole owner or partner. Assets used wholly and " + "exclusively for the company's trade are disregarded while the person " + "is engaged in activities in the course of that trade. This follows " + "the regulation: DWP guidance (ADM H4372) instead disregards all of " + "the company capital while the person works in the business, and " + "H1880 paraphrases the test as 'wholly or mainly', both closer to the " + "older Income Support rule." + ) + definition_period = YEAR + unit = GBP + quantity_type = STOCK + reference = [ + dict( + title="The Universal Credit Regulations 2013 reg. 77(2) and (3)(a)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/77", + ), + dict( + title="Advice for Decision Making, Chapter H4, paras. H4367 and H4371-H4373", + href="https://www.gov.uk/government/publications/advice-for-decision-making-staff-guide", + ), + ] + + def formula(person, period, parameters): + applies = person("uc_company_owner_treatment_applies", period) + trade_assets_disregarded = person( + "owned_company_carries_on_trade", period + ) & person("is_engaged_in_owned_company_trade", period) + disregarded = where( + trade_assets_disregarded, + person("owned_company_trade_assets", period), + 0, + ) + capital = max_(0, person("owned_company_capital", period) - disregarded) + return where(applies, capital, 0) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_gainful_self_employment.py b/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_gainful_self_employment.py new file mode 100644 index 0000000000..00cc3e9e02 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_gainful_self_employment.py @@ -0,0 +1,32 @@ +from policyengine_uk.model_api import * + + +class uc_company_gainful_self_employment(Variable): + value_type = bool + entity = Person + label = "Universal Credit treats the person as gainfully self-employed through an owned company" + documentation = ( + "Whether Universal Credit treats the person as in gainful " + "self-employment because they stand as sole owner or partner of a " + "company carrying on a trade and their activities in the course of " + "that trade are their main employment. The minimum income floor then " + "applies (unless they are in a start-up period)." + ) + definition_period = YEAR + reference = [ + dict( + title="The Universal Credit Regulations 2013 reg. 77(3)(c)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/77", + ), + dict( + title="Advice for Decision Making, Chapter H4, para. H4375", + href="https://www.gov.uk/government/publications/advice-for-decision-making-staff-guide", + ), + ] + + def formula(person, period, parameters): + return ( + person("uc_company_owner_treatment_applies", period) + & person("owned_company_carries_on_trade", period) + & person("owned_company_is_main_employment", period) + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_holding_disregard.py b/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_holding_disregard.py new file mode 100644 index 0000000000..80691ccbc8 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_holding_disregard.py @@ -0,0 +1,26 @@ +from policyengine_uk.model_api import * + + +class uc_company_holding_disregard(Variable): + value_type = float + entity = Person + label = "Universal Credit disregard of a holding in an owned company" + documentation = ( + "The value of the person's holding in the company in which they stand " + "as sole owner or partner, which Universal Credit disregards; the " + "company's own capital is counted instead (uc_company_capital)." + ) + definition_period = YEAR + unit = GBP + quantity_type = STOCK + reference = dict( + title="The Universal Credit Regulations 2013 reg. 77(2)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/77", + ) + + def formula(person, period, parameters): + return where( + person("uc_company_owner_treatment_applies", period), + person("owned_company_holding_value", period), + 0, + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_owner_treatment_applies.py b/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_owner_treatment_applies.py new file mode 100644 index 0000000000..b0f8154c15 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_owner_treatment_applies.py @@ -0,0 +1,36 @@ +from policyengine_uk.model_api import * + + +class uc_company_owner_treatment_applies(Variable): + value_type = bool + entity = Person + label = "Universal Credit treats the person as a company's sole owner or partner" + documentation = ( + "Whether the person stands in a position analogous to a sole owner or " + "partner in relation to a company that carries on a trade or a " + "property business, so that Universal Credit treats them as that sole " + "owner or partner. This does not apply where the person derives " + "employed earnings from the company under the intermediaries or " + "managed service company rules from their main employment." + ) + definition_period = YEAR + reference = [ + dict( + title="The Universal Credit Regulations 2013 reg. 77(1) and (5)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/77", + ), + dict( + title="The Universal Credit Regulations (Northern Ireland) 2016 reg. 77(1) and (5)", + href="https://www.legislation.gov.uk/nisr/2016/216/regulation/77", + ), + ] + + def formula(person, period, parameters): + carries_on_business = person("owned_company_carries_on_trade", period) | person( + "owned_company_carries_on_property_business", period + ) + return ( + person("stands_as_sole_owner_or_partner_of_company", period) + & carries_on_business + & ~person("owned_company_income_is_intermediary_employed_earnings", period) + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_self_employed_earnings.py b/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_self_employed_earnings.py new file mode 100644 index 0000000000..9949ec11bc --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_self_employed_earnings.py @@ -0,0 +1,37 @@ +from policyengine_uk.model_api import * + + +class uc_company_self_employed_earnings(Variable): + value_type = float + entity = Person + label = "Universal Credit self-employed earnings from an owned company" + documentation = ( + "The income of a company carrying on a trade, or the person's share " + "of it, which Universal Credit treats as the person's self-employed " + "earnings when they stand as the company's sole owner or partner. It " + "is in addition to any pay they receive as the company's director or " + "employee. A company that carries on only a property business gives " + "no earnings." + ) + definition_period = YEAR + unit = GBP + reference = [ + dict( + title="The Universal Credit Regulations 2013 reg. 77(3)(b) and (4)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/77", + ), + dict( + title="Advice for Decision Making, Chapter H4, paras. H4374 and H4376", + href="https://www.gov.uk/government/publications/advice-for-decision-making-staff-guide", + ), + ] + + def formula(person, period, parameters): + trading_company_owner = person( + "uc_company_owner_treatment_applies", period + ) & person("owned_company_carries_on_trade", period) + return where( + trading_company_owner, + person("owned_company_income_share", period), + 0, + ) 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..c9d5e6e711 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 @@ -8,10 +8,21 @@ class uc_mif_applies(Variable): documentation = ( "Whether the Minimum Income Floor should be used to determine UC entitlement" ) - 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/2021-04-06", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/77", + ] definition_period = YEAR def formula(person, period, parameters): has_self_empl_income = person("self_employment_income", period) > 0 + # A person whose main employment is the trade of a company they stand + # as sole owner or partner of is treated as gainfully self-employed, + # so the floor applies (UC Regs 2013 reg. 77(3)(c)). + company_gainful_self_employment = person( + "uc_company_gainful_self_employment", period + ) in_startup_period = person("uc_is_in_startup_period", period) - return has_self_empl_income & ~in_startup_period + return ( + has_self_empl_income | company_gainful_self_employment + ) & ~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 index 03c7deb25c..d936d15702 100644 --- 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 @@ -14,6 +14,10 @@ def formula(person, period, parameters): "employment_income", "self_employment_income", "miscellaneous_income", + # Income of a company the person stands as sole owner or partner + # of, treated as self-employed earnings (UC Regs 2013 reg. 77(3)(b)), + # in addition to their pay as its director or employee (reg. 77(4)). + "uc_company_self_employed_earnings", ] bi = parameters(period).gov.contrib.ubi_center.basic_income if bi.interactions.include_in_means_tests: diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_assessable_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_assessable_capital.py index eef9c8f729..0ba1c19f35 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_assessable_capital.py @@ -7,7 +7,9 @@ class uc_assessable_capital(Variable): label = "Universal Credit assessable capital" documentation = ( "Universal Credit capital counted from the configured capital sources, " - "with benunit-reported overrides when available." + "with benunit-reported overrides when available, and with a holding " + "in a company the person stands as sole owner or partner of replaced " + "by the company's capital." ) definition_period = YEAR unit = GBP @@ -40,4 +42,12 @@ def formula(benunit, period, parameters): reported_capital, household_capital_proxy, ) - return max_(0, assessed_capital) + # A person who stands as sole owner or partner of a company has their + # holding in it disregarded and is treated as possessing the company's + # capital (or their share of it) instead (UC Regs 2013 reg. 77(2), + # (3)(a)). Both the household sources and uc_reported_capital value + # the assets held, so the substitution applies to either. + company_capital = add(benunit, period, ["uc_company_capital"]) - add( + benunit, period, ["uc_company_holding_disregard"] + ) + return max_(0, assessed_capital + company_capital) diff --git a/policyengine_uk/variables/input/owned_company/README.md b/policyengine_uk/variables/input/owned_company/README.md new file mode 100644 index 0000000000..4bfd490669 --- /dev/null +++ b/policyengine_uk/variables/input/owned_company/README.md @@ -0,0 +1,11 @@ +# Owned company + +Facts about a company in which a person stands in a position analogous to a +sole owner or partner (an owner-manager trading through a limited company, or +a controlling shareholder in a property company). Universal Credit treats such +a person as the sole owner or partner (Universal Credit Regulations 2013 reg. +77; Universal Credit Regulations (Northern Ireland) 2016 reg. 77); see +`gov/dwp/universal_credit/company/`. + +Every input defaults to "no such company", so datasets that do not carry these +facts are unaffected. diff --git a/policyengine_uk/variables/input/owned_company/is_engaged_in_owned_company_trade.py b/policyengine_uk/variables/input/owned_company/is_engaged_in_owned_company_trade.py new file mode 100644 index 0000000000..7594ad73c0 --- /dev/null +++ b/policyengine_uk/variables/input/owned_company/is_engaged_in_owned_company_trade.py @@ -0,0 +1,25 @@ +from policyengine_uk.model_api import * + + +class is_engaged_in_owned_company_trade(Variable): + value_type = bool + entity = Person + label = "engaged in the trade of an owned company" + documentation = ( + "Whether the person is engaged in activities in the course of the " + "trade of the company in which they stand as sole owner or partner. " + "DWP guidance treats any work for the company, however little (such " + "as taking its telephone messages), as engagement." + ) + definition_period = YEAR + default_value = False + reference = [ + dict( + title="The Universal Credit Regulations 2013 reg. 77(3)(a)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/77", + ), + dict( + title="Advice for Decision Making, Chapter H4, para. H4373", + href="https://www.gov.uk/government/publications/advice-for-decision-making-staff-guide", + ), + ] diff --git a/policyengine_uk/variables/input/owned_company/owned_company_capital.py b/policyengine_uk/variables/input/owned_company/owned_company_capital.py new file mode 100644 index 0000000000..8155d67c80 --- /dev/null +++ b/policyengine_uk/variables/input/owned_company/owned_company_capital.py @@ -0,0 +1,30 @@ +from policyengine_uk.model_api import * + + +class owned_company_capital(Variable): + value_type = float + entity = Person + label = "owned company capital" + documentation = ( + "The value of the capital of the company in which the person stands " + "as sole owner or partner, or the person's share of that value. DWP " + "guidance takes the company's net value (its capital less its " + "liabilities) and a person's share as their fraction of its shares. " + "Includes any assets used wholly and exclusively for the company's " + "trade (recorded again in owned_company_trade_assets)." + ) + definition_period = YEAR + unit = GBP + default_value = 0 + quantity_type = STOCK + uprating = "gov.economic_assumptions.indices.obr.per_capita.gdp" + reference = [ + dict( + title="The Universal Credit Regulations 2013 reg. 77(2)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/77", + ), + dict( + title="Advice for Decision Making, Chapter H1, paras. H1892-H1893", + href="https://www.gov.uk/government/publications/advice-for-decision-making-staff-guide", + ), + ] diff --git a/policyengine_uk/variables/input/owned_company/owned_company_carries_on_property_business.py b/policyengine_uk/variables/input/owned_company/owned_company_carries_on_property_business.py new file mode 100644 index 0000000000..d44f14ef31 --- /dev/null +++ b/policyengine_uk/variables/input/owned_company/owned_company_carries_on_property_business.py @@ -0,0 +1,25 @@ +from policyengine_uk.model_api import * + + +class owned_company_carries_on_property_business(Variable): + value_type = bool + entity = Person + label = "owned company carries on a property business" + documentation = ( + "Whether the company in which the person stands as sole owner or " + "partner carries on a property business within the meaning of section " + "204 of the Corporation Tax Act 2009, such as letting land or " + "buildings it owns as an investment." + ) + definition_period = YEAR + default_value = False + reference = [ + dict( + title="The Universal Credit Regulations 2013 reg. 77(1) and (6)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/77", + ), + dict( + title="Corporation Tax Act 2009 s. 204", + href="https://www.legislation.gov.uk/ukpga/2009/4/section/204", + ), + ] diff --git a/policyengine_uk/variables/input/owned_company/owned_company_carries_on_trade.py b/policyengine_uk/variables/input/owned_company/owned_company_carries_on_trade.py new file mode 100644 index 0000000000..f37f2c6d16 --- /dev/null +++ b/policyengine_uk/variables/input/owned_company/owned_company_carries_on_trade.py @@ -0,0 +1,25 @@ +from policyengine_uk.model_api import * + + +class owned_company_carries_on_trade(Variable): + value_type = bool + entity = Person + label = "owned company carries on a trade" + documentation = ( + "Whether the company in which the person stands as sole owner or " + "partner carries on a trade: a business with a profit-seeking motive, " + "usually providing goods or services to customers on a commercial " + "basis." + ) + definition_period = YEAR + default_value = False + reference = [ + dict( + title="The Universal Credit Regulations 2013 reg. 77(1) and (3)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/77", + ), + dict( + title="Advice for Decision Making, Chapter H4, para. H4370", + href="https://www.gov.uk/government/publications/advice-for-decision-making-staff-guide", + ), + ] diff --git a/policyengine_uk/variables/input/owned_company/owned_company_holding_value.py b/policyengine_uk/variables/input/owned_company/owned_company_holding_value.py new file mode 100644 index 0000000000..2624e2cc7e --- /dev/null +++ b/policyengine_uk/variables/input/owned_company/owned_company_holding_value.py @@ -0,0 +1,22 @@ +from policyengine_uk.model_api import * + + +class owned_company_holding_value(Variable): + value_type = float + entity = Person + label = "value of holding in owned company" + documentation = ( + "The value of the person's holding (their shares) in the company in " + "which they stand as sole owner or partner, to the extent that it is " + "already counted in the household's capital (for example within " + "corporate_wealth) or in uc_reported_capital." + ) + definition_period = YEAR + unit = GBP + default_value = 0 + quantity_type = STOCK + uprating = "gov.economic_assumptions.indices.obr.per_capita.gdp" + reference = dict( + title="The Universal Credit Regulations 2013 reg. 77(2)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/77", + ) diff --git a/policyengine_uk/variables/input/owned_company/owned_company_income_is_intermediary_employed_earnings.py b/policyengine_uk/variables/input/owned_company/owned_company_income_is_intermediary_employed_earnings.py new file mode 100644 index 0000000000..00386bfc5d --- /dev/null +++ b/policyengine_uk/variables/input/owned_company/owned_company_income_is_intermediary_employed_earnings.py @@ -0,0 +1,29 @@ +from policyengine_uk.model_api import * + + +class owned_company_income_is_intermediary_employed_earnings(Variable): + value_type = bool + entity = Person + label = "owned company income is intermediary employed earnings" + documentation = ( + "Whether the person derives income from the company that is employed " + "earnings under Chapter 8 (workers under arrangements made by " + "intermediaries), Chapter 9 (managed service companies) or Chapter 10 " + "(workers' services provided through intermediaries) of Part 2 of the " + "Income Tax (Earnings and Pensions) Act 2003, derived from activities " + "that are the person's main employment. Where this holds, Universal " + "Credit does not treat the person as the company's sole owner or " + "partner, and that income counts as employed earnings instead." + ) + definition_period = YEAR + default_value = False + reference = [ + dict( + title="The Universal Credit Regulations 2013 reg. 77(5)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/77", + ), + dict( + title="Income Tax (Earnings and Pensions) Act 2003 Part 2 Chapters 8-10", + href="https://www.legislation.gov.uk/ukpga/2003/1/part/2", + ), + ] diff --git a/policyengine_uk/variables/input/owned_company/owned_company_income_share.py b/policyengine_uk/variables/input/owned_company/owned_company_income_share.py new file mode 100644 index 0000000000..3e6dab4870 --- /dev/null +++ b/policyengine_uk/variables/input/owned_company/owned_company_income_share.py @@ -0,0 +1,31 @@ +from policyengine_uk.model_api import * + + +class owned_company_income_share(Variable): + value_type = float + entity = Person + label = "share of owned company income" + documentation = ( + "The income of the company in which the person stands as sole owner " + "or partner, or the person's share of that income, calculated as " + "self-employed earnings would be: the company's actual receipts less " + "its permitted expenses. Pay the company gives the person as a " + "director or employee is a company expense here and is recorded in " + "employment_income instead. Dividends the person receives from the " + "company are paid out of this income, so they are not added to it." + ) + definition_period = YEAR + unit = GBP + default_value = 0 + quantity_type = FLOW + uprating = "gov.economic_assumptions.indices.obr.per_capita.mixed_income" + reference = [ + dict( + title="The Universal Credit Regulations 2013 reg. 77(3)(b)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/77", + ), + dict( + title="The Universal Credit Regulations 2013 reg. 57", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/57", + ), + ] diff --git a/policyengine_uk/variables/input/owned_company/owned_company_is_main_employment.py b/policyengine_uk/variables/input/owned_company/owned_company_is_main_employment.py new file mode 100644 index 0000000000..c39d973bc1 --- /dev/null +++ b/policyengine_uk/variables/input/owned_company/owned_company_is_main_employment.py @@ -0,0 +1,18 @@ +from policyengine_uk.model_api import * + + +class owned_company_is_main_employment(Variable): + value_type = bool + entity = Person + label = "owned company trade is main employment" + documentation = ( + "Whether the person's activities in the course of the trade of the " + "company in which they stand as sole owner or partner are their main " + "employment." + ) + definition_period = YEAR + default_value = False + reference = dict( + title="The Universal Credit Regulations 2013 reg. 77(3)(c)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/77", + ) diff --git a/policyengine_uk/variables/input/owned_company/owned_company_trade_assets.py b/policyengine_uk/variables/input/owned_company/owned_company_trade_assets.py new file mode 100644 index 0000000000..e37de76746 --- /dev/null +++ b/policyengine_uk/variables/input/owned_company/owned_company_trade_assets.py @@ -0,0 +1,20 @@ +from policyengine_uk.model_api import * + + +class owned_company_trade_assets(Variable): + value_type = float + entity = Person + label = "owned company trade assets" + documentation = ( + "The part of owned_company_capital made up of the company's assets " + "that are used wholly and exclusively for the purposes of its trade." + ) + definition_period = YEAR + unit = GBP + default_value = 0 + quantity_type = STOCK + uprating = "gov.economic_assumptions.indices.obr.per_capita.gdp" + reference = dict( + title="The Universal Credit Regulations 2013 reg. 77(3)(a)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/77", + ) diff --git a/policyengine_uk/variables/input/owned_company/stands_as_sole_owner_or_partner_of_company.py b/policyengine_uk/variables/input/owned_company/stands_as_sole_owner_or_partner_of_company.py new file mode 100644 index 0000000000..bf8ab44f7d --- /dev/null +++ b/policyengine_uk/variables/input/owned_company/stands_as_sole_owner_or_partner_of_company.py @@ -0,0 +1,30 @@ +from policyengine_uk.model_api import * + + +class stands_as_sole_owner_or_partner_of_company(Variable): + value_type = bool + entity = Person + label = "stands as sole owner or partner of a company" + documentation = ( + "Whether the person stands in a position analogous to that of a sole " + "owner or partner in relation to a company. This is a question of " + "fact. DWP guidance treats a person as like a sole owner when they " + "have total influence over the day-to-day running of the company (for " + "example owning 99% of the shares), and as like a partner when the " + "company has a small number of shareholders and the person has some " + "meaningful influence over its day-to-day running. A shareholder in a " + "company with many shareholders is an investor, not an owner or " + "partner. The person need not work for the company." + ) + definition_period = YEAR + default_value = False + reference = [ + dict( + title="The Universal Credit Regulations 2013 reg. 77(1)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/77", + ), + dict( + title="Advice for Decision Making, Chapter H4, paras. H4362-H4364", + href="https://www.gov.uk/government/publications/advice-for-decision-making-staff-guide", + ), + ] From 57c3c266d7b7df519d5d8db00639ed7e3a42ccbb Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 17:18:00 -0400 Subject: [PATCH 2/6] Document reg 77 research findings; add Axiom parity test and changelog Co-Authored-By: Claude Opus 5.5 --- changelog.d/1948.added.md | 1 + .../test_uc_company_owner_axiom_parity.py | 193 ++++++++++++++++++ .../uc_company_self_employed_earnings.py | 5 +- .../income/income_floor/uc_mif_applies.py | 4 +- ...ncome_is_intermediary_employed_earnings.py | 4 +- .../owned_company_income_share.py | 12 +- 6 files changed, 212 insertions(+), 7 deletions(-) create mode 100644 changelog.d/1948.added.md create mode 100644 policyengine_uk/tests/test_uc_company_owner_axiom_parity.py diff --git a/changelog.d/1948.added.md b/changelog.d/1948.added.md new file mode 100644 index 0000000000..51af73928b --- /dev/null +++ b/changelog.d/1948.added.md @@ -0,0 +1 @@ +- Universal Credit now applies regulation 77 to a person who stands as sole owner or partner of a company carrying on a trade or property business: the company's income counts as their self-employed earnings on top of any pay from it, the minimum income floor applies when the trade is their main employment, and the company's capital (less trade assets while they work in the trade) replaces the value of their shares. New `owned_company_*` inputs describe the company; every input defaults to no such company, so existing datasets are unchanged. diff --git a/policyengine_uk/tests/test_uc_company_owner_axiom_parity.py b/policyengine_uk/tests/test_uc_company_owner_axiom_parity.py new file mode 100644 index 0000000000..c6056dfbcd --- /dev/null +++ b/policyengine_uk/tests/test_uc_company_owner_axiom_parity.py @@ -0,0 +1,193 @@ +"""Differential test: Universal Credit reg. 77 against the Axiom encoding. + +The four cases are the tests of TheAxiomFoundation/rulespec-uk +uk/regulations/uksi/2013/376/77.test.yaml (commit 0644db8), which encodes the +Universal Credit Regulations 2013 reg. 77 independently of this model. Axiom +works in monthly assessment periods; income amounts here are the monthly +figures times 12, and capital amounts are unchanged. + +Axiom input -> policyengine-uk input: +- person_stands_in_position_analogous_to_sole_owner_or_partner_in_relation_to_company + -> stands_as_sole_owner_or_partner_of_company +- company_carries_on_trade -> owned_company_carries_on_trade +- company_carries_on_property_business_within_meaning_of_corporation_tax_act_2009_section_204 + -> owned_company_carries_on_property_business +- person_derives_company_income_that_is_employed_earnings_by_itepa_part_2_chapter_8/9/10 + and intermediary_employed_earnings_derived_from_person_main_employment_activities + -> owned_company_income_is_intermediary_employed_earnings (their conjunction) +- company_capital_value_or_person_share_value -> owned_company_capital +- company_trade_asset_value_or_person_share_used_wholly_and_exclusively_for_trade + -> owned_company_trade_assets +- person_engaged_in_activities_in_course_of_company_trade + -> is_engaged_in_owned_company_trade +- person_holding_value_in_company -> owned_company_holding_value +- company_income_or_person_share_calculated_as_self_employed_earnings_under_regulation_57 + -> owned_company_income_share +- person_employed_earnings_as_director_or_employee_of_company -> employment_income +- person_activities_in_course_of_company_trade_are_main_employment + -> owned_company_is_main_employment +""" + +import pytest + +from policyengine_uk import Simulation + +YEAR = 2026 +MONTHS = 12 + +CASES = { + "trade_company_main_employment_treats_capital_income_and_mif": dict( + inputs=dict( + stands=True, + trade=True, + property=False, + intermediary=False, + capital=100_000, + trade_assets=40_000, + engaged=True, + holding=25_000, + income=3_000, + director_pay=1_200, + main=True, + ), + outputs=dict( + applies=True, + trade_assets_disregarded=40_000, + capital=60_000, + holding_disregarded=25_000, + company_earnings=3_000, + company_and_director_earnings=4_200, + gainful=True, + mif=True, + ), + ), + "intermediary_employed_earnings_main_employment_excludes_regulation": dict( + inputs=dict( + stands=True, + trade=True, + property=False, + intermediary=True, + capital=100_000, + trade_assets=40_000, + engaged=True, + holding=25_000, + income=3_000, + director_pay=1_200, + main=True, + ), + outputs=dict( + applies=False, + trade_assets_disregarded=0, + capital=0, + holding_disregarded=0, + company_earnings=0, + company_and_director_earnings=0, + gainful=False, + mif=False, + ), + ), + "property_business_treatment_applies_without_trade_income_rules": dict( + inputs=dict( + stands=True, + trade=False, + property=True, + intermediary=False, + capital=50_000, + trade_assets=5_000, + engaged=True, + holding=10_000, + income=2_000, + director_pay=800, + main=False, + ), + outputs=dict( + applies=True, + trade_assets_disregarded=0, + capital=50_000, + holding_disregarded=10_000, + company_earnings=0, + company_and_director_earnings=0, + gainful=False, + mif=False, + ), + ), + "trade_assets_not_disregarded_when_person_not_engaged_in_trade_activities": dict( + inputs=dict( + stands=True, + trade=True, + property=False, + intermediary=False, + capital=90_000, + trade_assets=30_000, + engaged=False, + holding=20_000, + income=2_500, + director_pay=500, + main=False, + ), + outputs=dict( + applies=True, + trade_assets_disregarded=0, + capital=90_000, + holding_disregarded=20_000, + company_earnings=2_500, + company_and_director_earnings=3_000, + gainful=False, + mif=False, + ), + ), +} + + +@pytest.mark.parametrize("name", sorted(CASES)) +def test_reg_77_matches_axiom(name): + i, o = CASES[name]["inputs"], CASES[name]["outputs"] + person = { + "age": 40, + "stands_as_sole_owner_or_partner_of_company": i["stands"], + "owned_company_carries_on_trade": i["trade"], + "owned_company_carries_on_property_business": i["property"], + "owned_company_income_is_intermediary_employed_earnings": i["intermediary"], + "owned_company_capital": i["capital"], + "owned_company_trade_assets": i["trade_assets"], + "is_engaged_in_owned_company_trade": i["engaged"], + "owned_company_holding_value": i["holding"], + "owned_company_income_share": i["income"] * MONTHS, + "employment_income": i["director_pay"] * MONTHS, + "owned_company_is_main_employment": i["main"], + } + sim = Simulation( + situation={ + "people": {"p": {k: {YEAR: v} for k, v in person.items()}}, + "benunits": {"b": {"members": ["p"]}}, + "households": {"h": {"members": ["p"]}}, + } + ) + + def calc(variable): + return sim.calculate(variable, YEAR)[0] + + assert bool(calc("uc_company_owner_treatment_applies")) == o["applies"] + assert calc("uc_company_capital") == pytest.approx(o["capital"]) + # Axiom's trade-asset disregard is the gap between the company capital + # input and the capital treated as possessed, where the rule applies. + if o["applies"]: + assert i["capital"] - calc("uc_company_capital") == pytest.approx( + o["trade_assets_disregarded"] + ) + assert calc("uc_company_holding_disregard") == pytest.approx( + o["holding_disregarded"] + ) + assert calc("uc_company_self_employed_earnings") == pytest.approx( + o["company_earnings"] * MONTHS + ) + # Reg. 77(4): the company earnings are in addition to director pay. + if o["company_and_director_earnings"]: + assert calc("uc_company_self_employed_earnings") + calc( + "employment_income" + ) == pytest.approx(o["company_and_director_earnings"] * MONTHS) + assert bool(calc("uc_company_gainful_self_employment")) == o["gainful"] + # Axiom's minimum_income_floor_applies_due_to_company_trade is the reg. + # 77(3)(c) trigger; the model's floor also applies to self-employment + # income, which these cases do not have. + assert bool(calc("uc_mif_applies")) == o["mif"] diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_self_employed_earnings.py b/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_self_employed_earnings.py index 9949ec11bc..7b762d46bf 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_self_employed_earnings.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_self_employed_earnings.py @@ -11,7 +11,10 @@ class uc_company_self_employed_earnings(Variable): "earnings when they stand as the company's sole owner or partner. It " "is in addition to any pay they receive as the company's director or " "employee. A company that carries on only a property business gives " - "no earnings." + "no earnings. Where the minimum income floor applies it is compared " + "with the person's total earned income, including this amount and " + "their pay (reg. 62(2)); DWP guidance (ADM H4375) describes comparing " + "the company income alone." ) definition_period = YEAR unit = GBP 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 c9d5e6e711..43645dc87a 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 @@ -18,7 +18,9 @@ def formula(person, period, parameters): has_self_empl_income = person("self_employment_income", period) > 0 # A person whose main employment is the trade of a company they stand # as sole owner or partner of is treated as gainfully self-employed, - # so the floor applies (UC Regs 2013 reg. 77(3)(c)). + # so the floor applies (UC Regs 2013 reg. 77(3)(c)). Not modelled for + # either route: reg. 62(1)(b) limits the floor to claimants subject to + # all work-related requirements. company_gainful_self_employment = person( "uc_company_gainful_self_employment", period ) diff --git a/policyengine_uk/variables/input/owned_company/owned_company_income_is_intermediary_employed_earnings.py b/policyengine_uk/variables/input/owned_company/owned_company_income_is_intermediary_employed_earnings.py index 00386bfc5d..fa89a6b7aa 100644 --- a/policyengine_uk/variables/input/owned_company/owned_company_income_is_intermediary_employed_earnings.py +++ b/policyengine_uk/variables/input/owned_company/owned_company_income_is_intermediary_employed_earnings.py @@ -11,7 +11,9 @@ class owned_company_income_is_intermediary_employed_earnings(Variable): "intermediaries), Chapter 9 (managed service companies) or Chapter 10 " "(workers' services provided through intermediaries) of Part 2 of the " "Income Tax (Earnings and Pensions) Act 2003, derived from activities " - "that are the person's main employment. Where this holds, Universal " + "that are the person's main employment (Chapter 10 from 28 November " + "2018; before then only Chapters 8 and 9 excluded the person). Where " + "this holds, Universal " "Credit does not treat the person as the company's sole owner or " "partner, and that income counts as employed earnings instead." ) diff --git a/policyengine_uk/variables/input/owned_company/owned_company_income_share.py b/policyengine_uk/variables/input/owned_company/owned_company_income_share.py index 3e6dab4870..a73b553051 100644 --- a/policyengine_uk/variables/input/owned_company/owned_company_income_share.py +++ b/policyengine_uk/variables/input/owned_company/owned_company_income_share.py @@ -9,10 +9,14 @@ class owned_company_income_share(Variable): "The income of the company in which the person stands as sole owner " "or partner, or the person's share of that income, calculated as " "self-employed earnings would be: the company's actual receipts less " - "its permitted expenses. Pay the company gives the person as a " - "director or employee is a company expense here and is recorded in " - "employment_income instead. Dividends the person receives from the " - "company are paid out of this income, so they are not added to it." + "its permitted expenses, on a cash basis. Pay the company gives the " + "person as a director or employee is a company expense here and is " + "recorded in employment_income instead. Corporation tax is not " + "deducted: the self-employed earnings calculation deducts only " + "income tax and National Insurance the person pays, and neither the " + "regulations nor DWP guidance provide for corporation tax. Dividends " + "the person receives from the company are paid out of this income, " + "so they are not added to it." ) definition_period = YEAR unit = GBP From f98479068144d08ec6eabf15e375af34224d1912 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 17:22:30 -0400 Subject: [PATCH 3/6] Date the reg 77(5) intermediaries exclusion (SI 2018/1129) Until 27 November 2018 any Chapter 8 or 9 ITEPA employed earnings from the company excluded the owner; from 28 November 2018 Chapters 8-10 exclude only when derived from main-employment activities. Replace the single boolean with the ITEPA chapter and a main-employment flag, and parameterise the change. Co-Authored-By: Claude Opus 5.5 --- changelog.d/1948.added.md | 2 +- .../intermediary_exclusion/chapters.yaml | 23 ++++++++ .../requires_main_employment.yaml | 18 +++++++ .../uc_company_analogous_business.yaml | 54 ++++++++++++++++++- .../test_uc_company_owner_axiom_parity.py | 20 ++++--- .../tests/test_uc_company_owner_properties.py | 34 ++++++++---- ..._company_intermediary_exclusion_applies.py | 31 +++++++++++ .../uc_company_owner_treatment_applies.py | 9 ++-- ...ncome_is_intermediary_employed_earnings.py | 31 ----------- ...d_company_intermediary_earnings_chapter.py | 27 ++++++++++ ...ermediary_earnings_from_main_employment.py | 18 +++++++ 11 files changed, 213 insertions(+), 54 deletions(-) create mode 100644 policyengine_uk/parameters/gov/dwp/universal_credit/company_owner/intermediary_exclusion/chapters.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/universal_credit/company_owner/intermediary_exclusion/requires_main_employment.yaml create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_intermediary_exclusion_applies.py delete mode 100644 policyengine_uk/variables/input/owned_company/owned_company_income_is_intermediary_employed_earnings.py create mode 100644 policyengine_uk/variables/input/owned_company/owned_company_intermediary_earnings_chapter.py create mode 100644 policyengine_uk/variables/input/owned_company/owned_company_intermediary_earnings_from_main_employment.py diff --git a/changelog.d/1948.added.md b/changelog.d/1948.added.md index 51af73928b..92c1dac55c 100644 --- a/changelog.d/1948.added.md +++ b/changelog.d/1948.added.md @@ -1 +1 @@ -- Universal Credit now applies regulation 77 to a person who stands as sole owner or partner of a company carrying on a trade or property business: the company's income counts as their self-employed earnings on top of any pay from it, the minimum income floor applies when the trade is their main employment, and the company's capital (less trade assets while they work in the trade) replaces the value of their shares. New `owned_company_*` inputs describe the company; every input defaults to no such company, so existing datasets are unchanged. +- Universal Credit now applies regulation 77 to a person who stands as sole owner or partner of a company carrying on a trade or property business: the company's income counts as their self-employed earnings on top of any pay from it, the minimum income floor applies when the trade is their main employment, and the company's capital (less trade assets while they work in the trade) replaces the value of their shares. The intermediaries (IR35) exclusion follows reg. 77(5) as in force: Chapters 8 and 9 of ITEPA Part 2 until 27 November 2018, and Chapters 8 to 10 from main-employment activities from 28 November 2018. New `owned_company_*` inputs describe the company; every input defaults to no such company, so existing datasets are unchanged. diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/company_owner/intermediary_exclusion/chapters.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/company_owner/intermediary_exclusion/chapters.yaml new file mode 100644 index 0000000000..68ce04d877 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/company_owner/intermediary_exclusion/chapters.yaml @@ -0,0 +1,23 @@ +description: > + Chapters of Part 2 of the Income Tax (Earnings and Pensions) Act 2003 under + which employed earnings from a company take the person out of Universal + Credit's company owner rule. Chapter 10 was added from 28 November 2018. +metadata: + label: Universal Credit company owner rule excluded intermediary chapters + period: year + unit: list + reference: + - title: The Universal Credit Regulations 2013 reg. 77(5) (as made) + href: https://www.legislation.gov.uk/uksi/2013/376/regulation/77/made + - title: The Universal Credit and Jobseeker's Allowance (Miscellaneous Amendments) Regulations 2018 reg. 3(5) + href: https://www.legislation.gov.uk/uksi/2018/1129/regulation/3/made + - title: The Universal Credit Regulations 2013 reg. 77(5) + href: https://www.legislation.gov.uk/uksi/2013/376/regulation/77 +values: + 2013-04-29: + - CHAPTER_8 + - CHAPTER_9 + 2018-11-28: + - CHAPTER_8 + - CHAPTER_9 + - CHAPTER_10 diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/company_owner/intermediary_exclusion/requires_main_employment.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/company_owner/intermediary_exclusion/requires_main_employment.yaml new file mode 100644 index 0000000000..7f6ed16b32 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/company_owner/intermediary_exclusion/requires_main_employment.yaml @@ -0,0 +1,18 @@ +description: > + Whether employed earnings under the intermediaries rules take the person out + of Universal Credit's company owner rule only when they derive from the + person's main employment. As made, any such earnings did; from 28 November + 2018 only those derived from activities that are the person's main + employment do. +metadata: + label: Universal Credit company owner intermediary exclusion requires main employment + period: year + unit: bool + reference: + - title: The Universal Credit Regulations 2013 reg. 77(5) (as made) + href: https://www.legislation.gov.uk/uksi/2013/376/regulation/77/made + - title: The Universal Credit and Jobseeker's Allowance (Miscellaneous Amendments) Regulations 2018 reg. 3(5) + href: https://www.legislation.gov.uk/uksi/2018/1129/regulation/3/made +values: + 2013-04-29: false + 2018-11-28: true diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/company/uc_company_analogous_business.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/company/uc_company_analogous_business.yaml index 39fe6c7674..4f07e9e30e 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/company/uc_company_analogous_business.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/company/uc_company_analogous_business.yaml @@ -215,7 +215,8 @@ age: 40 stands_as_sole_owner_or_partner_of_company: true owned_company_carries_on_trade: true - owned_company_income_is_intermediary_employed_earnings: true + owned_company_intermediary_earnings_chapter: CHAPTER_8 + owned_company_intermediary_earnings_from_main_employment: true owned_company_is_main_employment: true is_engaged_in_owned_company_trade: true owned_company_income_share: 36_000 @@ -230,6 +231,7 @@ members: person corporate_wealth: 25_000 output: + uc_company_intermediary_exclusion_applies: [true] uc_company_owner_treatment_applies: [false] uc_company_self_employed_earnings: [0] uc_company_gainful_self_employment: [false] @@ -347,3 +349,53 @@ uc_assessable_capital: 21_000 is_uc_eligible: false universal_credit: 0 + +- name: Since November 2018 intermediary earnings exclude only when they come from the main employment + # Reg. 77(5) as substituted by SI 2018/1129 reg. 3(5) from 28 November 2018: + # Chapter 8 income from a side engagement does not take a trading-company + # owner out of the rule. + period: 2026 + absolute_error_margin: 0 + input: + stands_as_sole_owner_or_partner_of_company: true + owned_company_carries_on_trade: true + owned_company_intermediary_earnings_chapter: CHAPTER_8 + owned_company_intermediary_earnings_from_main_employment: false + owned_company_income_share: 10_000 + output: + uc_company_intermediary_exclusion_applies: false + uc_company_owner_treatment_applies: true + uc_company_self_employed_earnings: 10_000 + +- name: Before November 2018 any Chapter 8 or 9 intermediary earnings excluded the owner + # Reg. 77(5) as made: "This regulation does not apply where the person + # derives income from the company that is employed earnings by virtue of + # Chapter 8 ... or Chapter 9 ... of Part 2 of ITEPA", with no main + # employment condition. + period: 2017 + absolute_error_margin: 0 + input: + stands_as_sole_owner_or_partner_of_company: true + owned_company_carries_on_trade: true + owned_company_intermediary_earnings_chapter: CHAPTER_9 + owned_company_intermediary_earnings_from_main_employment: false + owned_company_income_share: 10_000 + output: + uc_company_intermediary_exclusion_applies: true + uc_company_owner_treatment_applies: false + uc_company_self_employed_earnings: 0 + +- name: Before November 2018 Chapter 10 earnings did not exclude the owner + # Chapter 10 was added to reg. 77(5) only from 28 November 2018. + period: 2017 + absolute_error_margin: 0 + input: + stands_as_sole_owner_or_partner_of_company: true + owned_company_carries_on_trade: true + owned_company_intermediary_earnings_chapter: CHAPTER_10 + owned_company_intermediary_earnings_from_main_employment: true + owned_company_income_share: 10_000 + output: + uc_company_intermediary_exclusion_applies: false + uc_company_owner_treatment_applies: true + uc_company_self_employed_earnings: 10_000 diff --git a/policyengine_uk/tests/test_uc_company_owner_axiom_parity.py b/policyengine_uk/tests/test_uc_company_owner_axiom_parity.py index c6056dfbcd..3c59430a78 100644 --- a/policyengine_uk/tests/test_uc_company_owner_axiom_parity.py +++ b/policyengine_uk/tests/test_uc_company_owner_axiom_parity.py @@ -13,8 +13,9 @@ - company_carries_on_property_business_within_meaning_of_corporation_tax_act_2009_section_204 -> owned_company_carries_on_property_business - person_derives_company_income_that_is_employed_earnings_by_itepa_part_2_chapter_8/9/10 - and intermediary_employed_earnings_derived_from_person_main_employment_activities - -> owned_company_income_is_intermediary_employed_earnings (their conjunction) + -> owned_company_intermediary_earnings_chapter +- intermediary_employed_earnings_derived_from_person_main_employment_activities + -> owned_company_intermediary_earnings_from_main_employment - company_capital_value_or_person_share_value -> owned_company_capital - company_trade_asset_value_or_person_share_used_wholly_and_exclusively_for_trade -> owned_company_trade_assets @@ -41,7 +42,8 @@ stands=True, trade=True, property=False, - intermediary=False, + chapter="NONE", + chapter_main=False, capital=100_000, trade_assets=40_000, engaged=True, @@ -66,7 +68,8 @@ stands=True, trade=True, property=False, - intermediary=True, + chapter="CHAPTER_8", + chapter_main=True, capital=100_000, trade_assets=40_000, engaged=True, @@ -91,7 +94,8 @@ stands=True, trade=False, property=True, - intermediary=False, + chapter="NONE", + chapter_main=False, capital=50_000, trade_assets=5_000, engaged=True, @@ -116,7 +120,8 @@ stands=True, trade=True, property=False, - intermediary=False, + chapter="NONE", + chapter_main=False, capital=90_000, trade_assets=30_000, engaged=False, @@ -147,7 +152,8 @@ def test_reg_77_matches_axiom(name): "stands_as_sole_owner_or_partner_of_company": i["stands"], "owned_company_carries_on_trade": i["trade"], "owned_company_carries_on_property_business": i["property"], - "owned_company_income_is_intermediary_employed_earnings": i["intermediary"], + "owned_company_intermediary_earnings_chapter": i["chapter"], + "owned_company_intermediary_earnings_from_main_employment": i["chapter_main"], "owned_company_capital": i["capital"], "owned_company_trade_assets": i["trade_assets"], "is_engaged_in_owned_company_trade": i["engaged"], diff --git a/policyengine_uk/tests/test_uc_company_owner_properties.py b/policyengine_uk/tests/test_uc_company_owner_properties.py index 082672877b..2af3b2d7d6 100644 --- a/policyengine_uk/tests/test_uc_company_owner_properties.py +++ b/policyengine_uk/tests/test_uc_company_owner_properties.py @@ -48,7 +48,8 @@ "stands_as_sole_owner_or_partner_of_company", "owned_company_carries_on_trade", "owned_company_carries_on_property_business", - "owned_company_income_is_intermediary_employed_earnings", + "owned_company_intermediary_earnings_chapter", + "owned_company_intermediary_earnings_from_main_employment", "owned_company_is_main_employment", "is_engaged_in_owned_company_trade", "owned_company_income_share", @@ -85,9 +86,12 @@ def companies(draw): stands_as_sole_owner_or_partner_of_company=draw(st.booleans()), owned_company_carries_on_trade=draw(st.booleans()), owned_company_carries_on_property_business=draw(st.booleans()), - owned_company_income_is_intermediary_employed_earnings=draw( - st.sampled_from([False, False, False, True]) + owned_company_intermediary_earnings_chapter=draw( + st.sampled_from( + ["NONE", "NONE", "NONE", "CHAPTER_8", "CHAPTER_9", "CHAPTER_10"] + ) ), + owned_company_intermediary_earnings_from_main_employment=draw(st.booleans()), owned_company_is_main_employment=draw(st.booleans()), is_engaged_in_owned_company_trade=draw(st.booleans()), owned_company_income_share=draw(money), @@ -171,12 +175,17 @@ def calculate(variants): return out +def _empty(value): + if isinstance(value, bool): + return False + if isinstance(value, str): + return "NONE" + return 0.0 + + def no_company(unit): return { - j: { - k: (False if isinstance(v, bool) else 0.0) - for k, v in adult["company"].items() - } + j: {k: _empty(v) for k, v in adult["company"].items()} for j, adult in enumerate(unit["adults"]) } @@ -188,7 +197,11 @@ def applies(company): company["owned_company_carries_on_trade"] or company["owned_company_carries_on_property_business"] ) - and not company["owned_company_income_is_intermediary_employed_earnings"] + and not ( + # Reg. 77(5) as in force from 28 November 2018. + company["owned_company_intermediary_earnings_chapter"] != "NONE" + and company["owned_company_intermediary_earnings_from_main_employment"] + ) ) @@ -206,7 +219,8 @@ def test_rule_is_inert_when_it_does_not_apply(unit_list): excluded[j] = {"stands_as_sole_owner_or_partner_of_company": False} elif way == 1: excluded[j] = { - "owned_company_income_is_intermediary_employed_earnings": True + "owned_company_intermediary_earnings_chapter": "CHAPTER_10", + "owned_company_intermediary_earnings_from_main_employment": True, } else: excluded[j] = { @@ -280,7 +294,7 @@ def _owner(unit, **facts): company = { "stands_as_sole_owner_or_partner_of_company": True, "owned_company_carries_on_trade": True, - "owned_company_income_is_intermediary_employed_earnings": False, + "owned_company_intermediary_earnings_chapter": "NONE", **facts, } return {0: company} diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_intermediary_exclusion_applies.py b/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_intermediary_exclusion_applies.py new file mode 100644 index 0000000000..74d5cd32de --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_intermediary_exclusion_applies.py @@ -0,0 +1,31 @@ +from policyengine_uk.model_api import * + + +class uc_company_intermediary_exclusion_applies(Variable): + value_type = bool + entity = Person + label = "Universal Credit company owner rule excluded by the intermediaries rules" + documentation = ( + "Whether income the person derives from the company is employed " + "earnings under the intermediaries or managed service company rules " + "in a way that takes them out of Universal Credit's company owner " + "rule: Chapter 8 or 9 of Part 2 of ITEPA 2003 until 27 November 2018; " + "Chapter 8, 9 or 10, derived from activities that are the person's " + "main employment, from 28 November 2018." + ) + definition_period = YEAR + reference = dict( + title="The Universal Credit Regulations 2013 reg. 77(5)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/77", + ) + + def formula(person, period, parameters): + p = parameters( + period + ).gov.dwp.universal_credit.company_owner.intermediary_exclusion + chapter = person("owned_company_intermediary_earnings_chapter", period) + excluded_chapter = np.isin(chapter.decode_to_str(), p.chapters) + from_main_employment = person( + "owned_company_intermediary_earnings_from_main_employment", period + ) + return excluded_chapter & (from_main_employment | ~p.requires_main_employment) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_owner_treatment_applies.py b/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_owner_treatment_applies.py index b0f8154c15..dca42718b4 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_owner_treatment_applies.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_owner_treatment_applies.py @@ -9,9 +9,10 @@ class uc_company_owner_treatment_applies(Variable): "Whether the person stands in a position analogous to a sole owner or " "partner in relation to a company that carries on a trade or a " "property business, so that Universal Credit treats them as that sole " - "owner or partner. This does not apply where the person derives " - "employed earnings from the company under the intermediaries or " - "managed service company rules from their main employment." + "owner or partner. This does not apply where income the person " + "derives from the company is employed earnings under the " + "intermediaries or managed service company rules " + "(uc_company_intermediary_exclusion_applies)." ) definition_period = YEAR reference = [ @@ -32,5 +33,5 @@ def formula(person, period, parameters): return ( person("stands_as_sole_owner_or_partner_of_company", period) & carries_on_business - & ~person("owned_company_income_is_intermediary_employed_earnings", period) + & ~person("uc_company_intermediary_exclusion_applies", period) ) diff --git a/policyengine_uk/variables/input/owned_company/owned_company_income_is_intermediary_employed_earnings.py b/policyengine_uk/variables/input/owned_company/owned_company_income_is_intermediary_employed_earnings.py deleted file mode 100644 index fa89a6b7aa..0000000000 --- a/policyengine_uk/variables/input/owned_company/owned_company_income_is_intermediary_employed_earnings.py +++ /dev/null @@ -1,31 +0,0 @@ -from policyengine_uk.model_api import * - - -class owned_company_income_is_intermediary_employed_earnings(Variable): - value_type = bool - entity = Person - label = "owned company income is intermediary employed earnings" - documentation = ( - "Whether the person derives income from the company that is employed " - "earnings under Chapter 8 (workers under arrangements made by " - "intermediaries), Chapter 9 (managed service companies) or Chapter 10 " - "(workers' services provided through intermediaries) of Part 2 of the " - "Income Tax (Earnings and Pensions) Act 2003, derived from activities " - "that are the person's main employment (Chapter 10 from 28 November " - "2018; before then only Chapters 8 and 9 excluded the person). Where " - "this holds, Universal " - "Credit does not treat the person as the company's sole owner or " - "partner, and that income counts as employed earnings instead." - ) - definition_period = YEAR - default_value = False - reference = [ - dict( - title="The Universal Credit Regulations 2013 reg. 77(5)", - href="https://www.legislation.gov.uk/uksi/2013/376/regulation/77", - ), - dict( - title="Income Tax (Earnings and Pensions) Act 2003 Part 2 Chapters 8-10", - href="https://www.legislation.gov.uk/ukpga/2003/1/part/2", - ), - ] diff --git a/policyengine_uk/variables/input/owned_company/owned_company_intermediary_earnings_chapter.py b/policyengine_uk/variables/input/owned_company/owned_company_intermediary_earnings_chapter.py new file mode 100644 index 0000000000..0306448847 --- /dev/null +++ b/policyengine_uk/variables/input/owned_company/owned_company_intermediary_earnings_chapter.py @@ -0,0 +1,27 @@ +from policyengine_uk.model_api import * + + +class ITEPAIntermediaryChapter(Enum): + NONE = "None" + CHAPTER_8 = "Chapter 8: workers under arrangements made by intermediaries" + CHAPTER_9 = "Chapter 9: managed service companies" + CHAPTER_10 = "Chapter 10: workers' services provided through intermediaries" + + +class owned_company_intermediary_earnings_chapter(Variable): + value_type = Enum + possible_values = ITEPAIntermediaryChapter + default_value = ITEPAIntermediaryChapter.NONE + entity = Person + label = "owned company income made employed earnings by the intermediaries rules" + documentation = ( + "The chapter of Part 2 of the Income Tax (Earnings and Pensions) Act " + "2003, if any, under which income the person derives from the company " + "in which they stand as sole owner or partner is employed earnings " + "(the IR35 and managed service company rules)." + ) + definition_period = YEAR + reference = dict( + title="Income Tax (Earnings and Pensions) Act 2003 Part 2 Chapters 8-10", + href="https://www.legislation.gov.uk/ukpga/2003/1/part/2", + ) diff --git a/policyengine_uk/variables/input/owned_company/owned_company_intermediary_earnings_from_main_employment.py b/policyengine_uk/variables/input/owned_company/owned_company_intermediary_earnings_from_main_employment.py new file mode 100644 index 0000000000..af77be864c --- /dev/null +++ b/policyengine_uk/variables/input/owned_company/owned_company_intermediary_earnings_from_main_employment.py @@ -0,0 +1,18 @@ +from policyengine_uk.model_api import * + + +class owned_company_intermediary_earnings_from_main_employment(Variable): + value_type = bool + entity = Person + label = "owned company intermediary earnings derive from main employment" + documentation = ( + "Whether the income recorded in " + "owned_company_intermediary_earnings_chapter derives from activities " + "that are the person's main employment." + ) + definition_period = YEAR + default_value = False + reference = dict( + title="The Universal Credit Regulations 2013 reg. 77(5)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/77", + ) From 36947b202e9df4e23fd5c8baf7cbc58f47be2227 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 22:33:36 -0400 Subject: [PATCH 4/6] Address independent review of reg 77 - Company losses count as nil self-employed earnings (reg 57(2)). - Company earnings count towards the benefit cap earnings exemption (reg 82). - In multi-benunit households, disregarded holdings leave the household capital pool before it is shared; a reporting benunit's holdings come off its own reported capital. Both floored at zero. - Main employment in the company's trade counts as engagement in it for the 77(3)(a) trade-asset disregard. - Replace bitwise NOT on a Python bool in the 77(5) exclusion. - Document dating, trade assets net of secured liabilities, NI references, and the dividend double count until dividends leave UC unearned income. - Strengthen the Hypothesis strategies, add a conservation property, and make the Axiom test an explicit golden-case mirror asserting modelled earnings. Co-Authored-By: Claude Opus 5.5 --- changelog.d/1948.added.md | 8 +- .../intermediary_exclusion/chapters.yaml | 4 + .../requires_main_employment.yaml | 4 + .../uc_company_analogous_business.yaml | 112 ++++++++++++++- .../test_uc_company_owner_axiom_parity.py | 28 +++- .../tests/test_uc_company_owner_properties.py | 136 +++++++++++++----- .../gov/dwp/is_benefit_cap_exempt_earnings.py | 4 + .../company/uc_company_capital.py | 19 ++- .../uc_company_gainful_self_employment.py | 4 + ..._company_intermediary_exclusion_applies.py | 4 +- .../uc_company_self_employed_earnings.py | 10 +- ...ousehold_uc_unreported_company_holdings.py | 23 +++ .../income/income_floor/uc_mif_applies.py | 1 + .../universal_credit/uc_assessable_capital.py | 26 ++-- .../variables/input/owned_company/README.md | 2 +- .../owned_company_income_share.py | 13 +- .../owned_company_trade_assets.py | 4 +- 17 files changed, 327 insertions(+), 75 deletions(-) create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/household_uc_unreported_company_holdings.py diff --git a/changelog.d/1948.added.md b/changelog.d/1948.added.md index 92c1dac55c..2c3c341d93 100644 --- a/changelog.d/1948.added.md +++ b/changelog.d/1948.added.md @@ -1 +1,7 @@ -- Universal Credit now applies regulation 77 to a person who stands as sole owner or partner of a company carrying on a trade or property business: the company's income counts as their self-employed earnings on top of any pay from it, the minimum income floor applies when the trade is their main employment, and the company's capital (less trade assets while they work in the trade) replaces the value of their shares. The intermediaries (IR35) exclusion follows reg. 77(5) as in force: Chapters 8 and 9 of ITEPA Part 2 until 27 November 2018, and Chapters 8 to 10 from main-employment activities from 28 November 2018. New `owned_company_*` inputs describe the company; every input defaults to no such company, so existing datasets are unchanged. +- Universal Credit now applies regulation 77 to a person who stands as sole owner or partner of a company carrying on a trade or property business. + - The company's income counts as their self-employed earnings on top of any pay from it. A loss counts as nil. + - The earnings count towards the benefit cap earnings exemption. + - The minimum income floor applies when the trade is their main employment. + - The company's capital replaces the value of their shares. Trade assets are disregarded while they work in the trade. In households with several benefit units, the shares leave the household's capital before it is shared out. + - The intermediaries (IR35) exclusion follows reg. 77(5) as in force: Chapters 8 and 9 of ITEPA Part 2 until 27 November 2018, and Chapters 8 to 10 from main-employment activities from 28 November 2018. + - New `owned_company_*` inputs describe the company. Every input defaults to no such company, so existing datasets are unchanged. diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/company_owner/intermediary_exclusion/chapters.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/company_owner/intermediary_exclusion/chapters.yaml index 68ce04d877..835331544e 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/company_owner/intermediary_exclusion/chapters.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/company_owner/intermediary_exclusion/chapters.yaml @@ -2,6 +2,8 @@ description: > Chapters of Part 2 of the Income Tax (Earnings and Pensions) Act 2003 under which employed earnings from a company take the person out of Universal Credit's company owner rule. Chapter 10 was added from 28 November 2018. + Annual simulations use the rule in force at the start of each tax year, so + the 2018-19 tax year uses the earlier rule throughout. metadata: label: Universal Credit company owner rule excluded intermediary chapters period: year @@ -11,6 +13,8 @@ metadata: href: https://www.legislation.gov.uk/uksi/2013/376/regulation/77/made - title: The Universal Credit and Jobseeker's Allowance (Miscellaneous Amendments) Regulations 2018 reg. 3(5) href: https://www.legislation.gov.uk/uksi/2018/1129/regulation/3/made + - title: The Universal Credit and Jobseeker's Allowance (Miscellaneous Amendments) Regulations (Northern Ireland) 2018 reg. 3(7) + href: https://www.legislation.gov.uk/nisr/2018/187/regulation/3/made - title: The Universal Credit Regulations 2013 reg. 77(5) href: https://www.legislation.gov.uk/uksi/2013/376/regulation/77 values: diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/company_owner/intermediary_exclusion/requires_main_employment.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/company_owner/intermediary_exclusion/requires_main_employment.yaml index 7f6ed16b32..b902859fd4 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/company_owner/intermediary_exclusion/requires_main_employment.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/company_owner/intermediary_exclusion/requires_main_employment.yaml @@ -4,6 +4,8 @@ description: > person's main employment. As made, any such earnings did; from 28 November 2018 only those derived from activities that are the person's main employment do. + Annual simulations use the rule in force at the start of each tax year, so + the 2018-19 tax year uses the earlier rule throughout. metadata: label: Universal Credit company owner intermediary exclusion requires main employment period: year @@ -13,6 +15,8 @@ metadata: href: https://www.legislation.gov.uk/uksi/2013/376/regulation/77/made - title: The Universal Credit and Jobseeker's Allowance (Miscellaneous Amendments) Regulations 2018 reg. 3(5) href: https://www.legislation.gov.uk/uksi/2018/1129/regulation/3/made + - title: The Universal Credit and Jobseeker's Allowance (Miscellaneous Amendments) Regulations (Northern Ireland) 2018 reg. 3(7) + href: https://www.legislation.gov.uk/nisr/2018/187/regulation/3/made values: 2013-04-29: false 2018-11-28: true diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/company/uc_company_analogous_business.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/company/uc_company_analogous_business.yaml index 4f07e9e30e..c57c164ffe 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/company/uc_company_analogous_business.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/company/uc_company_analogous_business.yaml @@ -325,7 +325,7 @@ # 1,000 of savings and a disregarded 1,000 holding, but the company holds # 20,000 of cash not used wholly and exclusively for its trade: assessable # capital 1,000 + 1,000 - 1,000 + 20,000 = 21,000 exceeds the 16,000 limit - # (reg. 18(1)), so no award. + # (reg. 18(1); Welfare Reform Act 2012 s. 5(1)(a)), so no award. period: 2026 absolute_error_margin: 0 input: @@ -399,3 +399,113 @@ uc_company_intermediary_exclusion_applies: false uc_company_owner_treatment_applies: true uc_company_self_employed_earnings: 10_000 + +- name: A company loss counts as nil, not against the owner's pay + # Reg. 57(2): if the result of steps 1 to 3 "is nil or a negative amount, + # the amount of the person's self-employed earnings ... is nil". A 10,000 + # loss leaves the 20,000 of director's pay intact (reg. 77(4)). + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + employment_income: 20_000 + stands_as_sole_owner_or_partner_of_company: true + owned_company_carries_on_trade: true + owned_company_income_share: -10_000 + output: + uc_company_self_employed_earnings: 0 + uc_mif_capped_earned_income: 20_000 + +- name: Working in the trade as one's main employment is engagement in it + # Reg. 77(3)(a) disregards trade assets "while they are engaged in + # activities in the course of that trade"; a person whose main employment + # is that trade is. 100,000 - 40,000 = 60,000 treated as possessed. + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + stands_as_sole_owner_or_partner_of_company: true + owned_company_carries_on_trade: true + owned_company_is_main_employment: true + is_engaged_in_owned_company_trade: false + owned_company_capital: 100_000 + owned_company_trade_assets: 40_000 + output: + uc_company_capital: 60_000 + +- name: In a household of two benunits the disregarded holding leaves the household pool before it is shared + # The household has 10,000 of savings and the owner's 30,000 of shares + # (within corporate_wealth). Reg. 77(2) disregards the shares, so 10,000 + # remains, shared between the two unreported adults: 5,000 each. The + # company has no other capital. + period: 2026 + absolute_error_margin: 0 + input: + people: + owner: + age: 40 + stands_as_sole_owner_or_partner_of_company: true + owned_company_carries_on_trade: true + owned_company_holding_value: 30_000 + lodger: + age: 30 + benunits: + owner_unit: + members: [owner] + lodger_unit: + members: [lodger] + households: + household: + members: [owner, lodger] + savings: 10_000 + corporate_wealth: 30_000 + output: + household_uc_unreported_company_holdings: 30_000 + uc_assessable_capital: [5_000, 5_000] + +- name: A benunit that reports its capital has its own holding taken off what it reports + # Reported capital of 50,000 includes the 30,000 holding: 20,000 remains, + # plus 15,000 of company capital not used in the trade = 35,000. + period: 2026 + absolute_error_margin: 0 + input: + people: + person: + age: 40 + stands_as_sole_owner_or_partner_of_company: true + owned_company_carries_on_trade: true + owned_company_holding_value: 30_000 + owned_company_capital: 15_000 + benunits: + benunit: + members: person + uc_reported_capital: 50_000 + households: + household: + members: person + output: + uc_assessable_capital: 35_000 + +- name: Company earnings count towards the benefit cap earnings exemption + # UC Regs 2013 reg. 82(1)(a): the cap does not apply where earned income is + # at least the threshold. Reg. 77(3)(b) earnings are self-employed earnings + # (the model's threshold is 10,152 a year); only income deemed by the + # minimum income floor is excluded (reg. 82(4)). 20,000 of company income, + # not in a start-up period but not the main employment, so no floor. + period: 2026 + absolute_error_margin: 0 + input: + people: + person: + age: 40 + stands_as_sole_owner_or_partner_of_company: true + owned_company_carries_on_trade: true + owned_company_income_share: 20_000 + benunits: + benunit: + members: person + households: + household: + members: person + output: + is_benefit_cap_exempt_earnings: true diff --git a/policyengine_uk/tests/test_uc_company_owner_axiom_parity.py b/policyengine_uk/tests/test_uc_company_owner_axiom_parity.py index 3c59430a78..020814738c 100644 --- a/policyengine_uk/tests/test_uc_company_owner_axiom_parity.py +++ b/policyengine_uk/tests/test_uc_company_owner_axiom_parity.py @@ -1,10 +1,18 @@ -"""Differential test: Universal Credit reg. 77 against the Axiom encoding. +"""Golden-case parity: Universal Credit reg. 77 against the Axiom encoding. -The four cases are the tests of TheAxiomFoundation/rulespec-uk +The four cases are copied from the tests of TheAxiomFoundation/rulespec-uk uk/regulations/uksi/2013/376/77.test.yaml (commit 0644db8), which encodes the Universal Credit Regulations 2013 reg. 77 independently of this model. Axiom works in monthly assessment periods; income amounts here are the monthly -figures times 12, and capital amounts are unchanged. +figures times 12, and capital amounts are unchanged. This mirrors Axiom's +expected outputs; it does not run Axiom, so it will not notice if that +encoding changes. + +Outputs compared: the 77(5) exclusion, treatment, the trade-asset +disregard, capital treated as possessed, the holding disregard, the 77(3)(b) +self-employed earnings, the 77(4) total of those earnings and director pay +(through the model's earned income), gainful self-employment and the +minimum income floor trigger. Axiom input -> policyengine-uk input: - person_stands_in_position_analogous_to_sole_owner_or_partner_in_relation_to_company @@ -187,11 +195,17 @@ def calc(variable): assert calc("uc_company_self_employed_earnings") == pytest.approx( o["company_earnings"] * MONTHS ) - # Reg. 77(4): the company earnings are in addition to director pay. + assert bool(calc("uc_company_intermediary_exclusion_applies")) == ( + i["chapter"] != "NONE" and i["chapter_main"] + ) + # Reg. 77(4): the model's earned income adds the company earnings to + # director pay. These cases' totals are above the floor, so the floor + # does not change them. if o["company_and_director_earnings"]: - assert calc("uc_company_self_employed_earnings") + calc( - "employment_income" - ) == pytest.approx(o["company_and_director_earnings"] * MONTHS) + assert calc("uc_mif_capped_earned_income") == pytest.approx( + o["company_and_director_earnings"] * MONTHS + ) + assert calc("uc_mif_capped_earned_income") > calc("uc_minimum_income_floor") assert bool(calc("uc_company_gainful_self_employment")) == o["gainful"] # Axiom's minimum_income_floor_applies_due_to_company_trade is the reg. # 77(3)(c) trigger; the model's floor also applies to self-employment diff --git a/policyengine_uk/tests/test_uc_company_owner_properties.py b/policyengine_uk/tests/test_uc_company_owner_properties.py index 2af3b2d7d6..2b2d45c98e 100644 --- a/policyengine_uk/tests/test_uc_company_owner_properties.py +++ b/policyengine_uk/tests/test_uc_company_owner_properties.py @@ -18,17 +18,24 @@ 77(5) applies, or the company carries on neither a trade nor a property business, no company fact changes Universal Credit, its capital or its earned income. -2. Capital identity: assessable capital equals the capital without the rule, - plus each owner's company capital net of disregarded trade assets, less - each owner's holding, floored at zero. -3. Earnings identity: without the floor, gross earned income is pay plus - self-employment profit plus the company income share for a trading - company, and nothing for a property-only company. -4. Floor: a trading-company owner whose main employment it is and who is not +2. Capital identity: assessable capital equals the capital without the rule + less the owners' holdings (floored at zero), plus each owner's company + capital net of trade assets disregarded while they work in the trade. +3. Conservation: in a household of two benefit units, the units' assessable + capital sums to the household's capital less the owners' holdings + (floored at zero), plus the owners' company capital. +4. Earnings identity: without the floor, gross earned income is pay plus + self-employment profit plus the company income share (a loss counts as + nil) for a trading company, and nothing for a property-only company. +5. Floor: a trading-company owner whose main employment it is and who is not in a start-up period has earned income of at least the floor. -5. Monotone: Universal Credit is non-increasing in the company income share +6. Monotone: Universal Credit is non-increasing in the company income share and in the company's capital, and non-decreasing in the value of the disregarded holding and of disregarded trade assets. + +The strategies make most adults owners of a trading company, keep capital +mostly under the £16,000 limit and put earnings around the floor, so each +property is exercised rather than satisfied vacuously. """ import numpy as np @@ -39,7 +46,7 @@ YEAR = 2026 PROPERTY_SETTINGS = settings( - max_examples=6, + max_examples=10, deadline=None, derandomize=True, suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], @@ -68,7 +75,9 @@ "uc_mif_applies", ] -money = st.floats(0, 60_000, allow_nan=False, allow_infinity=False) +money = st.floats(0, 30_000, allow_nan=False, allow_infinity=False) +company_income = st.floats(-20_000, 40_000, allow_nan=False, allow_infinity=False) +small_capital = st.one_of(st.just(0.0), st.floats(0, 8_000)) def close(a, b): @@ -76,28 +85,27 @@ def close(a, b): return abs(a - b) <= 1e-2 + 1e-6 * abs(b) -capital = st.one_of(st.just(0.0), st.floats(0, 40_000)) - - @st.composite def companies(draw): - company_capital = draw(capital) + company_capital = draw(st.one_of(st.just(0.0), st.floats(0, 12_000))) return dict( - stands_as_sole_owner_or_partner_of_company=draw(st.booleans()), - owned_company_carries_on_trade=draw(st.booleans()), - owned_company_carries_on_property_business=draw(st.booleans()), + stands_as_sole_owner_or_partner_of_company=draw( + st.sampled_from([True, True, True, False]) + ), + owned_company_carries_on_trade=draw(st.sampled_from([True, True, True, False])), + owned_company_carries_on_property_business=draw( + st.sampled_from([False, False, True]) + ), owned_company_intermediary_earnings_chapter=draw( - st.sampled_from( - ["NONE", "NONE", "NONE", "CHAPTER_8", "CHAPTER_9", "CHAPTER_10"] - ) + st.sampled_from(["NONE"] * 5 + ["CHAPTER_8", "CHAPTER_9", "CHAPTER_10"]) ), owned_company_intermediary_earnings_from_main_employment=draw(st.booleans()), owned_company_is_main_employment=draw(st.booleans()), is_engaged_in_owned_company_trade=draw(st.booleans()), - owned_company_income_share=draw(money), + owned_company_income_share=draw(company_income), owned_company_capital=company_capital, owned_company_trade_assets=draw(st.floats(0, 1)) * company_capital, - owned_company_holding_value=draw(capital), + owned_company_holding_value=draw(small_capital), ) @@ -106,8 +114,8 @@ def adults(draw): return dict( age=draw(st.integers(25, 60)), employment_income=draw(st.one_of(st.just(0.0), money)), - self_employment_income=draw(st.one_of(st.just(0.0), money)), - uc_is_in_startup_period=draw(st.booleans()), + self_employment_income=draw(st.one_of(st.just(0.0), st.floats(0, 15_000))), + uc_is_in_startup_period=draw(st.sampled_from([False, False, True])), company=draw(companies()), ) @@ -118,8 +126,8 @@ def units(draw): adults=draw(st.lists(adults(), min_size=1, max_size=2)), children=draw(st.integers(0, 2)), rent=draw(st.floats(0, 15_000)), - savings=draw(capital), - corporate_wealth=draw(capital), + savings=draw(small_capital), + corporate_wealth=draw(small_capital), ) @@ -205,6 +213,20 @@ def applies(company): ) +def company_capital_of(company): + """Capital treated as possessed under reg. 77(2), net of 77(3)(a).""" + engaged = ( + company["is_engaged_in_owned_company_trade"] + or company["owned_company_is_main_employment"] + ) + disregarded = ( + company["owned_company_trade_assets"] + if company["owned_company_carries_on_trade"] and engaged + else 0.0 + ) + return max(0.0, company["owned_company_capital"] - disregarded) + + @PROPERTY_SETTINGS @given(st.lists(units(), min_size=2, max_size=4)) def test_rule_is_inert_when_it_does_not_apply(unit_list): @@ -241,25 +263,17 @@ def test_capital_and_earnings_identities(unit_list): got = calculate([(u, {}) for u in unit_list]) base = calculate([(u, no_company(u)) for u in unit_list]) for i, unit in enumerate(unit_list): - net_company_capital = 0.0 + holdings, company_capital = 0.0, 0.0 for j, adult in enumerate(unit["adults"]): c = adult["company"] k = got["adult_positions"][i][j] assert got["uc_company_owner_treatment_applies"][k] == applies(c) if applies(c): - disregarded = ( - c["owned_company_trade_assets"] - if c["owned_company_carries_on_trade"] - and c["is_engaged_in_owned_company_trade"] - else 0.0 - ) - net_company_capital += ( - max(0.0, c["owned_company_capital"] - disregarded) - - c["owned_company_holding_value"] - ) + holdings += c["owned_company_holding_value"] + company_capital += company_capital_of(c) # Earnings identity, compared before the floor. company_earnings = ( - c["owned_company_income_share"] + max(0.0, c["owned_company_income_share"]) if applies(c) and c["owned_company_carries_on_trade"] else 0.0 ) @@ -285,7 +299,9 @@ def test_capital_and_earnings_identities(unit_list): assert capped >= got["uc_minimum_income_floor"][k] - 1e-2 if adult["uc_is_in_startup_period"]: assert not got["uc_mif_applies"][k] - expected = max(0.0, base["uc_assessable_capital"][i] + net_company_capital) + expected = ( + max(0.0, base["uc_assessable_capital"][i] - holdings) + company_capital + ) assert close(got["uc_assessable_capital"][i], expected) @@ -387,3 +403,45 @@ def test_property_company_gives_no_earnings(unit_list, share): for v in ["universal_credit", "uc_earned_income"]: a, b = got[v].reshape(-1, 2).T np.testing.assert_allclose(a, b, rtol=0, atol=1e-6, err_msg=v) + + +@PROPERTY_SETTINGS +@given(st.lists(st.tuples(units(), units()), min_size=1, max_size=3)) +def test_household_capital_is_conserved_across_benefit_units(pairs): + # Each household holds two benefit units: the first unit's adults and the + # second's, with the first unit's savings and corporate wealth. No unit + # reports its own capital, so the household pool is shared by adults. + people, benunits, households = {}, {}, {} + expected = [] + for h, (first, second) in enumerate(pairs): + names, holdings, company_capital = [], 0.0, 0.0 + for b, unit in enumerate((first, second)): + members = [] + for j, adult in enumerate(unit["adults"]): + name = f"h{h}_b{b}_p{j}" + c = adult["company"] + people[name] = { + "age": {YEAR: adult["age"]}, + **{k: {YEAR: v} for k, v in c.items()}, + } + if applies(c): + holdings += c["owned_company_holding_value"] + company_capital += company_capital_of(c) + members.append(name) + benunits[f"h{h}_b{b}"] = {"members": members} + names += members + households[f"h{h}"] = { + "members": names, + "savings": {YEAR: first["savings"]}, + "corporate_wealth": {YEAR: first["corporate_wealth"]}, + } + pool = first["savings"] + first["corporate_wealth"] + expected.append(max(0.0, pool - holdings) + company_capital) + sim = Simulation( + situation={"people": people, "benunits": benunits, "households": households} + ) + capital = np.asarray(sim.calculate("uc_assessable_capital", YEAR)) + assert (capital >= 0).all() + per_household = capital.reshape(-1, 2).sum(axis=1) + for got, want in zip(per_household, expected): + assert close(got, want) 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 c27e13c39d..04842ca096 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 @@ -23,9 +23,13 @@ def formula(benunit, period, parameters): # Earnings exemption for UC (£846/month = £10,152/year) # Note: Only check earned income, not UC amount itself to avoid circular dependency + # Earned income includes a company's income treated as the owner's + # self-employed earnings (UC Regs 2013 reg. 77(3)(b)); income deemed + # by the minimum income floor does not count (reg. 82(4)). uc_earned = benunit.sum( benunit.members("employment_income", period) + benunit.members("self_employment_income", period) + + benunit.members("uc_company_self_employed_earnings", period) - benunit.members("income_tax", period) - benunit.members("national_insurance", period) ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_capital.py index b51419aa61..b83abcd23c 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_capital.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_capital.py @@ -11,10 +11,10 @@ class uc_company_capital(Variable): "stand as the company's sole owner or partner. Assets used wholly and " "exclusively for the company's trade are disregarded while the person " "is engaged in activities in the course of that trade. This follows " - "the regulation: DWP guidance (ADM H4372) instead disregards all of " - "the company capital while the person works in the business, and " - "H1880 paraphrases the test as 'wholly or mainly', both closer to the " - "older Income Support rule." + "the regulation. DWP guidance (ADM H4372) instead disregards all of " + "the company capital while the person works in the business, and ADM " + "H1880 paraphrases the test as 'wholly or mainly', the wording of the " + "business-assets disregard in Schedule 10 para. 7." ) definition_period = YEAR unit = GBP @@ -32,9 +32,14 @@ class uc_company_capital(Variable): def formula(person, period, parameters): applies = person("uc_company_owner_treatment_applies", period) - trade_assets_disregarded = person( - "owned_company_carries_on_trade", period - ) & person("is_engaged_in_owned_company_trade", period) + # A person whose main employment is the company's trade is engaged in + # activities in the course of that trade. + engaged = person("is_engaged_in_owned_company_trade", period) | person( + "owned_company_is_main_employment", period + ) + trade_assets_disregarded = ( + person("owned_company_carries_on_trade", period) & engaged + ) disregarded = where( trade_assets_disregarded, person("owned_company_trade_assets", period), diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_gainful_self_employment.py b/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_gainful_self_employment.py index 00cc3e9e02..be94519de0 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_gainful_self_employment.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_gainful_self_employment.py @@ -18,6 +18,10 @@ class uc_company_gainful_self_employment(Variable): title="The Universal Credit Regulations 2013 reg. 77(3)(c)", href="https://www.legislation.gov.uk/uksi/2013/376/regulation/77", ), + dict( + title="The Universal Credit Regulations (Northern Ireland) 2016 regs. 77(3)(c) and 63", + href="https://www.legislation.gov.uk/nisr/2016/216/regulation/77", + ), dict( title="Advice for Decision Making, Chapter H4, para. H4375", href="https://www.gov.uk/government/publications/advice-for-decision-making-staff-guide", diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_intermediary_exclusion_applies.py b/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_intermediary_exclusion_applies.py index 74d5cd32de..c95adac4bf 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_intermediary_exclusion_applies.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_intermediary_exclusion_applies.py @@ -28,4 +28,6 @@ def formula(person, period, parameters): from_main_employment = person( "owned_company_intermediary_earnings_from_main_employment", period ) - return excluded_chapter & (from_main_employment | ~p.requires_main_employment) + if p.requires_main_employment: + return excluded_chapter & from_main_employment + return excluded_chapter diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_self_employed_earnings.py b/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_self_employed_earnings.py index 7b762d46bf..db9121f537 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_self_employed_earnings.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_self_employed_earnings.py @@ -14,7 +14,9 @@ class uc_company_self_employed_earnings(Variable): "no earnings. Where the minimum income floor applies it is compared " "with the person's total earned income, including this amount and " "their pay (reg. 62(2)); DWP guidance (ADM H4375) describes comparing " - "the company income alone." + "the company income alone. A loss counts as nil, as negative " + "self-employed earnings do (reg. 57(2)); carrying it forward against " + "later profits (reg. 57A) is not modelled." ) definition_period = YEAR unit = GBP @@ -23,6 +25,10 @@ class uc_company_self_employed_earnings(Variable): title="The Universal Credit Regulations 2013 reg. 77(3)(b) and (4)", href="https://www.legislation.gov.uk/uksi/2013/376/regulation/77", ), + dict( + title="The Universal Credit Regulations 2013 reg. 57(2)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/57", + ), dict( title="Advice for Decision Making, Chapter H4, paras. H4374 and H4376", href="https://www.gov.uk/government/publications/advice-for-decision-making-staff-guide", @@ -35,6 +41,6 @@ def formula(person, period, parameters): ) & person("owned_company_carries_on_trade", period) return where( trading_company_owner, - person("owned_company_income_share", period), + max_(0, person("owned_company_income_share", period)), 0, ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_unreported_company_holdings.py b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_unreported_company_holdings.py new file mode 100644 index 0000000000..08d4e896c6 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_unreported_company_holdings.py @@ -0,0 +1,23 @@ +from policyengine_uk.model_api import * + + +class household_uc_unreported_company_holdings(Variable): + value_type = float + entity = Household + label = "Household company holdings disregarded for Universal Credit, unreported benunits" + documentation = ( + "The disregarded holdings (uc_company_holding_disregard) of people in " + "benunits without reported UC capital. They are taken out of the " + "household's capital before it is shared among those benunits, so the " + "whole holding is removed from the household pool and no benunit " + "counts any part of it." + ) + definition_period = YEAR + unit = GBP + quantity_type = STOCK + + def formula(household, period, parameters): + person = household.members + has_reported_capital = person.benunit("uc_reported_capital", period) >= 0 + holdings = person("uc_company_holding_disregard", period) + return household.sum(holdings * ~has_reported_capital) 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 43645dc87a..2aa8f441cb 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 @@ -11,6 +11,7 @@ class uc_mif_applies(Variable): reference = [ "https://www.legislation.gov.uk/uksi/2013/376/regulation/62/2021-04-06", "https://www.legislation.gov.uk/uksi/2013/376/regulation/77", + "https://www.legislation.gov.uk/nisr/2016/216/regulation/63", ] definition_period = YEAR diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_assessable_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_assessable_capital.py index 0ba1c19f35..e9133f5453 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_assessable_capital.py @@ -29,25 +29,29 @@ def formula(benunit, period, parameters): adult_divisor = max_(1, household_unreported_adults) reported_capital = benunit("uc_reported_capital", period) use_reported_capital = reported_capital >= 0 + # A person who stands as sole owner or partner of a company has their + # holding in it disregarded and is treated as possessing the company's + # capital (or their share of it) instead (UC Regs 2013 reg. 77(2), + # (3)(a)). Holdings of people in unreported benunits leave the + # household pool before it is shared out; a reporting benunit's + # holdings come off its own reported capital. Neither subtraction can + # take capital below zero. + unreported_holdings = household( + "household_uc_unreported_company_holdings", period + ) residual_household_capital = max_( - 0, household_capital - household_reported_capital + 0, household_capital - household_reported_capital - unreported_holdings ) household_capital_proxy = where( household_unreported_adults > 0, residual_household_capital * benunit_adults / adult_divisor, 0, ) + own_holdings = add(benunit, period, ["uc_company_holding_disregard"]) assessed_capital = where( use_reported_capital, - reported_capital, + max_(0, reported_capital - own_holdings), household_capital_proxy, ) - # A person who stands as sole owner or partner of a company has their - # holding in it disregarded and is treated as possessing the company's - # capital (or their share of it) instead (UC Regs 2013 reg. 77(2), - # (3)(a)). Both the household sources and uc_reported_capital value - # the assets held, so the substitution applies to either. - company_capital = add(benunit, period, ["uc_company_capital"]) - add( - benunit, period, ["uc_company_holding_disregard"] - ) - return max_(0, assessed_capital + company_capital) + company_capital = add(benunit, period, ["uc_company_capital"]) + return max_(0, assessed_capital) + company_capital diff --git a/policyengine_uk/variables/input/owned_company/README.md b/policyengine_uk/variables/input/owned_company/README.md index 4bfd490669..784cbe703c 100644 --- a/policyengine_uk/variables/input/owned_company/README.md +++ b/policyengine_uk/variables/input/owned_company/README.md @@ -2,7 +2,7 @@ Facts about a company in which a person stands in a position analogous to a sole owner or partner (an owner-manager trading through a limited company, or -a controlling shareholder in a property company). Universal Credit treats such +a shareholder with meaningful influence in a closely held property company). Universal Credit treats such a person as the sole owner or partner (Universal Credit Regulations 2013 reg. 77; Universal Credit Regulations (Northern Ireland) 2016 reg. 77); see `gov/dwp/universal_credit/company/`. diff --git a/policyengine_uk/variables/input/owned_company/owned_company_income_share.py b/policyengine_uk/variables/input/owned_company/owned_company_income_share.py index a73b553051..130d33f326 100644 --- a/policyengine_uk/variables/input/owned_company/owned_company_income_share.py +++ b/policyengine_uk/variables/input/owned_company/owned_company_income_share.py @@ -9,14 +9,19 @@ class owned_company_income_share(Variable): "The income of the company in which the person stands as sole owner " "or partner, or the person's share of that income, calculated as " "self-employed earnings would be: the company's actual receipts less " - "its permitted expenses, on a cash basis. Pay the company gives the " - "person as a director or employee is a company expense here and is " - "recorded in employment_income instead. Corporation tax is not " + "its permitted expenses, on a cash basis; negative for a loss. Pay " + "the company gives the person as a director or employee is a company " + "expense here (DWP operational guidance deducts it from the company's " + "net profit so that it is not counted twice) and is recorded in " + "employment_income instead. Corporation tax is not " "deducted: the self-employed earnings calculation deducts only " "income tax and National Insurance the person pays, and neither the " "regulations nor DWP guidance provide for corporation tax. Dividends " "the person receives from the company are paid out of this income, " - "so they are not added to it." + "so they are not added to it. Dividends are never Universal Credit " + "unearned income (UC Regs 2013 reg. 66(1)); a model version that still " + "counts dividend_income as unearned income counts any dividends this " + "person also records there a second time." ) definition_period = YEAR unit = GBP diff --git a/policyengine_uk/variables/input/owned_company/owned_company_trade_assets.py b/policyengine_uk/variables/input/owned_company/owned_company_trade_assets.py index e37de76746..160e9263cc 100644 --- a/policyengine_uk/variables/input/owned_company/owned_company_trade_assets.py +++ b/policyengine_uk/variables/input/owned_company/owned_company_trade_assets.py @@ -7,7 +7,9 @@ class owned_company_trade_assets(Variable): label = "owned company trade assets" documentation = ( "The part of owned_company_capital made up of the company's assets " - "that are used wholly and exclusively for the purposes of its trade." + "that are used wholly and exclusively for the purposes of its trade, " + "valued net of any liabilities secured on them so that it is a " + "component of the net owned_company_capital." ) definition_period = YEAR unit = GBP From 2a52fa09f99bf886cd4f2636449f0a22f0f243b9 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 13 Oct 2026 07:00:14 -0400 Subject: [PATCH 5/6] Deduct trade tax inside reg 57(2); leave the cap to #1820; pool dependants' holdings Addresses the GPT-6.1 Sol round-3 review at e6ebb9c8b. - Reg 57(2) step 3 deducts income tax and NI paid in respect of any trade from the combined trading result before the nil rule. Earned income took all tax and NI on earnings off the total after flooring the trade, so a company loss that cancelled a taxed trade left that trade's tax coming off pay (12,000 pay, 20,000 own-trade profit, -20,000 company income gave 7,668.20 instead of 12,000). New uc_income_tax_on_trade (tax on earnings less the tax on the other earnings alone, which take the lower slice as PAYE codes give the allowance to an employment) and uc_national_insurance_on_trade (Class 2 and 4), each capped at the earnings figure in use, which can be an input. uc_income_tax_on_earnings is unchanged; its computation moved to utils/uc_income_tax.py so both take slices of one computation. - is_benefit_cap_exempt_earnings is main's again. A company term there carried signed losses and dividend tax into the test; #1820 rebases the test on uc_individual_earned_income_before_mif, which includes company earnings, and on the 16-hour National Living Wage threshold. The two cap YAML cases (one of which blessed main's out-of-date 10,152) go. - household_uc_unreported_company_holdings becomes household_uc_pool_company_holdings: every holding except a reporting unit's claimant's or partner's leaves the household pool, so a dependant's holding in a reporting family no longer counts for an unrelated unreported claimant. - Tests: the review's deduction-order case and its control, the reporting-family dependant holding case; properties now check the reg 57(2) order, an independent no-tax identity at low incomes, LCWRA owners get no floor, and disregarded holdings count for no unit. Co-Authored-By: Claude Opus 5.5 --- changelog.d/1948.added.md | 6 +- .../uc_company_analogous_business.yaml | 153 +++++++++++------- .../tests/test_uc_company_owner_properties.py | 124 +++++++++++++- policyengine_uk/utils/uc_income_tax.py | 103 ++++++++++++ .../gov/dwp/is_benefit_cap_exempt_earnings.py | 3 - .../household_uc_pool_company_holdings.py | 38 +++++ ...ousehold_uc_unreported_company_holdings.py | 29 ---- .../income/uc_income_tax_on_earnings.py | 95 +---------- .../income/uc_income_tax_on_trade.py | 40 +++++ .../uc_individual_earned_income_before_mif.py | 44 +++-- .../income/uc_national_insurance_on_trade.py | 25 +++ .../universal_credit/uc_assessable_capital.py | 20 ++- 12 files changed, 456 insertions(+), 224 deletions(-) create mode 100644 policyengine_uk/utils/uc_income_tax.py create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/household_uc_pool_company_holdings.py delete mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/household_uc_unreported_company_holdings.py create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/income/uc_income_tax_on_trade.py create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/income/uc_national_insurance_on_trade.py diff --git a/changelog.d/1948.added.md b/changelog.d/1948.added.md index dce4bb3eb6..20ea05cdfd 100644 --- a/changelog.d/1948.added.md +++ b/changelog.d/1948.added.md @@ -1,7 +1,7 @@ - Universal Credit now applies regulation 77 to a person who stands as sole owner or partner of a company carrying on a trade or property business. - - A trading company's income counts as their self-employed earnings on top of any pay from it. A company loss is set against the profit of any trade they carry on themselves, and a negative total counts as nil. - - Only a claimant's or partner's company earnings reduce the award or count towards the benefit cap earnings exemption. + - A trading company's income counts as their self-employed earnings on top of any pay from it. A company loss is set against the profit of any trade they carry on themselves, the tax and National Insurance on their trades come off that total, and a negative result counts as nil, so neither a loss nor the tax on a trade it cancels comes off their pay. + - Only a claimant's or partner's company earnings reduce the award. - The minimum income floor applies when the trade is their main employment. - - The company's capital replaces the value of their shares. Trade assets are disregarded while they work in the trade. In households with several benefit units, the shares leave the household's capital before it is shared out. + - The company's capital replaces the value of their shares. Trade assets are disregarded while they work in the trade. In households with several benefit units, the shares leave the household's capital before it is shared out, including a dependant's, which count for nobody. - The intermediaries (IR35) exclusion follows reg. 77(5) as in force: Chapters 8 and 9 of ITEPA Part 2 until 27 November 2018, and Chapters 8 to 10 from main-employment activities from 28 November 2018. Annual simulations take the rule in force at the start of the year, so 2018 uses the earlier rule throughout. - New `owned_company_*` inputs describe the company. Every input defaults to no such company, so existing datasets are unchanged. diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/company/uc_company_analogous_business.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/company/uc_company_analogous_business.yaml index 1a551f8e79..be74f2340d 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/company/uc_company_analogous_business.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/company/uc_company_analogous_business.yaml @@ -483,7 +483,7 @@ savings: 10_000 corporate_wealth: 30_000 output: - household_uc_unreported_company_holdings: 30_000 + household_uc_pool_company_holdings: 30_000 uc_assessable_capital: [5_000, 5_000] - name: A benunit that reports its capital has its own holding taken off what it reports @@ -509,37 +509,12 @@ output: uc_assessable_capital: 35_000 -- name: Company earnings count towards the benefit cap earnings exemption - # UC Regs 2013 reg. 82(1)(a): the cap does not apply where earned income is - # at least the threshold. Reg. 77(3)(b) earnings are self-employed earnings - # (the model's threshold is 10,152 a year); only income deemed by the - # minimum income floor is excluded (reg. 82(4)). 20,000 of company income, - # not in a start-up period but not the main employment, so no floor. - period: 2026 - absolute_error_margin: 0 - input: - people: - person: - age: 40 - stands_as_sole_owner_or_partner_of_company: true - owned_company_carries_on_trade: true - owned_company_income_share: 20_000 - benunits: - benunit: - members: person - households: - household: - members: person - output: - is_benefit_cap_exempt_earnings: true - - name: A dependant's company earnings are not the claimant's earned income # Reg. 22(1)(b) deducts "the claimant's earned income (or, in the case of - # joint claimants, their combined earned income)", and reg. 82(1)(a) tests - # the same for the benefit cap. A qualifying young person who owns a - # trading company is not a claimant: their 20,000 of company income is - # their own self-employed earnings but neither reduces the award nor - # exempts the household from the cap. + # joint claimants, their combined earned income)". A qualifying young + # person who owns a trading company is not a claimant: their 20,000 of + # company income is their own self-employed earnings but does not reduce + # the award, and no floor applies to them. period: 2026 absolute_error_margin: 0 input: @@ -561,62 +536,116 @@ output: is_uc_assessed_claimant: [true, false] uc_company_self_employed_earnings: [0, 20_000] + uc_mif_applies: [false, false] uc_earned_income: 0 - is_benefit_cap_exempt_earnings: false -- name: Company earnings below the benefit cap earnings threshold leave the cap in place - # Reg. 82(1)(a): the earnings exception needs earned income of at least the - # threshold (10,152 a year in the model). 10,000 of company income falls - # short; 10,152 meets it, so a rise in company income can lift the cap and - # raise the award. +- name: A holding is disregarded only from the corporate wealth that holds it + # Reg. 77(2) disregards "the value of the person's holding in the + # company", which is shares, so it comes out of the household's + # corporate_wealth and cannot remove savings. A 10,000 holding entered with + # 10,000 of savings and no corporate_wealth leaves the 10,000 of savings. period: 2026 absolute_error_margin: 0 input: people: - low: - age: 40 - stands_as_sole_owner_or_partner_of_company: true - owned_company_carries_on_trade: true - owned_company_income_share: 10_000 - high: + person: age: 40 stands_as_sole_owner_or_partner_of_company: true owned_company_carries_on_trade: true - owned_company_income_share: 10_152 + owned_company_holding_value: 10_000 benunits: - low_unit: - members: [low] - high_unit: - members: [high] + benunit: + members: person households: - low_household: - members: [low] - high_household: - members: [high] + household: + members: person + savings: 10_000 output: - is_benefit_cap_exempt_earnings: [false, true] + household_uc_pool_company_holdings: 0 + uc_assessable_capital: 10_000 -- name: A holding is disregarded only from the corporate wealth that holds it - # Reg. 77(2) disregards "the value of the person's holding in the - # company", which is shares, so it comes out of the household's - # corporate_wealth and cannot remove savings. A 10,000 holding entered with - # 10,000 of savings and no corporate_wealth leaves the 10,000 of savings. +- name: Tax and National Insurance on a trade a company loss cancels are not taken off pay + # Reg. 57(2): the profit or loss of each trade is added together (step 2), + # then income tax and National Insurance paid in respect of any trade come + # off (step 3), and a nil or negative result is nil. 20,000 of profit from + # the owner's own trade and the company's 20,000 loss make 0. The trade's + # income tax is (12,000 + 20,000 - 12,570) x 20% = 3,886, all of it on the + # trade because the 12,000 of pay is within the personal allowance, and + # its Class 4 contributions are (20,000 - 12,570) x 6% = 445.80; both + # stay inside the nil trading result. Pay is below the primary threshold, + # so employed earnings are 12,000 (reg. 55(5)). In a start-up period, so + # no floor. 55% of 12,000 is more than the maximum amount, so no award. period: 2026 - absolute_error_margin: 0 + absolute_error_margin: 0.01 input: people: person: age: 40 + employment_income: 12_000 + self_employment_income: 20_000 + uc_is_in_startup_period: true stands_as_sole_owner_or_partner_of_company: true owned_company_carries_on_trade: true - owned_company_holding_value: 10_000 + owned_company_income_share: -20_000 benunits: benunit: members: person households: household: members: person - savings: 10_000 output: - household_uc_unreported_company_holdings: 0 - uc_assessable_capital: 10_000 + uc_income_tax_on_trade: [3_886] + uc_national_insurance_on_trade: [445.80] + uc_individual_earned_income_before_mif: [12_000] + universal_credit: 0 + +- name: Without the company loss the same tax and National Insurance come off the trade + # The control for the case above: 12,000 + 20,000 - 3,886 - 445.80. + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 12_000 + self_employment_income: 20_000 + uc_is_in_startup_period: true + output: + uc_income_tax_on_trade: 3_886 + uc_national_insurance_on_trade: 445.80 + uc_individual_earned_income_before_mif: 27_668.20 + +- name: A dependant's holding in a benunit that reports its capital leaves the household pool + # The parent reports nil capital, so their benunit's capital is that + # figure; a dependant's capital is not theirs, and the 17-year-old's 12,000 + # holding (all of the household's corporate_wealth) is disregarded (reg. + # 77(2)) and is nobody's to count. So it leaves the pool shared by the + # unrelated adult, who does not report: 8,000 + 12,000 - 12,000 = 8,000. + # The dependant's 25,000 of company capital is not a claimant's. + period: 2026 + absolute_error_margin: 0 + input: + people: + parent: + age: 40 + student: + age: 17 + is_in_non_advanced_education: true + stands_as_sole_owner_or_partner_of_company: true + owned_company_carries_on_trade: true + owned_company_holding_value: 12_000 + owned_company_capital: 25_000 + lodger: + age: 40 + benunits: + family: + members: [parent, student] + uc_reported_capital: 0 + lodger_unit: + members: [lodger] + households: + household: + members: [parent, student, lodger] + savings: 8_000 + corporate_wealth: 12_000 + output: + household_uc_pool_company_holdings: 12_000 + uc_assessable_capital: [0, 8_000] diff --git a/policyengine_uk/tests/test_uc_company_owner_properties.py b/policyengine_uk/tests/test_uc_company_owner_properties.py index af1a3ed24e..f55e70a881 100644 --- a/policyengine_uk/tests/test_uc_company_owner_properties.py +++ b/policyengine_uk/tests/test_uc_company_owner_properties.py @@ -95,6 +95,8 @@ "uc_mif_applies", "uc_income_tax_on_earnings", "uc_national_insurance_on_earnings", + "uc_income_tax_on_trade", + "uc_national_insurance_on_trade", "pension_contributions", "is_uc_assessed_claimant", ] @@ -140,6 +142,7 @@ def adults(draw): employment_income=draw(st.one_of(st.just(0.0), money)), self_employment_income=draw(st.one_of(st.just(0.0), st.floats(0, 15_000))), uc_is_in_startup_period=draw(st.sampled_from([False, False, True])), + uc_limited_capability_for_WRA=draw(st.sampled_from([False] * 4 + [True])), company=draw(companies()), ) @@ -168,6 +171,9 @@ def situation(variants): "employment_income": {YEAR: adult["employment_income"]}, "self_employment_income": {YEAR: adult["self_employment_income"]}, "uc_is_in_startup_period": {YEAR: adult["uc_is_in_startup_period"]}, + "uc_limited_capability_for_WRA": { + YEAR: adult["uc_limited_capability_for_WRA"] + }, **{k: {YEAR: v} for k, v in facts.items()}, } names.append(name) @@ -301,25 +307,55 @@ def test_capital_and_earnings_identities(unit_list): company_capital += company_capital_of(c) # Earnings identity, before the floor: reg. 77(3)(b) adds the # company's profit or loss to the person's own trade (reg. 57(2) - # step 2) before a negative total becomes nil. + # step 2), the tax and NI on the trade come off that total (step + # 3), and a negative result becomes nil; pay is net of the tax and + # NI on the employment (reg. 55(5)). company_profit_or_loss = ( c["owned_company_income_share"] if applies(c) and c["owned_company_carries_on_trade"] else 0.0 ) + trade_deductions = ( + got["uc_income_tax_on_trade"][k] + + got["uc_national_insurance_on_trade"][k] + ) self_employed = max( - 0.0, adult["self_employment_income"] + company_profit_or_loss + 0.0, + adult["self_employment_income"] + + company_profit_or_loss + - trade_deductions, ) - deductions = ( - got["pension_contributions"][k] - + got["uc_income_tax_on_earnings"][k] + employment_deductions = ( + got["uc_income_tax_on_earnings"][k] + got["uc_national_insurance_on_earnings"][k] + - trade_deductions ) before = got["uc_individual_earned_income_before_mif"][k] assert close( before, - max(0.0, adult["employment_income"] + self_employed - deductions), + max( + 0.0, + adult["employment_income"] + - employment_deductions + + self_employed + - got["pension_contributions"][k], + ), ) + # Independently of the model's tax: earnings within the personal + # allowance, the primary threshold and the lower profits limit + # bear no income tax or NI, so earned income is pay plus the nil + # floored trading total. + if ( + adult["employment_income"] + adult["self_employment_income"] <= 12_000 + and got["pension_contributions"][k] == 0 + ): + assert close( + before, + adult["employment_income"] + + max( + 0.0, adult["self_employment_income"] + company_profit_or_loss + ), + ) # Floor trigger: reg. 62(1) and (5), with reg. 77(3)(c). trade_main = ( applies(c) @@ -334,6 +370,12 @@ def test_capital_and_earnings_identities(unit_list): and gainful and not adult["uc_is_in_startup_period"] ) + # Independently of the model's groups: limited capability for + # work and work-related activity puts a claimant in the no + # work-related requirements group (Welfare Reform Act 2012 + # s. 19(2)(a)), so no floor (reg. 62(1)(b)). + if adult["uc_limited_capability_for_WRA"]: + assert not got["uc_mif_applies"][k] earned = got["uc_individual_earned_income"][k] floor_can_apply = ( got["uc_mif_applies"][k] and got["is_uc_assessed_claimant"][k] @@ -576,3 +618,73 @@ def build(facts): got, base = build(company), build(none) for v in VARIABLES: np.testing.assert_allclose(got[v], base[v], rtol=0, atol=1e-6, err_msg=v) + + +@PROPERTY_SETTINGS +@given( + st.lists(st.tuples(units(), units()), min_size=1, max_size=3), + st.floats(1, 20_000), + st.floats(1, 20_000), + st.one_of(st.none(), st.floats(0, 20_000)), +) +def test_disregarded_holdings_count_for_no_benefit_unit( + pairs, dependant_holding, owner_holding, reported +): + # Each household holds a family (the first unit's adults and a + # qualifying young person), which reports its capital unless `reported` + # is None, and a second, unreported benefit unit. The young person and + # the second unit's first adult each hold shares in a company they stand + # as sole owner of; the shares are in the household's corporate_wealth. + # Reg. 77(2) disregards both holdings, and a dependant's capital is no + # claimant's, so adding the shares changes no unit's capital. + def build(with_holdings): + people, benunits, households = {}, {}, {} + for h, (family, other) in enumerate(pairs): + family_names = [] + for j, adult in enumerate(family["adults"]): + people[f"h{h}_f{j}"] = {"age": {YEAR: adult["age"]}} + family_names.append(f"h{h}_f{j}") + dependant = { + "age": {YEAR: 17}, + "is_in_non_advanced_education": {YEAR: True}, + } + other_names = [] + for j, adult in enumerate(other["adults"]): + people[f"h{h}_o{j}"] = {"age": {YEAR: adult["age"]}} + other_names.append(f"h{h}_o{j}") + shares = 0.0 + if with_holdings: + owner = { + "stands_as_sole_owner_or_partner_of_company": {YEAR: True}, + "owned_company_carries_on_trade": {YEAR: True}, + } + dependant.update( + owner, owned_company_holding_value={YEAR: dependant_holding} + ) + people[other_names[0]].update( + owner, owned_company_holding_value={YEAR: owner_holding} + ) + shares = dependant_holding + owner_holding + people[f"h{h}_d"] = dependant + family_names.append(f"h{h}_d") + benunits[f"h{h}_family"] = {"members": family_names} + if reported is not None: + benunits[f"h{h}_family"]["uc_reported_capital"] = {YEAR: reported} + benunits[f"h{h}_other"] = {"members": other_names} + households[f"h{h}"] = { + "members": family_names + other_names, + "savings": {YEAR: family["savings"]}, + "corporate_wealth": {YEAR: family["corporate_wealth"] + shares}, + } + sim = Simulation( + situation={ + "people": people, + "benunits": benunits, + "households": households, + } + ) + return np.asarray(sim.calculate("uc_assessable_capital", YEAR)) + + with_holdings, without = build(True), build(False) + for got, want in zip(with_holdings, without): + assert close(got, want) diff --git a/policyengine_uk/utils/uc_income_tax.py b/policyengine_uk/utils/uc_income_tax.py new file mode 100644 index 0000000000..c34920d687 --- /dev/null +++ b/policyengine_uk/utils/uc_income_tax.py @@ -0,0 +1,103 @@ +"""Income tax on a person's earnings, as Universal Credit deducts it. + +Shared by uc_income_tax_on_earnings and uc_income_tax_on_trade, so both +take the same slice of the same tax computation. +""" + +from policyengine_core.model_api import add, max_, where + +from policyengine_uk.utils.income_tax import ( + calc_income_tax_with_gift_aid, + first_extended_scottish_threshold, +) + + +def uc_income_tax_on_earnings_slice(person, period, parameters, include_trade=True): + """Income tax on the person's earnings taken as the lowest slice of income. + + With ``include_trade`` false, the slice is the person's employment and + miscellaneous earnings alone, below their trading profits: the tax they + would pay on those earnings if they had no trade, as PAYE codes give the + personal allowance to the employment first. + """ + # Reg. 55(5)(b) deducts income tax paid by the person "in respect of + # the employment" and reg. 57(2) step 3 income tax paid "in respect + # of any trade". Neither says how to split a person's tax when they + # also have other income. Earnings are taken as the lowest slice of + # their non-savings income, after the allowances they actually have: + # savings and dividends sit above earnings and other non-savings + # income (as in ITA 2007 s. 16), property income above the rest of + # it (as in s. 16A from 2027-28), and other non-savings income + # (private pensions, State Pension, taxable benefits) above + # earnings. So tax on other income never comes off earnings. Under RTI, DWP deducts the PAYE + # actually taken on the job, which can include tax on a State + # Pension coded against it; that is not modelled. + p = parameters(period) + income_tax = p.gov.hmrc.income_tax + # earned_taxable_income is net income less the incomes and + # allowances on the exclusions list. Read that list, so a reform + # that changes it (for example one that exempts pensions) is + # followed here. + exclusions = list(income_tax.earned_taxable_income_exclusions) + excluded_incomes = [ + variable + for variable in exclusions + if variable in income_tax.adjusted_net_income_components + ] + earnings_components = ["taxable_employment_income", "taxable_miscellaneous_income"] + if include_trade: + earnings_components.append("taxable_self_employment_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") + earnings = add( + person, + period, + [variable for variable in earnings_components if variable not in exclusions], + ) + # The income left in earned_taxable_income before allowances. The + # part of it above earnings is the person's other non-savings income. + income_in_base = person("net_income", period) - add( + person, period, excluded_incomes + ) + other_income = max_(0, income_in_base - earnings) + taxable_earnings = max_(0, person("earned_taxable_income", period) - other_income) + rates = income_tax.rates + gross_gift = person("gift_aid_grossed_up", period) + tax = where( + person("pays_scottish_income_tax", period), + calc_income_tax_with_gift_aid( + rates.scotland.rates, + taxable_earnings, + gross_gift, + first_extended_scottish_threshold(rates.scotland.rates), + len(rates.scotland.rates.thresholds), + ), + calc_income_tax_with_gift_aid(rates.uk, taxable_earnings, gross_gift), + ) + # Tax reductions (for example the married couple's allowance) come + # off the tax on earnings first, as allowances do. So the deduction + # never exceeds the income tax the person pays, never includes a + # charge such as the High Income Child Benefit Charge, and does not + # move when other income absorbs more or less of a reduction. The + # exception is the finance-cost reduction, which is given on property + # profits and limited by them (ITTOIA 2005 s. 274AA), so it comes off + # the tax on property income first. Only a part larger than that tax + # (possible where Scottish rates on property income are below the + # reduction's rate) comes off the tax on earnings. + reductions = add( + person, + period, + [ + variable + for variable in income_tax.income_tax_subtractions + if variable != "property_finance_cost_relief" + ], + ) + reductions += max_( + 0, + person("property_finance_cost_relief", period) + - person("property_income_tax", period), + ) + return max_(0, tax - reductions) 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 79320e00c6..76e7dcf27c 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 @@ -21,9 +21,6 @@ def formula(benunit, period, parameters): ( person("employment_income", period) + person("self_employment_income", period) - # A company's income treated as its owner's self-employed - # earnings (UC Regs 2013 reg. 77(3)(b)). - + person("uc_company_self_employed_earnings", period) - person("income_tax", period) - person("national_insurance", period) ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_pool_company_holdings.py b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_pool_company_holdings.py new file mode 100644 index 0000000000..91862290a9 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_pool_company_holdings.py @@ -0,0 +1,38 @@ +from policyengine_uk.model_api import * + + +class household_uc_pool_company_holdings(Variable): + value_type = float + entity = Household + label = ( + "Company holdings taken out of the household's Universal Credit capital pool" + ) + documentation = ( + "Disregarded company holdings (uc_company_holding_disregard) taken " + "out of the household's capital before it is shared among the " + "claimants and partners of benunits that do not report their " + "capital. That is every holding except a reporting benunit's " + "claimant's or partner's, which comes off that benunit's reported " + "capital instead: the holdings of everyone in benunits that do not " + "report, and of dependants in benunits that do, whose capital is " + "nobody's to count. So no benunit counts any part of a disregarded " + "holding. A holding is shares, held within the household's " + "corporate_wealth, so no more than that is taken out: a holding " + "entered without matching corporate_wealth cannot remove savings or " + "property." + ) + definition_period = YEAR + unit = GBP + quantity_type = STOCK + reference = "https://www.legislation.gov.uk/uksi/2013/376/regulation/77" + + def formula(household, period, parameters): + person = household.members + reported = person.benunit("uc_reported_capital", period) >= 0 + claimant_or_partner = person("is_uc_claimant", period) + off_reported_capital = reported & claimant_or_partner + holdings = household.sum( + person("uc_company_holding_disregard", period) * ~off_reported_capital + ) + corporate_wealth = max_(0, household("corporate_wealth", period)) + return min_(holdings, corporate_wealth) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_unreported_company_holdings.py b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_unreported_company_holdings.py deleted file mode 100644 index 8231ee5c86..0000000000 --- a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_unreported_company_holdings.py +++ /dev/null @@ -1,29 +0,0 @@ -from policyengine_uk.model_api import * - - -class household_uc_unreported_company_holdings(Variable): - value_type = float - entity = Household - label = "Household company holdings disregarded for Universal Credit, unreported benunits" - documentation = ( - "The disregarded holdings (uc_company_holding_disregard) of people in " - "benunits without reported UC capital. They are taken out of the " - "household's capital before it is shared among those benunits' " - "claimants and partners, so no benunit counts any part of a " - "disregarded holding. A holding is shares, held within the " - "household's corporate_wealth, so no more than that is taken out: a " - "holding entered without matching corporate_wealth cannot remove " - "savings or property." - ) - definition_period = YEAR - unit = GBP - quantity_type = STOCK - - def formula(household, period, parameters): - person = household.members - has_reported_capital = person.benunit("uc_reported_capital", period) >= 0 - holdings = household.sum( - person("uc_company_holding_disregard", period) * ~has_reported_capital - ) - corporate_wealth = max_(0, household("corporate_wealth", period)) - return min_(holdings, corporate_wealth) 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 bd3d93a2ff..4c3a189149 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 @@ -1,8 +1,5 @@ from policyengine_uk.model_api import * -from policyengine_uk.utils.income_tax import ( - calc_income_tax_with_gift_aid, - first_extended_scottish_threshold, -) +from policyengine_uk.utils.uc_income_tax import uc_income_tax_on_earnings_slice class uc_income_tax_on_earnings(Variable): @@ -34,92 +31,4 @@ class uc_income_tax_on_earnings(Variable): ] def formula(person, period, parameters): - # Reg. 55(5)(b) deducts income tax paid by the person "in respect of - # the employment" and reg. 57(2) step 3 income tax paid "in respect - # of any trade". Neither says how to split a person's tax when they - # also have other income. Earnings are taken as the lowest slice of - # their non-savings income, after the allowances they actually have: - # savings and dividends sit above earnings and other non-savings - # income (as in ITA 2007 s. 16), property income above the rest of - # it (as in s. 16A from 2027-28), and other non-savings income - # (private pensions, State Pension, taxable benefits) above - # earnings. So tax on other income never comes off earnings. Under RTI, DWP deducts the PAYE - # actually taken on the job, which can include tax on a State - # Pension coded against it; that is not modelled. - p = parameters(period) - income_tax = p.gov.hmrc.income_tax - # earned_taxable_income is net income less the incomes and - # allowances on the exclusions list. Read that list, so a reform - # that changes it (for example one that exempts pensions) is - # followed here. - exclusions = list(income_tax.earned_taxable_income_exclusions) - excluded_incomes = [ - variable - for variable in exclusions - if variable in income_tax.adjusted_net_income_components - ] - earnings_components = [ - "taxable_employment_income", - "taxable_self_employment_income", - "taxable_miscellaneous_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") - earnings = add( - person, - period, - [ - variable - for variable in earnings_components - if variable not in exclusions - ], - ) - # The income left in earned_taxable_income before allowances. The - # part of it above earnings is the person's other non-savings income. - income_in_base = person("net_income", period) - add( - person, period, excluded_incomes - ) - other_income = max_(0, income_in_base - earnings) - taxable_earnings = max_( - 0, person("earned_taxable_income", period) - other_income - ) - rates = income_tax.rates - gross_gift = person("gift_aid_grossed_up", period) - tax = where( - person("pays_scottish_income_tax", period), - calc_income_tax_with_gift_aid( - rates.scotland.rates, - taxable_earnings, - gross_gift, - first_extended_scottish_threshold(rates.scotland.rates), - len(rates.scotland.rates.thresholds), - ), - calc_income_tax_with_gift_aid(rates.uk, taxable_earnings, gross_gift), - ) - # Tax reductions (for example the married couple's allowance) come - # off the tax on earnings first, as allowances do. So the deduction - # never exceeds the income tax the person pays, never includes a - # charge such as the High Income Child Benefit Charge, and does not - # move when other income absorbs more or less of a reduction. The - # exception is the finance-cost reduction, which is given on property - # profits and limited by them (ITTOIA 2005 s. 274AA), so it comes off - # the tax on property income first. Only a part larger than that tax - # (possible where Scottish rates on property income are below the - # reduction's rate) comes off the tax on earnings. - reductions = add( - person, - period, - [ - variable - for variable in income_tax.income_tax_subtractions - if variable != "property_finance_cost_relief" - ], - ) - reductions += max_( - 0, - person("property_finance_cost_relief", period) - - person("property_income_tax", period), - ) - return max_(0, tax - reductions) + return uc_income_tax_on_earnings_slice(person, period, parameters) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_income_tax_on_trade.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_income_tax_on_trade.py new file mode 100644 index 0000000000..40a518f032 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_income_tax_on_trade.py @@ -0,0 +1,40 @@ +from policyengine_uk.model_api import * +from policyengine_uk.utils.uc_income_tax import uc_income_tax_on_earnings_slice + + +class uc_income_tax_on_trade(Variable): + value_type = float + entity = Person + label = "Universal Credit deduction for income tax on the person's trade" + documentation = ( + "The part of uc_income_tax_on_earnings paid in respect of the " + "person's trade, profession or vocation, which reg. 57(2) step 3 " + "deducts inside the self-employed earnings calculation: the tax on " + "their earnings less the tax on their other earnings alone. The " + "other earnings are taken as the lower slice, as PAYE codes give " + "the personal allowance to an employment first; the rest of the " + "tax on earnings is in respect of the employment (reg. 55(5)(b))." + ) + definition_period = YEAR + unit = GBP + reference = [ + dict( + title="Universal Credit Regulations 2013 reg. 57(2), step 3", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/57", + ), + dict( + title="Universal Credit Regulations 2013 reg. 55(5)(b)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/55", + ), + ] + + def formula(person, period, parameters): + with_trade = uc_income_tax_on_earnings_slice(person, period, parameters) + without_trade = uc_income_tax_on_earnings_slice( + person, period, parameters, include_trade=False + ) + # Never more than the tax on earnings in use, which can be an input: + # the rest of that figure is in respect of the employment. + return min_( + with_trade - without_trade, person("uc_income_tax_on_earnings", period) + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py index c59bb4d452..f608b6f6bb 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py @@ -61,30 +61,40 @@ def formula(person, period, parameters): 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. A company's income treated - # as the owner's self-employed earnings (reg. 77(3)(b)) is another - # trade, added before that nil rule (reg. 57(2) step 2). - self_employed = max_( - 0, + # Self-employed earnings follow reg. 57(2): each trade's profit or + # loss is added together (step 2), including a company's income + # treated as the owner's self-employed earnings (reg. 77(3)(b)), and + # the income tax and National Insurance paid in respect of any trade + # come off that total (step 3) before a nil or negative result + # becomes nil. So a trading loss, or tax on a trade a company loss + # has cancelled, is never set against employed earnings. + trade = ( person("self_employment_income", period) - + person("uc_company_self_employed_earnings", period), + + person("uc_company_self_employed_earnings", period) + - person("uc_income_tax_on_trade", period) + - person("uc_national_insurance_on_trade", period) + ) + self_employed = max_(0, trade) + # Employed earnings less the income tax and primary Class 1 + # contributions paid in respect of the employment (reg. 55(5)(b)). + employment_tax_and_national_insurance = ( + person("uc_income_tax_on_earnings", period) + - person("uc_income_tax_on_trade", period) + + person("uc_national_insurance_on_earnings", period) + - person("uc_national_insurance_on_trade", period) + ) + employed = ( + add(person, period, earnings_components) + - employment_tax_and_national_insurance ) - 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. + # them. Reg. 55(5)(a) takes them off employed earnings and reg. + # 57(2) step 4 off what is left of self-employed earnings. 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 - ) + return max_(0, employed + self_employed - relievable_pension_contributions) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_national_insurance_on_trade.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_national_insurance_on_trade.py new file mode 100644 index 0000000000..2e8062a808 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_national_insurance_on_trade.py @@ -0,0 +1,25 @@ +from policyengine_uk.model_api import * + + +class uc_national_insurance_on_trade(Variable): + value_type = float + entity = Person + label = "Universal Credit deduction for National Insurance on the person's trade" + documentation = ( + "Class 2 and Class 4 contributions, paid in respect of the person's " + "trade, which reg. 57(2) step 3 deducts inside the self-employed " + "earnings calculation. The rest of uc_national_insurance_on_earnings " + "(primary Class 1) is in respect of the employment (reg. 55(5)(b))." + ) + definition_period = YEAR + unit = GBP + reference = dict( + title="Universal Credit Regulations 2013 reg. 57(2), step 3", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/57", + ) + + def formula(person, period, parameters): + trade = add(person, period, ["ni_class_2", "ni_class_4"]) + # Never more than the NI on earnings in use, which can be an input: + # the rest of that figure is in respect of the employment. + return min_(trade, person("uc_national_insurance_on_earnings", period)) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_assessable_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_assessable_capital.py index 4b84464a86..b3b6c78158 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_assessable_capital.py @@ -54,18 +54,16 @@ def formula(benunit, period, parameters): # A person who stands as sole owner or partner of a company has their # holding in it disregarded and is treated as possessing the company's # capital (or their share of it) instead (UC Regs 2013 reg. 77(2), - # (3)(a)). Holdings of people in unreported benunits leave the - # household pool before it is shared out, up to the household's - # corporate_wealth that holds them; a reporting benunit's claimant and - # partner's holdings come off its reported capital, which has no - # breakdown to cap them against. Neither subtraction can take capital - # below zero. Only the claimant and partner's company capital is - # theirs to count. - unreported_holdings = household( - "household_uc_unreported_company_holdings", period - ) + # (3)(a)). A reporting benunit's claimant and partner's holdings come + # off its reported capital, which has no breakdown to cap them + # against. Every other holding (those of people in unreported + # benunits, and of dependants in reporting ones) leaves the household + # pool before it is shared out, up to the household's corporate_wealth + # that holds them. Neither subtraction can take capital below zero. + # Only the claimant and partner's company capital is theirs to count. + pool_holdings = household("household_uc_pool_company_holdings", period) residual_household_capital = max_( - 0, household_capital - household_reported_capital - unreported_holdings + 0, household_capital - household_reported_capital - pool_holdings ) household_capital_proxy = where( household_unreported_claimants > 0, From 93dd669cc9de5a4b255ab746f1b81752c170a633 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 13 Oct 2026 07:41:08 -0400 Subject: [PATCH 6/6] Pin reg 77 property examples; describe the invariants as tested The derandomised examples had drifted away from three reg 77 branches, so three reinstated defects passed. Pinned examples now make every run reach the 77(3)(c) floor, a company loss against the owner's own trade, the tax on a trade a company loss cancels, an LCWRA owner, and a disregarded holding in a shared household. All eight mutants in the evidence script now fail the properties. The module docstring states invariant 4 in reg 57(2) order and lists all nine invariants (round-4 review, non-blocking). Co-Authored-By: Claude Opus 5.5 --- .../tests/test_uc_company_owner_properties.py | 134 ++++++++++++++++-- 1 file changed, 120 insertions(+), 14 deletions(-) diff --git a/policyengine_uk/tests/test_uc_company_owner_properties.py b/policyengine_uk/tests/test_uc_company_owner_properties.py index f55e70a881..102ca3bdc2 100644 --- a/policyengine_uk/tests/test_uc_company_owner_properties.py +++ b/policyengine_uk/tests/test_uc_company_owner_properties.py @@ -29,35 +29,45 @@ proportion to their claimants, and each unit adds its own claimants' company capital; so the units' capital sums to that pool plus the owners' company capital. -4. Earnings identity: earned income before the floor is pay plus - self-employed earnings, less the person's own relievable pension - contributions, income tax and National Insurance, floored at zero. - Self-employed earnings are the person's own trading profit or loss plus, - for a trading company, the company income share (a loss is negative), - floored at zero (reg. 57(2) steps 1 and 2); a property-only company adds - nothing. +4. Earnings identity: earned income before the floor is pay less the + person's own income tax and NI on the employment, plus the trading total + floored at zero, less their relievable pension contributions, floored at + zero. The trading total is their own trading profit or loss plus, for a + trading company, the company income share (a loss is negative), less the + income tax and NI on the trade (reg. 57(2) steps 1 to 3); a property-only + company adds nothing. Where pay and own-trade profit are within 12,000, + no tax or NI is due and it is exactly pay plus the floored trading total. 5. Floor: the floor applies exactly to claimants in the all work-related requirements group, outside a start-up period, who are self-employed or own a trading company that is their main employment (reg. 62(1), (5), - reg. 77(3)(c)). It never lowers earned income, and a single claimant it - applies to has earned income of at least the floor. + reg. 77(3)(c)), and never to one with limited capability for work and + work-related activity. It never lowers earned income, and a single + claimant it applies to has earned income of at least the floor. 6. Monotone: Universal Credit is non-increasing in the company income share, among cases where the extra income does not change the benefit cap earnings exemption (reg. 82(1)(a): crossing the threshold lifts the cap and can raise the award), and in the company's capital; it is non-decreasing in the value of the disregarded holding and of disregarded trade assets. -7. Dependants: company income and capital belonging to a dependant change - neither the award nor its capital, earned income or cap exemption - (reg. 22(1)(b), reg. 82(1)(a)). +7. Property companies: the income share and the main-employment flag never + change Universal Credit or earned income for a property-only company. +8. Dependants: company income and capital belonging to a dependant (with no + holding) change neither the award nor its capital or earned income + (reg. 22(1)(b)). +9. Holdings count for nobody: adding shares to the household's corporate + wealth as the disregarded holding of a dependant in a family (reporting + its capital or not) and of an adult in another, unreported benefit unit + changes no unit's assessable capital. The strategies make most adults owners of a trading company, keep capital mostly under the £16,000 limit and put earnings around the floor, so each -property is exercised rather than satisfied vacuously. +property is exercised rather than satisfied vacuously. Pinned examples make +every run reach each reg. 77 earnings and floor branch and a shared-household +holding, whatever the generated examples reach. """ 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 Simulation @@ -261,6 +271,89 @@ def company_capital_of(company): return max(0.0, company["owned_company_capital"] - disregarded) +def pinned_company(**facts): + """A pinned example's company: a trading company the adult solely owns.""" + return { + "stands_as_sole_owner_or_partner_of_company": True, + "owned_company_carries_on_trade": True, + "owned_company_carries_on_property_business": False, + "owned_company_intermediary_earnings_chapter": "NONE", + "owned_company_intermediary_earnings_from_main_employment": False, + "owned_company_is_main_employment": False, + "is_engaged_in_owned_company_trade": False, + "owned_company_income_share": 0.0, + "owned_company_capital": 0.0, + "owned_company_trade_assets": 0.0, + "owned_company_holding_value": 0.0, + **facts, + } + + +def pinned_adult(company=None, **facts): + return { + "age": 40, + "employment_income": 0.0, + "self_employment_income": 0.0, + "uc_is_in_startup_period": False, + "uc_limited_capability_for_WRA": False, + "company": company + or pinned_company(stands_as_sole_owner_or_partner_of_company=False), + **facts, + } + + +def pinned_unit(*adults, savings=0.0, corporate_wealth=0.0): + return dict( + adults=list(adults), + children=0, + rent=0.0, + savings=savings, + corporate_wealth=corporate_wealth, + ) + + +# Every run exercises each reg. 77 earnings and floor branch at least once, +# whatever the generated examples reach. +PINNED_EARNINGS_UNITS = [ + # 77(3)(c): main-employment trading company, no other self-employment. + pinned_unit( + pinned_adult( + pinned_company( + owned_company_is_main_employment=True, + owned_company_income_share=6_000.0, + ) + ) + ), + # Reg. 57(2) step 2: a company loss against the owner's own trade. + pinned_unit( + pinned_adult( + pinned_company(owned_company_income_share=-3_000.0), + self_employment_income=8_000.0, + uc_is_in_startup_period=True, + ) + ), + # Reg. 57(2) step 3: the tax on a trade a company loss cancels. + pinned_unit( + pinned_adult( + pinned_company(owned_company_income_share=-20_000.0), + employment_income=12_000.0, + self_employment_income=20_000.0, + uc_is_in_startup_period=True, + ) + ), + # Limited capability for work and work-related activity: no floor. + pinned_unit( + pinned_adult( + pinned_company( + owned_company_is_main_employment=True, + owned_company_income_share=6_000.0, + ), + uc_limited_capability_for_WRA=True, + ) + ), +] + + @PROPERTY_SETTINGS @given(st.lists(units(), min_size=2, max_size=4)) def test_rule_is_inert_when_it_does_not_apply(unit_list): @@ -293,6 +386,7 @@ def test_rule_is_inert_when_it_does_not_apply(unit_list): @PROPERTY_SETTINGS @given(st.lists(units(), min_size=2, max_size=4)) +@example(PINNED_EARNINGS_UNITS) def test_capital_and_earnings_identities(unit_list): got = calculate([(u, {}) for u in unit_list]) base = calculate([(u, no_company(u)) for u in unit_list]) @@ -499,6 +593,18 @@ def test_property_company_gives_no_earnings(unit_list, share): @PROPERTY_SETTINGS @given(st.lists(st.tuples(units(), units()), min_size=1, max_size=3)) +@example( + [ + ( + pinned_unit( + pinned_adult(pinned_company(owned_company_holding_value=30_000.0)), + savings=10_000.0, + corporate_wealth=30_000.0, + ), + pinned_unit(pinned_adult()), + ) + ] +) def test_household_capital_is_allocated_and_conserved_across_benefit_units(pairs): # Each household holds two benefit units: the first unit's adults and the # second's, with the first unit's savings and corporate wealth. No unit