diff --git a/changelog.d/2351.fixed.md b/changelog.d/2351.fixed.md new file mode 100644 index 0000000000..429087f2a7 --- /dev/null +++ b/changelog.d/2351.fixed.md @@ -0,0 +1 @@ +Band savings and dividend income above property income, which is non-savings income (ITA 2007 ss. 16, 16A), including in the income the annual allowance charge's marginal rate is read from. diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/property_income_band_base.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/property_income_band_base.yaml new file mode 100644 index 0000000000..7312c6d5bd --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/property_income_band_base.yaml @@ -0,0 +1,141 @@ +# Savings and dividend income are the highest part of total income (ITA 2007 +# s. 16), so property income, like pay, fills the bands below them. 2026-27, +# single adult: personal allowance £12,570, basic rate limit £37,700, savings +# allowance £500 for a higher-rate taxpayer, dividend allowance £500 at +# 10.75% / 35.75%. + +- name: Case 1, dividends sit above property income. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 45 + property_income: 40_000 + dividend_income: 20_000 + households: + household: + members: [person] + country: ENGLAND + output: + # Non-savings taxable income 40,000 - 12,570 = 27,430. The £500 dividend + # allowance and 9,770 fill the basic-rate band (9,770 x 10.75% = + # 1,050.275); 9,730 is at 35.75% (3,478.475). Total 4,528.75, as for pay. + non_savings_taxable_income: 27_430 + dividend_income_tax: 4_528.75 + income_tax: 5_486 + 4_528.75 + +- name: Case 2, savings sit above property income. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 45 + property_income: 40_000 + savings_interest_income: 20_000 + households: + household: + members: [person] + country: ENGLAND + output: + # £500 savings allowance, then 9,770 at 20% (1,954) to the basic rate + # limit and 9,730 at 40% (3,892). + savings_income_tax: 5_846 + +- name: Case 3, pay and property income together form the band base. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 45 + employment_income: 10_000 + property_income: 30_000 + dividend_income: 20_000 + households: + household: + members: [person] + country: ENGLAND + output: + # The allowance covers the pay first: 0 earned and 27,430 property + # income taxable, so dividends sit on 27,430 as in Case 1. + taxable_property_income_after_allowances: 27_430 + non_savings_taxable_income: 27_430 + dividend_income_tax: 4_528.75 + +- name: Case 4, a Scottish taxpayer's dividends use UK bands above the property income. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 45 + property_income: 40_000 + dividend_income: 20_000 + households: + household: + members: [person] + country: SCOTLAND + output: + dividend_income_tax: 4_528.75 + +- name: Case 5, from 2027-28 property income has its own rates but still sits below dividends. + period: 2027 + absolute_error_margin: 0.01 + input: + people: + person: + age: 45 + property_income: 40_000 + dividend_income: 20_000 + households: + household: + members: [person] + country: ENGLAND + output: + # Property income at the 22% property basic rate: 27,430 x 22% = 6,034.60. + property_income_tax: 6_034.60 + dividend_income_tax: 4_528.75 + +- name: Case 6, the annual allowance charge reads the rate on property income too. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 45 + employment_income: 10_000 + property_income: 60_000 + employee_pension_contributions: 70_000 + households: + household: + members: [person] + country: ENGLAND + output: + # Relief is limited to the £10,000 of pay, so taxable income is + # 70,000 - 12,570 - 10,000 = 47,430, in the higher-rate band; the + # £10,000 over the £60,000 annual allowance is charged at 40%. + taxed_income: 47_430 + personal_pension_contributions_tax: 4_000 + +- name: Case 7, Marriage Allowance still comes off the band base (pinned; #1947). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 45 + employment_income: 20_000 + property_income: 10_000 + marriage_allowance: 1_260 + households: + household: + members: [person] + country: ENGLAND + output: + # earned_taxable_income subtracts Marriage Allowance, which the law gives + # as a tax reduction (ITA 2007 s. 55B), so the base is 30,000 - 12,570 - + # 1,260 = 16,170 rather than 17,430. Pinned here until #1947 is fixed. + non_savings_taxable_income: 16_170 + non_savings_income_after_allowances: 17_430 diff --git a/policyengine_uk/tests/policy/integration/income.yaml b/policyengine_uk/tests/policy/integration/income.yaml index dc8b7ea721..798953e25f 100644 --- a/policyengine_uk/tests/policy/integration/income.yaml +++ b/policyengine_uk/tests/policy/integration/income.yaml @@ -100,8 +100,13 @@ members: [person1, person2] output: property_income_tax: [17_792, 6_776.20] - savings_income_tax: [0, 0] - income_tax: [17_792, 6_776.20] + # Savings sit above the property income (ITA 2007 s. 16). Person 1: + # 75,900 - 12,570 = 63,330 of non-savings taxable income, so a higher- + # rate taxpayer with a £500 savings allowance and no starting rate: + # (2,900 - 500) x 40% = 960. Person 2: 46,451 - 12,570 = 33,881, a + # basic-rate taxpayer with £1,000: (1,525 - 1,000) x 20% = 105. + savings_income_tax: [960, 105] + income_tax: [18_752, 6_881.20] - name: Household net income can be negative period: 2020 diff --git a/policyengine_uk/tests/test_property_income_band_base_properties.py b/policyengine_uk/tests/test_property_income_band_base_properties.py new file mode 100644 index 0000000000..7d5730f867 --- /dev/null +++ b/policyengine_uk/tests/test_property_income_band_base_properties.py @@ -0,0 +1,127 @@ +"""Savings and dividends sit above all non-savings income (ITA 2007 s. 16). + +Property income is non-savings income, so it fills the bands before savings +and dividends, as pay does. + +Invariants, for single adults in 2026 with pay, property income, savings +interest and dividends drawn freely: + +1. Differential: replacing property income with pay equal to its taxable + amount leaves savings and dividend tax unchanged, in England and in + Scotland. (The taxable amount, because the £1,000 property allowance can + relieve small property income in full.) +2. Accounting: with no Marriage Allowance, non_savings_taxable_income equals + adjusted net income less savings, dividends and allowances, floored at nil + (earned_taxable_income also subtracts Marriage Allowance; #1947). +3. Monotonic: more property income never lowers savings or dividend tax. + (Main passes this too; the differential and the higher-rate check below + are what catch the bug.) +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +YEAR = 2026 +PEOPLE = 10 +amount = st.integers(min_value=0, max_value=200_000) +rows_strategy = st.lists( + st.tuples(amount, amount, st.integers(0, 60_000), st.integers(0, 60_000)), + min_size=PEOPLE, + max_size=PEOPLE, +) +VARIABLES = [ + "taxable_property_income", + "savings_income_tax", + "dividend_income_tax", + "non_savings_taxable_income", + "adjusted_net_income", + "taxable_savings_interest_income", + "taxable_dividend_income", + "allowances", +] + + +def _calculate(rows, country): + """rows: (pay, property, savings, dividends), one single adult each.""" + people, benunits, households = {}, {}, {} + for i, (pay, prop, savings, dividends) in enumerate(rows): + name = f"p{i}" + people[name] = { + "age": {YEAR: 45}, + "employment_income": {YEAR: pay}, + "property_income": {YEAR: prop}, + "savings_interest_income": {YEAR: savings}, + "dividend_income": {YEAR: dividends}, + } + benunits[f"b{i}"] = {"members": [name]} + households[f"h{i}"] = {"members": [name], "country": {YEAR: country}} + simulation = Simulation( + situation={"people": people, "benunits": benunits, "households": households} + ) + return {v: np.asarray(simulation.calculate(v, YEAR)) for v in VARIABLES} + + +SETTINGS = settings( + max_examples=8, + deadline=None, + suppress_health_check=[HealthCheck.too_slow], +) + + +@SETTINGS +@given(rows=rows_strategy, country=st.sampled_from(["ENGLAND", "SCOTLAND"])) +def test_moving_income_between_pay_and_property_leaves_savings_and_dividend_tax( + rows, country +): + as_property = _calculate(rows, country) + taxable_property = as_property["taxable_property_income"] + as_pay = _calculate( + [ + (pay + float(taxable), 0, s, d) + for (pay, _, s, d), taxable in zip(rows, taxable_property) + ], + country, + ) + for name in [ + "savings_income_tax", + "dividend_income_tax", + "non_savings_taxable_income", + ]: + np.testing.assert_allclose(as_property[name], as_pay[name], atol=0.01) + + +@SETTINGS +@given(rows=rows_strategy) +def test_non_savings_taxable_income_is_the_rest_after_allowances(rows): + v = _calculate(rows, "ENGLAND") + expected = np.maximum( + 0, + v["adjusted_net_income"] + - v["taxable_savings_interest_income"] + - v["taxable_dividend_income"] + - v["allowances"], + ) + np.testing.assert_allclose(v["non_savings_taxable_income"], expected, atol=0.01) + + +@SETTINGS +@given(rows=rows_strategy, rise=st.integers(min_value=1, max_value=100_000)) +def test_more_property_income_never_lowers_savings_or_dividend_tax(rows, rise): + before = _calculate(rows, "ENGLAND") + after = _calculate( + [(pay, prop + rise, s, d) for pay, prop, s, d in rows], "ENGLAND" + ) + for name in ["savings_income_tax", "dividend_income_tax"]: + assert np.all(after[name] >= before[name] - 0.01) + + +def test_savings_above_property_income_past_the_basic_rate_limit_pay_the_higher_rate(): + """£40,000 of property income leaves £10,270 of the basic-rate band, so + some of £20,000 of savings must pay 40%: savings tax exceeds 20% of + everything above the £500 savings allowance (£3,900). Main banded the + savings as if the property income were not there and charged £2,900.""" + v = _calculate([(0, 40_000, 20_000, 0)] + [(0, 0, 0, 0)] * (PEOPLE - 1), "ENGLAND") + assert v["savings_income_tax"][0] > (20_000 - 500) * 0.20 diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/non_savings_income_after_allowances.py b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/non_savings_income_after_allowances.py new file mode 100644 index 0000000000..0bc9405495 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/non_savings_income_after_allowances.py @@ -0,0 +1,29 @@ +from policyengine_uk.model_api import * + + +class non_savings_income_after_allowances(Variable): + value_type = float + entity = Person + label = "Non-savings, non-dividend income for Income Tax after allowances" + documentation = ( + "Adjusted net income less savings and dividend income and allowances, " + "floored at nil: the income below savings and dividends (ITA 2007 s. " + "16) once the allowances, which go to it first, are taken off. " + "property_income_tax and taxable_property_income_after_allowances " + "both read it." + ) + definition_period = YEAR + reference = dict( + title="Income Tax Act 2007, s. 16", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/16", + ) + unit = GBP + + def formula(person, period, parameters): + return max_( + 0, + person("adjusted_net_income", period) + - person("taxable_savings_interest_income", period) + - person("taxable_dividend_income", period) + - person("allowances", period), + ) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/non_savings_taxable_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/non_savings_taxable_income.py new file mode 100644 index 0000000000..8e1d2253a4 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/non_savings_taxable_income.py @@ -0,0 +1,36 @@ +from policyengine_uk.model_api import * + + +class non_savings_taxable_income(Variable): + value_type = float + entity = Person + label = "Taxable income other than savings and dividend income" + documentation = ( + "The part of taxable income below savings and dividend income, which " + "ITA 2007 s. 16 treats as the highest part of total income: " + "earned_taxable_income plus property income after allowances. " + "earned_taxable_income leaves property income out because " + "property_income_tax taxes it separately, but property income still " + "fills the bands before savings and dividends do (from 2027-28, " + "s. 16A places it immediately below savings income). With no Marriage " + "Allowance it equals non_savings_income_after_allowances; " + "earned_taxable_income also subtracts Marriage Allowance, which the " + "law gives as a tax reduction (#1947)." + ) + definition_period = YEAR + reference = [ + dict( + title="Income Tax Act 2007, s. 16", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/16", + ), + dict( + title="Income Tax Act 2007, s. 16A (from 2027-28)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/16A", + ), + ] + unit = GBP + + def formula(person, period, parameters): + return person("earned_taxable_income", period) + person( + "taxable_property_income_after_allowances", period + ) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/taxable_property_income_after_allowances.py b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/taxable_property_income_after_allowances.py new file mode 100644 index 0000000000..b38aabcf36 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/taxable_property_income_after_allowances.py @@ -0,0 +1,36 @@ +from policyengine_uk.model_api import * + + +class taxable_property_income_after_allowances(Variable): + value_type = float + entity = Person + label = "Property income for Income Tax after allowances" + documentation = ( + "Taxable property income less the part of the allowances it absorbs. " + "Allowances go to non-savings, non-dividend income first, and " + "property income is stacked above the rest of it, so it takes " + "whatever allowances the other non-savings income leaves. From " + "2027-28 that order is statutory: s. 25(3A) sets allowances against " + "income other than property, savings and dividend income first, and " + "s. 16A places property income immediately below savings income." + ) + definition_period = YEAR + reference = [ + dict( + title="Income Tax Act 2007, s. 25(3A) (from 2027-28)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/25", + ), + dict( + title="Income Tax Act 2007, s. 16A (from 2027-28)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/16A", + ), + ] + unit = GBP + + def formula(person, period, parameters): + non_savings_after_allowances = person( + "non_savings_income_after_allowances", period + ) + return min_( + person("taxable_property_income", period), non_savings_after_allowances + ) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_savings_income/add_rate_savings_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_savings_income/add_rate_savings_income.py index 610b9481b2..979d1c254d 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_savings_income/add_rate_savings_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_savings_income/add_rate_savings_income.py @@ -14,7 +14,9 @@ class add_rate_savings_income(Variable): def formula(person, period, parameters): thresholds = parameters(period).gov.hmrc.income_tax.rates.uk.thresholds - other_income = person("earned_taxable_income", period) + # Savings sit above all non-savings income, property income + # included (ITA 2007 s. 16). + other_income = person("non_savings_taxable_income", period) savings_after_allowances = max_( 0, person("taxable_savings_interest_income", period) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_savings_income/basic_rate_savings_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_savings_income/basic_rate_savings_income.py index cea212f46d..d4aa229abb 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_savings_income/basic_rate_savings_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_savings_income/basic_rate_savings_income.py @@ -14,7 +14,9 @@ class basic_rate_savings_income(Variable): def formula(person, period, parameters): thresholds = parameters(period).gov.hmrc.income_tax.rates.uk.thresholds - other_income = person("earned_taxable_income", period) + # Savings sit above all non-savings income, property income + # included (ITA 2007 s. 16). + other_income = person("non_savings_taxable_income", period) savings_after_allowances = max_( 0, person("taxable_savings_interest_income", period) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_savings_income/basic_rate_savings_income_pre_starter.py b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_savings_income/basic_rate_savings_income_pre_starter.py index a3f99e40ce..214b51cfcf 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_savings_income/basic_rate_savings_income_pre_starter.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_savings_income/basic_rate_savings_income_pre_starter.py @@ -19,7 +19,9 @@ def formula(person, period, parameters): person("taxable_savings_interest_income", period) - person("received_allowances_savings_income", period), ) - other_income = person("earned_taxable_income", period) + # Savings sit above all non-savings income, property income + # included (ITA 2007 s. 16). + other_income = person("non_savings_taxable_income", period) basic_rate_amount_with_savings = clip( savings_income + other_income, thresholds[0], diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_savings_income/higher_rate_savings_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_savings_income/higher_rate_savings_income.py index cfb7da1a31..96f845b5b3 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_savings_income/higher_rate_savings_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_savings_income/higher_rate_savings_income.py @@ -14,7 +14,9 @@ class higher_rate_savings_income(Variable): def formula(person, period, parameters): thresholds = parameters(period).gov.hmrc.income_tax.rates.uk.thresholds - other_income = person("earned_taxable_income", period) + # Savings sit above all non-savings income, property income + # included (ITA 2007 s. 16). + other_income = person("non_savings_taxable_income", period) savings_after_allowances = max_( 0, person("taxable_savings_interest_income", period) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_savings_income/savings_starter_rate_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_savings_income/savings_starter_rate_income.py index 354b3908c8..94e38d7101 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_savings_income/savings_starter_rate_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_savings_income/savings_starter_rate_income.py @@ -37,7 +37,11 @@ def formula(person, period, parameters): - person("received_allowances_savings_income", period), ), ) - earned_taxable_income = person("earned_taxable_income", period) + # The taper reads all non-savings income, property income included + # (ITA 2007 s. 16). Where the taper starts is a separate, known defect: + # s. 12 shrinks the band from the first pound of taxable non-savings + # income, not from the personal allowance plus the band (#1799). + earned_taxable_income = person("non_savings_taxable_income", period) removed_amount = max_( 0, diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/liability/dividend_income_tax.py b/policyengine_uk/variables/gov/hmrc/income_tax/liability/dividend_income_tax.py index e812a8f769..e9858f4184 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/liability/dividend_income_tax.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/liability/dividend_income_tax.py @@ -14,7 +14,9 @@ class dividend_income_tax(Variable): def formula(person, period, parameters): rates = parameters(period).gov.hmrc.income_tax.rates - other_income = person("earned_taxable_income", period) + max_( + # Dividends are the highest part of income, above savings and all + # non-savings income, property income included (ITA 2007 s. 16). + other_income = person("non_savings_taxable_income", period) + max_( 0, person("taxable_savings_interest_income", period) - person("received_allowances_savings_income", period), diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/liability/property_income_tax.py b/policyengine_uk/variables/gov/hmrc/income_tax/liability/property_income_tax.py index 0fff929eac..fad31dd093 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/liability/property_income_tax.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/liability/property_income_tax.py @@ -34,15 +34,12 @@ def formula(person, period, parameters): determining which tax band it falls into. """ rates = parameters(period).gov.hmrc.income_tax.rates - taxable_property = person("taxable_property_income", period) - non_savings_after_allowances = max_( - 0, - person("adjusted_net_income", period) - - person("taxable_savings_interest_income", period) - - person("taxable_dividend_income", period) - - person("allowances", period), + non_savings_after_allowances = person( + "non_savings_income_after_allowances", period + ) + property_after_allowances = person( + "taxable_property_income_after_allowances", period ) - property_after_allowances = min_(taxable_property, non_savings_after_allowances) other_earned_taxable = max_( 0, non_savings_after_allowances - property_after_allowances ) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/taxed_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/taxed_income.py index 3729ad0428..0a937cb36d 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/taxed_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/taxed_income.py @@ -12,8 +12,11 @@ class taxed_income(Variable): href="https://www.legislation.gov.uk/ukpga/2007/3/section/23", ) + # Property income is taxed income too: non_savings_taxable_income adds it + # to earned_taxable_income, which leaves it out because + # property_income_tax taxes it separately. adds = [ - "earned_taxable_income", + "non_savings_taxable_income", "taxed_savings_income", "taxed_dividend_income", ]