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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/2351.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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
9 changes: 7 additions & 2 deletions policyengine_uk/tests/policy/integration/income.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
127 changes: 127 additions & 0 deletions policyengine_uk/tests/test_property_income_band_base_properties.py
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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),
)
Original file line number Diff line number Diff line change
@@ -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
)
Original file line number Diff line number Diff line change
@@ -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
)
Loading
Loading