From bdca3141a82948f1db6693837c54dd0d27119487 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 16:25:22 -0400 Subject: [PATCH 1/3] Band savings and dividends above property income MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ITA 2007 s. 16 treats savings and dividend income as the highest part of total income, so property income, which is non-savings income, fills the bands below them. Since #1408, earned_taxable_income leaves property income out (property_income_tax taxes it separately), and the savings bands, the savings starting-rate taper and dividend_income_tax all stacked on earned_taxable_income, so they were banded as if property income were not there. £40,000 of property income and £20,000 of dividends paid £2,096.25 of dividend tax instead of £4,528.75. New non_savings_taxable_income (earned_taxable_income plus property income after allowances, from the new taxable_property_income_after_allowances, which property_income_tax now reads too) is the base for those variables. Fixes #2351 Co-Authored-By: Claude Opus 5.5 --- changelog.d/2351.fixed.md | 1 + .../income_tax/property_income_band_base.yaml | 99 +++++++++++++++ ...st_property_income_band_base_properties.py | 115 ++++++++++++++++++ .../non_savings_taxable_income.py | 26 ++++ ...axable_property_income_after_allowances.py | 31 +++++ .../add_rate_savings_income.py | 4 +- .../basic_rate_savings_income.py | 4 +- .../basic_rate_savings_income_pre_starter.py | 4 +- .../higher_rate_savings_income.py | 4 +- .../savings_starter_rate_income.py | 4 +- .../liability/dividend_income_tax.py | 4 +- .../liability/property_income_tax.py | 5 +- 12 files changed, 293 insertions(+), 8 deletions(-) create mode 100644 changelog.d/2351.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/property_income_band_base.yaml create mode 100644 policyengine_uk/tests/test_property_income_band_base_properties.py create mode 100644 policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/non_savings_taxable_income.py create mode 100644 policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/taxable_property_income_after_allowances.py diff --git a/changelog.d/2351.fixed.md b/changelog.d/2351.fixed.md new file mode 100644 index 0000000000..b40f9582f0 --- /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 s. 16). 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..49d781b6e2 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/property_income_band_base.yaml @@ -0,0 +1,99 @@ +# 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 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..b51a4f409b --- /dev/null +++ b/policyengine_uk/tests/test_property_income_band_base_properties.py @@ -0,0 +1,115 @@ +"""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: non_savings_taxable_income equals adjusted net income less + savings, dividends and allowances, floored at nil. +3. Monotonic: more property income never lowers savings or dividend tax. +""" + +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) 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..0c6f878d0f --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/non_savings_taxable_income.py @@ -0,0 +1,26 @@ +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." + ) + 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 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..cb27d2133b --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/taxable_property_income_after_allowances.py @@ -0,0 +1,31 @@ +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." + ) + definition_period = YEAR + reference = dict( + title="Income Tax Act 2007, s. 25(2)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/25", + ) + unit = GBP + + def formula(person, period, parameters): + 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), + ) + 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..6cb516b928 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,9 @@ def formula(person, period, parameters): - person("received_allowances_savings_income", period), ), ) - earned_taxable_income = person("earned_taxable_income", period) + # The starting rate band is reduced by non-savings income, property + # income included (ITA 2007 ss. 12, 16). + 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..9fcafebf6f 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,7 +34,6 @@ 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) @@ -42,7 +41,9 @@ def formula(person, period, parameters): - person("taxable_dividend_income", period) - person("allowances", period), ) - property_after_allowances = min_(taxable_property, non_savings_after_allowances) + property_after_allowances = person( + "taxable_property_income_after_allowances", period + ) other_earned_taxable = max_( 0, non_savings_after_allowances - property_after_allowances ) From 1a80b21d1ae968c919dd35761e5906cc59ea493b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 16:31:27 -0400 Subject: [PATCH 2/3] Correct the property integration test's savings tax, which encoded the bug The case expected nil savings tax for two people with property income and savings interest. With savings above the property income (ITA 2007 s. 16), person 1 is a higher-rate taxpayer: (2,900 - 500) x 40% = 960; person 2 a basic-rate one: (1,525 - 1,000) x 20% = 105. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/tests/policy/integration/income.yaml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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 From 2de2210d64ea37dcca61ace8d6eb3790563e0308 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 17:19:48 -0400 Subject: [PATCH 3/3] Use the full band base for the annual allowance charge; tidy the base variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups for #2352: - taxed_income, which sets the annual allowance charge's marginal rate, stacked on earned_taxable_income too and so left property income out; it now adds non_savings_taxable_income (Case 6: £10,000 of excess contributions with £60,000 of property income pay 40%, not 20%). - One non_savings_income_after_allowances variable feeds both taxable_property_income_after_allowances and property_income_tax. - Cite ss. 16A and 25(3A) for 2027-28; say the identity holds without Marriage Allowance and pin that case (#1947); point the starting-rate comment at #1799; replace the monotonic check, which main also passed, with a higher-rate check main fails. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2351.fixed.md | 2 +- .../income_tax/property_income_band_base.yaml | 42 +++++++++++++++++++ ...st_property_income_band_base_properties.py | 16 ++++++- .../non_savings_income_after_allowances.py | 29 +++++++++++++ .../non_savings_taxable_income.py | 20 ++++++--- ...axable_property_income_after_allowances.py | 27 +++++++----- .../savings_starter_rate_income.py | 6 ++- .../liability/property_income_tax.py | 8 +--- .../gov/hmrc/income_tax/taxed_income.py | 5 ++- 9 files changed, 127 insertions(+), 28 deletions(-) create mode 100644 policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/non_savings_income_after_allowances.py diff --git a/changelog.d/2351.fixed.md b/changelog.d/2351.fixed.md index b40f9582f0..429087f2a7 100644 --- a/changelog.d/2351.fixed.md +++ b/changelog.d/2351.fixed.md @@ -1 +1 @@ -Band savings and dividend income above property income, which is non-savings income (ITA 2007 s. 16). +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 index 49d781b6e2..7312c6d5bd 100644 --- 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 @@ -97,3 +97,45 @@ # 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/test_property_income_band_base_properties.py b/policyengine_uk/tests/test_property_income_band_base_properties.py index b51a4f409b..7d5730f867 100644 --- a/policyengine_uk/tests/test_property_income_band_base_properties.py +++ b/policyengine_uk/tests/test_property_income_band_base_properties.py @@ -10,9 +10,12 @@ 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: non_savings_taxable_income equals adjusted net income less - savings, dividends and allowances, floored at nil. +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 @@ -113,3 +116,12 @@ def test_more_property_income_never_lowers_savings_or_dividend_tax(rows, rise): ) 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 index 0c6f878d0f..8e1d2253a4 100644 --- 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 @@ -11,13 +11,23 @@ class non_savings_taxable_income(Variable): "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." + "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", - ) + 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): 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 index cb27d2133b..b38aabcf36 100644 --- 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 @@ -9,22 +9,27 @@ class taxable_property_income_after_allowances(Variable): "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." + "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(2)", - href="https://www.legislation.gov.uk/ukpga/2007/3/section/25", - ) + 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 = 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 ) return min_( person("taxable_property_income", period), non_savings_after_allowances 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 6cb516b928..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,8 +37,10 @@ def formula(person, period, parameters): - person("received_allowances_savings_income", period), ), ) - # The starting rate band is reduced by non-savings income, property - # income included (ITA 2007 ss. 12, 16). + # 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_( 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 9fcafebf6f..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,12 +34,8 @@ def formula(person, period, parameters): determining which tax band it falls into. """ rates = parameters(period).gov.hmrc.income_tax.rates - 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 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", ]