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/2733.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Count `other_investment_income` in `market_income`, `household_market_income` and `hbai_household_net_income`. Income tax already charged it as savings income, so household net income carried the tax on it without the income.
1 change: 1 addition & 0 deletions changelog.d/2745.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Count `other_investment_income` (National Savings, gilt and other securities interest, and trust interest) in `capital_income`, as market and HBAI income now do. Nothing reads `capital_income`, so income tax, benefits and the other income measures are unchanged.
2 changes: 1 addition & 1 deletion docs/book/validation/hbai.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ The table below shows how the HBAI income components map to PolicyEngine variabl
| Income received from dividends (from FYE 2022) |`dividend_income`|
| State support - all benefits and tax credits |`hbai_benefits`|
| Income from occupational and private pensions |`private_pension_income` (includes occupational pension income)|
| Investment income |`dividend_income`, `savings_interest_income`, `property_income_after_finance_costs`|
| Investment income |`dividend_income`, `savings_interest_income`, `other_investment_income`, `property_income_after_finance_costs`|
| Maintenance payments |`maintenance_income`, `spousal_maintenance_income`|
| Income from educational grants and scholarships (including, for students, student loans and parental contributions) |`student_payments`|
| The cash value of certain forms of income in kind (free school meals, free school breakfast, free school milk, free school fruit and vegetables, Healthy Start vouchers and free TV licences for those aged 75 and over who receive Pension Credit) |`free_school_meals`, `free_school_fruit_veg`, `free_school_milk`, `healthy_start_vouchers`, `free_tv_licence_value`|
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Other investment income (National Savings, gilt and other securities
# interest) is savings income for income tax (taxable_savings_interest_income),
# so market, capital, gross, net and HBAI income count it as they count bank
# interest. 2026-27: personal allowance £12,570, basic rate 20%, personal
# savings allowance £1,000 for a basic rate taxpayer, starting rate for savings
# £5,000 less non-savings income above the personal allowance (here nil),
# employee NI 8% from £12,570 to £50,270, TV licence £180.

- name: Other investment income counts in market, capital, net and HBAI income
period: 2026
absolute_error_margin: 0.01
input:
age: 40
employment_income: 40_000
other_investment_income: 2_000
output:
# 40,000 + 2,000.
market_income: 42_000
household_market_income: 42_000
household_gross_income: 42_000
capital_income: 2_000
# (40,000 - 12,570) x 20% = 5,486, plus (2,000 - 1,000 PSA) x 20% = 200.
income_tax: 5_686
# (40,000 - 12,570) x 8%.
national_insurance: 2_194.40
# 42,000 - 5,686 - 2,194.40.
hbai_household_net_income: 34_119.60
# Less the 180 TV licence. Without the interest it is 32,139.60, so the
# interest raises net income by 2,000 less its 200 of tax.
household_net_income: 33_939.60

- name: The same amount as bank interest gives the same incomes
period: 2026
absolute_error_margin: 0.01
input:
age: 40
employment_income: 40_000
savings_interest_income: 2_000
output:
market_income: 42_000
household_market_income: 42_000
household_gross_income: 42_000
capital_income: 2_000
income_tax: 5_686
national_insurance: 2_194.40
hbai_household_net_income: 34_119.60
household_net_income: 33_939.60
5 changes: 4 additions & 1 deletion policyengine_uk/tests/test_isa_income_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,10 @@ def test_unmarked_legacy_conversion_preserves_cash_and_supplied_exemptions(
assert value(
simulation, "individual_savings_account_interest_income"
) == income.get("individual_savings_account_interest_income", 0)
assert value(simulation, "capital_income") == income["savings_interest_income"]
# Capital income is the legacy gross interest plus other investment income.
assert value(simulation, "capital_income") == (
income["savings_interest_income"] + income.get("other_investment_income", 0)
)
assert value(simulation, "taxable_savings_interest_income") == expected_taxable
pd.testing.assert_frame_equal(dataset.person, original)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
"""Property tests for other investment income in household income.

Income tax charges other investment income (interest on National Savings and
Investments products, gilts and other securities, and from trusts) as savings
income through taxable_savings_interest_income, exactly as it charges bank
interest. HBAI counts investment income, which the Family Resources Survey
defines as the interest and dividends received on savings and investments,
NS&I products and gilts included. So every household income measure must
count a pound of other investment income as it counts a pound of bank
interest. Household net income used to take off the tax on it without adding
the income, so the interest lowered net income.

Each generated case is simulated three times: with no interest, with the
amount as bank interest and with it as other investment income.
"""

import numpy as np
import pytest
from hypothesis import HealthCheck, example, given, settings
from hypothesis import strategies as st

from policyengine_uk import Simulation

pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system")

YEAR = 2027
EMPLOYMENT_INCOME = [0, 8_000, 13_000, 30_000, 60_000, 110_000, 200_000]
# Every third pay level (£13,000 and £110,000) is in Scotland.
SCOTTISH = [i % 3 == 2 for i in range(len(EMPLOYMENT_INCOME))]
CASES = 21
# Each case is simulated with no interest, bank interest and other investment
# income, in that order.
PEOPLE = 3 * CASES
VARIABLES = [
"income_tax",
"market_income",
"capital_income",
"household_market_income",
"household_gross_income",
"household_net_income",
"hbai_household_net_income_before_reset_to_zero",
]

amount = st.one_of(
st.floats(min_value=0, max_value=2_500),
st.floats(min_value=0, max_value=60_000),
st.sampled_from([0.0, 500.0, 1_000.0, 5_000.0]),
)
amounts = st.lists(amount, min_size=CASES, max_size=CASES)


def person_index(i):
return i % CASES % len(EMPLOYMENT_INCOME)


@pytest.fixture(scope="module")
def sim():
people = {
f"person_{i}": {
"age": {YEAR: 40},
"employment_income": {YEAR: EMPLOYMENT_INCOME[person_index(i)]},
"savings_interest_income": {YEAR: 0},
"other_investment_income": {YEAR: 0},
}
for i in range(PEOPLE)
}
households = {
f"household_{i}": {
"members": [f"person_{i}"],
"region": {YEAR: "SCOTLAND" if SCOTTISH[person_index(i)] else "LONDON"},
}
for i in range(PEOPLE)
}
return Simulation(
situation={
"people": people,
"benunits": {
f"benunit_{i}": {"members": [f"person_{i}"]} for i in range(PEOPLE)
},
"households": households,
}
)


def run(sim, drawn):
interest = np.asarray(drawn, dtype=float)
none = np.zeros(CASES)
sim.set_input(
"savings_interest_income", YEAR, np.concatenate([none, interest, none])
)
sim.set_input(
"other_investment_income", YEAR, np.concatenate([none, none, interest])
)
sim.reset_calculations()
return interest, {
variable: sim.calculate(variable, YEAR).astype(float).reshape(3, CASES)
for variable in VARIABLES
}


def tolerance(*values):
"""A penny plus float32 rounding on the amounts involved."""
return 0.01 + 1e-6 * sum(np.abs(v) for v in values)


SETTINGS = settings(
max_examples=25,
deadline=None,
derandomize=True,
database=None,
suppress_health_check=[HealthCheck.too_slow],
)


@SETTINGS
@given(amounts)
@example([2_000.0] * CASES)
def test_other_investment_income_counts_like_bank_interest(sim, drawn):
interest, r = run(sim, drawn)
# Income tax charges the two alike, and so does every income measure.
for variable, (_, bank, other) in r.items():
assert (np.abs(other - bank) <= tolerance(bank, other)).all(), variable
# Market and capital income rise by the interest.
for variable in ("market_income", "capital_income", "household_market_income"):
none, _, other = r[variable]
change = other - none
assert (np.abs(change - interest) <= tolerance(other, interest)).all(), variable
# Net income rises by the interest less the tax on it: it never falls.
for variable in (
"household_net_income",
"hbai_household_net_income_before_reset_to_zero",
):
none, _, other = r[variable]
change = other - none
margin = tolerance(other, none)
assert (change >= -margin).all(), variable
assert (change <= interest + margin).all(), variable
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class capital_income(Variable):

adds = [
"savings_interest_income",
"other_investment_income",
"tax_free_savings_income",
"dividend_income",
"individual_savings_account_dividend_income",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
"employment_income",
"self_employment_income",
"savings_interest_income",
# National Savings, gilt and other securities interest, which income
# tax charges as savings income (taxable_savings_interest_income).
"other_investment_income",
"tax_free_savings_income",
"individual_savings_account_dividend_income",
"dividend_income",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
"employment_income",
"self_employment_income",
"savings_interest_income",
# National Savings, gilt and other securities interest, which income
# tax charges as savings income (taxable_savings_interest_income).
"other_investment_income",
"tax_free_savings_income",
"individual_savings_account_dividend_income",
"dividend_income",
Expand Down
3 changes: 3 additions & 0 deletions policyengine_uk/variables/household/income/market_income.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ def formula(person, period, parameters):
"employment_income",
"self_employment_income",
"savings_interest_income",
# National Savings, gilt and other securities interest, which income
# tax charges as savings income (taxable_savings_interest_income).
"other_investment_income",
"tax_free_savings_income",
"individual_savings_account_dividend_income",
"dividend_income",
Expand Down
Loading