diff --git a/changelog.d/2733.fixed.md b/changelog.d/2733.fixed.md new file mode 100644 index 0000000000..d74116d6d1 --- /dev/null +++ b/changelog.d/2733.fixed.md @@ -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. diff --git a/changelog.d/2745.fixed.md b/changelog.d/2745.fixed.md new file mode 100644 index 0000000000..a28ad3209e --- /dev/null +++ b/changelog.d/2745.fixed.md @@ -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. diff --git a/docs/book/validation/hbai.md b/docs/book/validation/hbai.md index 30839e69d7..d493d606bb 100644 --- a/docs/book/validation/hbai.md +++ b/docs/book/validation/hbai.md @@ -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`| diff --git a/policyengine_uk/tests/policy/baseline/household/income/other_investment_income_in_household_income.yaml b/policyengine_uk/tests/policy/baseline/household/income/other_investment_income_in_household_income.yaml new file mode 100644 index 0000000000..16ebc9e59f --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/household/income/other_investment_income_in_household_income.yaml @@ -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 diff --git a/policyengine_uk/tests/test_isa_income_contract.py b/policyengine_uk/tests/test_isa_income_contract.py index 81f5b62353..b5d9c7da98 100644 --- a/policyengine_uk/tests/test_isa_income_contract.py +++ b/policyengine_uk/tests/test_isa_income_contract.py @@ -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) diff --git a/policyengine_uk/tests/test_other_investment_income_household_income_properties.py b/policyengine_uk/tests/test_other_investment_income_household_income_properties.py new file mode 100644 index 0000000000..337cd18195 --- /dev/null +++ b/policyengine_uk/tests/test_other_investment_income_household_income_properties.py @@ -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 diff --git a/policyengine_uk/variables/household/income/capital_income.py b/policyengine_uk/variables/household/income/capital_income.py index e3c26b32df..f99320f9df 100644 --- a/policyengine_uk/variables/household/income/capital_income.py +++ b/policyengine_uk/variables/household/income/capital_income.py @@ -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", diff --git a/policyengine_uk/variables/household/income/hbai_household_net_income.py b/policyengine_uk/variables/household/income/hbai_household_net_income.py index 604ec70078..7b09beb6f7 100644 --- a/policyengine_uk/variables/household/income/hbai_household_net_income.py +++ b/policyengine_uk/variables/household/income/hbai_household_net_income.py @@ -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", diff --git a/policyengine_uk/variables/household/income/household_market_income.py b/policyengine_uk/variables/household/income/household_market_income.py index 1fd5503c79..766b11e901 100644 --- a/policyengine_uk/variables/household/income/household_market_income.py +++ b/policyengine_uk/variables/household/income/household_market_income.py @@ -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", diff --git a/policyengine_uk/variables/household/income/market_income.py b/policyengine_uk/variables/household/income/market_income.py index f759e64f76..01aa0c71a0 100644 --- a/policyengine_uk/variables/household/income/market_income.py +++ b/policyengine_uk/variables/household/income/market_income.py @@ -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",