From 574a39fe73537cfe7fea3c1d4e34aacc2dcbac77 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 16:43:42 -0400 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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