From 17c73816b1b41ccd1daa14c8a591d2f48e14ade3 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 22:20:14 -0400 Subject: [PATCH 01/21] Set Income Tax reliefs and allowances against income in the way that gives the least tax ITA 2007 s. 25(2) deducts Step 2 reliefs and Step 3 allowances in the way that gives the greatest reduction in liability, and from 2027-28 s. 25(3A) deducts them from income other than property, savings and dividend income first. The model set them against non-savings, savings and dividend income in that fixed order. utils/income_tax_allocation.py finds the least-tax share per person by evaluating the Step 5 tax at every crossing of the lines where its slope changes. Fixes #2106. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2106.fixed.md | 1 + .../reliefs/other_income_first.yaml | 14 + .../least_tax_allocation.yaml | 203 ++++++++ .../received_allowances_dividend_income.yaml | 60 ++- .../received_allowances_earned_income.yaml | 4 +- .../received_allowances_savings_income.yaml | 37 +- ...de_loss_relief_against_general_income.yaml | 17 +- .../tests/policy/integration/income.yaml | 36 +- .../tests/test_income_tax_allocation.py | 322 +++++++++++++ .../utils/income_tax_allocation.py | 448 ++++++++++++++++++ policyengine_uk/utils/uc_income_tax.py | 9 +- .../received_allowances_dividend_income.py | 38 +- .../received_allowances_earned_income.py | 32 +- .../received_allowances_savings_income.py | 36 +- .../earned_taxable_income.py | 9 +- .../non_savings_income_after_allowances.py | 29 +- ...ss_relief_against_savings_and_dividends.py | 29 +- 17 files changed, 1182 insertions(+), 142 deletions(-) create mode 100644 changelog.d/2106.fixed.md create mode 100644 policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/other_income_first.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml create mode 100644 policyengine_uk/tests/test_income_tax_allocation.py create mode 100644 policyengine_uk/utils/income_tax_allocation.py diff --git a/changelog.d/2106.fixed.md b/changelog.d/2106.fixed.md new file mode 100644 index 0000000000..7329fd05e7 --- /dev/null +++ b/changelog.d/2106.fixed.md @@ -0,0 +1 @@ +Income Tax now sets the Personal Allowance, other allowances and trade loss relief against non-savings, savings and dividend income in the way that gives the least tax (ITA 2007 s. 25(2)), and from 2027-28 against other income before property, savings and dividend income (s. 25(3A)), instead of always in the order non-savings, savings, dividends. diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/other_income_first.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/other_income_first.yaml new file mode 100644 index 0000000000..aad1838fca --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/other_income_first.yaml @@ -0,0 +1,14 @@ +description: Income Tax deducts reliefs and allowances from income other than property, savings and dividend income before it deducts them from property, savings or dividend income, and then in the way that gives the least tax. +values: + # Before 2027-28, s. 25(2) alone decides how they are deducted. + 0000-01-01: false + 2027-04-06: true +metadata: + unit: bool + period: year + label: Reliefs and allowances deducted from other income first + reference: + - title: Income Tax Act 2007 s. 25(2) and (3A) + href: https://www.legislation.gov.uk/ukpga/2007/3/section/25 + - title: Finance Act 2026 s. 6(6) and (8) (s. 25(3A) has effect from 2027-28) + href: https://www.legislation.gov.uk/ukpga/2026/11/section/6/enacted diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml new file mode 100644 index 0000000000..dff0428056 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml @@ -0,0 +1,203 @@ +# ITA 2007 s. 25(2): "At Steps 2 and 3, deduct the reliefs and allowances in +# the way which will result in the greatest reduction in the taxpayer's +# liability to income tax." From 2027-28, s. 25(3A) (Finance Act 2026 s. 6) +# deducts them from income other than property, savings and dividend income +# first. Savings sit above non-savings income and dividends above savings +# (s. 16), and from 2027-28 property income sits just below savings (s. 16A). +# 2026-27 and 2027-28: Personal Allowance £12,570, tapered away by £1 for each +# £2 of adjusted net income above £100,000 (s. 35); basic rate limit £37,700 +# and higher rate limit £125,140; starting rate limit for savings £5,000 +# (s. 12); personal savings allowance £1,000 for a basic rate taxpayer and £500 +# with higher rate income (s. 12B); dividend allowance £500 (s. 13A); dividend +# rates 10.75%, 35.75% and 39.35%. The model's 2027-28 property basic rate is +# 24% (Budget 2026, paragraph 6.246). + +- name: Case 1, dividends of £20,000 take the whole Personal Allowance + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + dividend_income: 20_000 + output: + received_allowances_dividend_income: 12_570 + # (20,000 - 12,570 - 500) x 10.75%. + income_tax: 744.975 + +- name: Case 2, savings in the starting rate band leave the Personal Allowance to dividends + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + savings_interest_income: 4_000 + dividend_income: 20_000 + output: + # The £4,000 of savings is the lowest part of Step 3 income, within the + # £5,000 starting rate band, so it is taxed at 0% (s. 12). Set against + # it, the allowance would save nothing; set against dividends it saves + # 10.75%. So all £12,570 comes off dividends, and the tax is as in case 1: + # (20,000 - 12,570 - 500) x 10.75%. + received_allowances_savings_income: 0 + received_allowances_dividend_income: 12_570 + savings_income_tax: 0 + income_tax: 744.975 + +- name: Case 3, a trading loss is set against dividends rather than savings at the starting rate + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + savings_interest_income: 4_000 + dividend_income: 200_000 + trading_loss: 1_000 + output: + # Adjusted net income of £203,000 removes the Personal Allowance. The + # £1,000 of relief (s. 64) comes off dividends: the savings stay at 0% + # in the starting rate band, and the dividends start £1,000 higher, at + # £4,000. Dividends of £199,000 run from £4,000 to £203,000: + # £500 nil rate, (37,700 - 4,500) x 10.75% = 3,569, + # (125,140 - 37,700) x 35.75% = 31,259.80 and + # (203,000 - 125,140) x 39.35% = 30,637.91, so 65,466.71. Set against + # savings, £1,000 more dividends would fall in the ordinary rate band: + # £107.50 more. + personal_allowance: 0 + trade_loss_relief_against_general_income: 1_000 + trade_loss_relief_against_savings_and_dividends: 1_000 + received_allowances_savings_income: 0 + received_allowances_dividend_income: 1_000 + savings_income_tax: 0 + income_tax: 65_466.71 + +- name: Case 4, in 2027-28 a trading loss is set against property income rather than dividends + period: 2027 + absolute_error_margin: 0.01 + input: + age: 40 + property_income: 20_000 + dividend_income: 200_000 + trading_loss: 1_000 + output: + # No other income, so s. 25(3A) leaves the choice between property and + # dividends to s. 25(2). Property income sits below dividends (s. 16A). + # Against property, the £1,000 of relief saves 24% on property income in + # the basic rate band, and moves £1,000 of dividends down into the + # ordinary rate band at 10.75%: a net saving of £132.50 over setting it + # against dividends, which would leave Step 3 income and the dividends' + # top of £219,000 unchanged. Property: 19,000 x 24% = 4,560. Dividends of + # £200,000 from £19,000 to £219,000: £500 nil rate, + # (37,700 - 19,500) x 10.75% = 1,956.50, 87,440 x 35.75% = 31,259.80 and + # (219,000 - 125,140) x 39.35% = 36,933.91, so 70,150.21. + personal_allowance: 0 + trade_loss_relief_against_savings_and_dividends: 0 + received_allowances_dividend_income: 0 + property_income_tax: 4_560 + dividend_income_tax: 70_150.21 + income_tax: 74_710.21 + +- name: Case 5, a Scottish taxpayer's savings and dividends follow the UK rates + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 3_000 + savings_interest_income: 4_000 + dividend_income: 20_000 + region: SCOTLAND + output: + # Scottish rates apply only to non-savings income (s. 11A). The pay + # takes £3,000 of the allowance, saving the 19% starter rate. The + # savings stay whole at 0% in the starting rate band (s. 12) and the + # dividends take the other £9,570. Dividends of £10,430 from £4,000: + # (10,430 - 500) x 10.75% on the UK bands (s. 13(5)) = 1,067.475. + # Setting the allowance against savings before dividends would leave + # £14,430 of dividends: £430 more tax. + received_allowances_earned_income: 3_000 + received_allowances_savings_income: 0 + received_allowances_dividend_income: 9_570 + earned_income_tax: 0 + savings_income_tax: 0 + income_tax: 1_067.475 + +- name: Case 6, a Scottish higher rate taxpayer keeps the allowance against pay + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 50_000 + savings_interest_income: 10_000 + region: SCOTLAND + output: + # Pay of £37,430 after the allowance is taxed at the Scottish rates: + # 3,967 x 19% + 12,989 x 20% + 14,136 x 21% + 6,338 x 42% = 8,982.05. + # Savings run from £37,430 to £47,430. Step 3 income would be higher + # rate income outside Scotland, so the savings allowance is £500 + # (s. 12B(8)(b)(iv)), and the other £9,500 is above the UK basic rate + # limit, at 40% (s. 11D(6)): 3,800. Moving £270 of the allowance to + # savings, as in case 7, would tax £270 more pay at 42% to take £270 of + # savings out of the 40% band: £5.40 more. + received_allowances_earned_income: 12_570 + received_allowances_savings_income: 0 + earned_income_tax: 8_982.05 + savings_income_tax: 3_800 + income_tax: 12_782.05 + +- name: Case 7, savings across the basic rate limit take part of the allowance + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 50_000 + savings_interest_income: 10_000 + output: + # With all £12,570 against pay, pay of £37,430 is taxed at 20% and the + # £500 savings allowance covers savings from £37,430 to £37,930, all but + # £230 of it below the basic rate limit, where savings would be taxed + # at 20% anyway. Setting £270 against savings instead puts the pay at + # £37,700 and the savings allowance above the limit, where it saves 40%: + # pay 37,700 x 20% = 7,540; savings (47,430 - 38,200) x 40% = 3,692. + # The order pay, savings, dividends charges 7,486 + 3,800 = 11,286. + received_allowances_earned_income: 12_300 + received_allowances_savings_income: 270 + earned_income_tax: 7_540 + savings_income_tax: 3_692 + income_tax: 11_232 + +- name: Case 8, dividends across the basic rate limit take part of the allowance + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 50_000 + dividend_income: 20_000 + output: + # As in case 7, the dividend allowance saves most above the basic rate + # limit. Pay 37,700 x 20% = 7,540; dividends of £19,730 from £37,700: + # £500 nil rate and (57,430 - 38,200) x 35.75% = 6,874.725. The order + # pay, savings, dividends charges 7,486 + 19,500 x 35.75% = 14,457.25. + received_allowances_earned_income: 12_300 + received_allowances_dividend_income: 270 + dividend_income_tax: 6_874.725 + income_tax: 14_414.725 + +- name: Case 9, a person with only savings sets the whole allowance against them + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + savings_interest_income: 20_000 + output: + # Step 3 income of £7,430: £5,000 at the starting rate, £1,000 within + # the savings allowance and (7,430 - 6,000) x 20% = 286. + received_allowances_savings_income: 12_570 + income_tax: 286 + +- name: Case 10, a person with only property income in 2027-28 sets the whole allowance against it + period: 2027 + absolute_error_margin: 0.01 + input: + age: 40 + property_income: 30_000 + output: + # (30,000 - 12,570) x 24%. + received_allowances_earned_income: 12_570 + property_income_tax: 4_183.2 + income_tax: 4_183.2 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_dividend_income.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_dividend_income.yaml index bc8ca3381e..ef45d15f0a 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_dividend_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_dividend_income.yaml @@ -1,59 +1,81 @@ -- name: Dividend income less than remaining allowance +# The allowances and trade loss relief go to non-savings, savings and dividend +# income in the way that gives the least tax (ITA 2007 s. 25(2)). 2024-25: +# Personal Allowance £12,570, starting rate limit for savings £5,000 (s. 12), +# dividend allowance £500 (s. 13A). + +- name: Dividend income less than the allowances left period: 2024 input: allowances: 12_570 - received_allowances_earned_income: 8_000 - received_allowances_savings_income: 2_000 + taxable_employment_income: 8_000 + taxable_savings_interest_income: 2_000 taxable_dividend_income: 2_000 output: + # The £12,000 of income is all within the allowances. received_allowances_dividend_income: 2_000 -- name: Dividend income equal to remaining allowance +- name: Dividend income equal to the allowances left period: 2024 input: allowances: 12_570 - received_allowances_earned_income: 10_000 - received_allowances_savings_income: 570 + taxable_employment_income: 10_000 + taxable_savings_interest_income: 570 taxable_dividend_income: 2_000 output: received_allowances_dividend_income: 2_000 -- name: Dividend income greater than remaining allowance +- name: Dividend income greater than the allowances left period: 2024 input: allowances: 12_570 - received_allowances_earned_income: 11_000 - received_allowances_savings_income: 1_000 + taxable_employment_income: 11_000 + taxable_savings_interest_income: 1_000 taxable_dividend_income: 1_000 output: + # The pay takes £11,000. Of the £430 of income left, savings would be + # in the starting rate band and dividends within the dividend + # allowance, both taxed at 0%, so the allowances keep the order + # savings then dividends: £1,000 and £570. received_allowances_dividend_income: 570 -- name: No remaining allowance +- name: Allowances go to dividends beyond the dividend allowance period: 2024 input: allowances: 12_570 - received_allowances_earned_income: 10_570 - received_allowances_savings_income: 2_000 + taxable_employment_income: 10_570 + taxable_savings_interest_income: 2_000 taxable_dividend_income: 1_000 output: - received_allowances_dividend_income: 0 + # After the pay, £1,000 of income is left. Left as £1,000 of dividends, + # £500 would be taxed at 8.75% (s. 13A nil rate on the first £500). Left + # as £500 of savings, in the starting rate band, and £500 of dividends, + # within the nil rate, it is all taxed at 0%. + received_allowances_savings_income: 1_500 + received_allowances_dividend_income: 500 + dividend_income_tax: 0 - name: Negative taxable dividend income period: 2024 input: allowances: 12_570 - received_allowances_earned_income: 10_000 - received_allowances_savings_income: 1_000 + taxable_employment_income: 10_000 + taxable_savings_interest_income: 1_000 taxable_dividend_income: -500 output: received_allowances_dividend_income: 0 -- name: Negative remaining allowance +- name: Allowances keep the dividends left within the dividend allowance period: 2024 input: allowances: 12_570 - received_allowances_earned_income: 11_000 - received_allowances_savings_income: 2_000 + taxable_employment_income: 11_000 + taxable_savings_interest_income: 2_000 taxable_dividend_income: 1_000 output: - received_allowances_dividend_income: 0 \ No newline at end of file + # £1,430 is left after the pay. Up to £500 of dividends are taxed at 0% + # (s. 13A) and savings in the starting rate band at 0% (s. 12), so £930 + # of savings and £500 of dividends are left: £1,070 of the allowances + # go to savings and £500 to dividends. + received_allowances_savings_income: 1_070 + received_allowances_dividend_income: 500 + dividend_income_tax: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_earned_income.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_earned_income.yaml index 6acebb4035..5abae01d67 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_earned_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_earned_income.yaml @@ -32,10 +32,12 @@ period: 2024 input: allowances: 12_570 - net_income: 20_000 + taxable_employment_income: 5_000 taxable_savings_interest_income: 8_000 taxable_dividend_income: 7_000 output: + # The pay would be taxed at 20%, so it takes £5,000 of the allowances + # (ITA 2007 s. 25(2)). received_allowances_earned_income: 5_000 - name: Earned income only, no savings or dividends diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_savings_income.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_savings_income.yaml index ff181a5172..708a4858f5 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_savings_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_savings_income.yaml @@ -1,53 +1,66 @@ -- name: Savings income less than remaining allowance +# The allowances and trade loss relief go to non-savings, savings and dividend +# income in the way that gives the least tax (ITA 2007 s. 25(2)). 2024-25: +# Personal Allowance £12,570, starting rate limit for savings £5,000 (s. 12), +# personal savings allowance £1,000 for a basic rate taxpayer (s. 12B). + +- name: Savings income less than the allowances left after earnings period: 2024 input: allowances: 12_570 - received_allowances_earned_income: 10_000 + taxable_employment_income: 10_000 taxable_savings_interest_income: 2_000 output: + # The £12,000 of income is all within the allowances. received_allowances_savings_income: 2_000 -- name: Savings income equal to remaining allowance +- name: Savings income equal to the allowances left after earnings period: 2024 input: allowances: 12_570 - received_allowances_earned_income: 10_570 + taxable_employment_income: 10_570 taxable_savings_interest_income: 2_000 output: received_allowances_savings_income: 2_000 -- name: Savings income greater than remaining allowance +- name: Savings income greater than the allowances left after earnings period: 2024 input: allowances: 12_570 - received_allowances_earned_income: 11_570 + taxable_employment_income: 11_570 taxable_savings_interest_income: 3_000 output: + # The pay takes £11,570, which would otherwise be taxed at 20%. The + # £2,000 of savings left is within the £5,000 starting rate band, so + # it is taxed at 0% whichever £1,000 of the allowances it takes. received_allowances_savings_income: 1_000 -- name: No remaining allowance +- name: No allowances go to savings that the starting rate leaves untaxed period: 2024 input: allowances: 12_570 - received_allowances_earned_income: 12_570 + taxable_employment_income: 12_570 taxable_savings_interest_income: 1_000 output: + # Against the savings the allowances would save nothing: the £1,000 is + # in the starting rate band. Against the pay they save 20%. received_allowances_savings_income: 0 - name: Negative taxable savings interest income period: 2024 input: allowances: 12_570 - received_allowances_earned_income: 10_000 + taxable_employment_income: 10_000 taxable_savings_interest_income: -500 output: received_allowances_savings_income: 0 -- name: Negative remaining allowance +- name: Earnings above the allowances leave none for savings period: 2024 input: allowances: 12_570 - received_allowances_earned_income: 13_000 + taxable_employment_income: 13_000 taxable_savings_interest_income: 1_000 output: - received_allowances_savings_income: 0 \ No newline at end of file + # £430 of pay is taxed at 20% and the savings above it at the starting + # rate of 0%, so the allowances are worth most against the pay. + received_allowances_savings_income: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.yaml index a7365d8d5e..da87919870 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.yaml @@ -110,7 +110,7 @@ # (40,000 - 12,570 - 500) x 10.75%: the tax on £40,000 of dividends. income_tax: 2_894.98 -- name: Relief takes non-savings income first, then savings, then dividends +- name: Relief and allowances leave savings that the starting rate and savings allowance cover period: 2026 absolute_error_margin: 0.01 input: @@ -121,13 +121,16 @@ output: trade_loss_relief_against_general_income: 20_000 trade_loss_relief_against_savings_and_dividends: 15_000 - # The £15,000 of relief left after the pay, with the Personal - # Allowance, takes all £10,000 of savings and £17,570 of dividends. + # ITA 2007 s. 25(2) sets the £32,570 of relief and Personal Allowance + # against income in the way that gives the least tax. The pay takes + # £5,000. Of Step 3 income of £12,430, savings up to £6,000 are taxed at + # 0%: £5,000 at the starting rate (s. 12) and £1,000 within the savings + # allowance (s. 12A). So savings take £4,000 and dividends £23,570. received_allowances_earned_income: 0 - received_allowances_savings_income: 10_000 - received_allowances_dividend_income: 17_570 - # £25,000 of dividends is left: (25,000 - 12,570 - 500) x 10.75%. - income_tax: 1_282.48 + received_allowances_savings_income: 4_000 + received_allowances_dividend_income: 23_570 + # £6,430 of dividends is left: (6,430 - 500) x 10.75%. + income_tax: 637.475 - name: Adjusted total income is total income after employment expenses period: 2026 diff --git a/policyengine_uk/tests/policy/integration/income.yaml b/policyengine_uk/tests/policy/integration/income.yaml index 391dcf83fb..e86069230b 100644 --- a/policyengine_uk/tests/policy/integration/income.yaml +++ b/policyengine_uk/tests/policy/integration/income.yaml @@ -20,12 +20,20 @@ employment_income: 40_000 savings_interest_income: 15_000 output: - basic_rate_savings_income_pre_starter: 10_000 + # ITA 2007 s. 25(2): of the £12,500 Personal Allowance, £2,500 comes off + # the pay and £10,000 off the savings. The pay left is £37,500, the basic + # rate limit, so the £500 savings allowance (s. 12B(3)) covers savings + # that would otherwise be taxed at 40% rather than 20%: £100 less tax + # than the allowance all against the pay. Savings of £5,000 run from + # £37,500 to £42,500: £500 at the nil rate and £4,500 at 40%. Setting + # more against the pay or the savings would not change the tax. + received_allowances_savings_income: 10_000 + basic_rate_savings_income_pre_starter: 0 savings_starter_rate_income: 0 - basic_rate_savings_income: 9_500 + basic_rate_savings_income: 0 savings_allowance: 500 - higher_rate_savings_income: 5_000 - taxed_savings_income: 14_500 + higher_rate_savings_income: 4_500 + taxed_savings_income: 4_500 - name: Dividends taxed at the marginal rates period: 2020 @@ -55,7 +63,7 @@ dividend_income_tax: 178.75 income_tax: 7_884.75 -- name: Personal allowance caps savings starter rate before dividends. +- name: Personal allowance goes to dividends rather than savings at the starting rate. period: 2027 absolute_error_margin: 0.01 input: @@ -63,14 +71,20 @@ savings_interest_income: 5_000 dividend_income: 5_000 output: - received_allowances_savings_income: 2_570 - basic_rate_savings_income_pre_starter: 2_430 - savings_starter_rate_income: 2_430 + # The pay takes £10,000 of the allowance first (ITA 2007 s. 25(3A)). The + # £5,000 of savings is all in the starting rate band (s. 12), so the + # other £2,570 saves tax only against dividends (s. 25(2)). Dividends of + # £2,430: (2,430 - 500) x 10.75% = 207.475. Setting the £2,570 against + # savings first would leave £5,000 of dividends and charge 483.75. + received_allowances_savings_income: 0 + received_allowances_dividend_income: 2_570 + basic_rate_savings_income_pre_starter: 5_000 + savings_starter_rate_income: 5_000 savings_allowance: 1_000 savings_income_tax: 0 - taxed_dividend_income: 4_500 - dividend_income_tax: 483.75 - income_tax: 483.75 + taxed_dividend_income: 1_930 + dividend_income_tax: 207.475 + income_tax: 207.475 - name: Starter-rate savings applies before personal savings allowance. period: 2027 diff --git a/policyengine_uk/tests/test_income_tax_allocation.py b/policyengine_uk/tests/test_income_tax_allocation.py new file mode 100644 index 0000000000..2a462513e0 --- /dev/null +++ b/policyengine_uk/tests/test_income_tax_allocation.py @@ -0,0 +1,322 @@ +"""Property tests for setting Income Tax reliefs and allowances against income +in the way that gives the least tax (ITA 2007 s. 25(2) and, from 2027-28, +s. 25(3A); #2106). + +The invariants, for every person without Marriage Allowance, on the Step 5 +tax (income_tax_pre_charges), which is all the share changes: + +1. Least tax: no share of the deductions between the components s. 25 lets + them come off gives less Step 5 tax than the model's. A brute-force oracle + forces each share on a grid through the model's own variables. +2. The tax never exceeds what the order non-savings, savings, dividends + charges, which is the order the model used before #2106. +3. The deductions set against the components add up to the deductions used, + min(relief + allowances, income), and none exceeds its component. +4. With one kind of income there is nothing to share, so the tax and the + deductions are the old order's. +5. The search's own tax function agrees with income_tax_pre_charges on every + share (a differential test of two implementations of Step 5). +6. The vertex search finds the least value of that function, checked against + a dense grid. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.utils.income_tax_allocation import ( + _context, + _least_tax_share, + _shares, + default_order_deductions, + step_5_tax, +) + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +YEARS = (2026, 2027) +PEOPLE = 12 +REGIONS = ["LONDON", "SCOTLAND", "WALES", "NORTH_EAST"] +INCOMES = ( + "employment_income", + "savings_interest_income", + "dividend_income", + "property_income", + "trading_loss", + "gift_aid", +) +ALLOCATION = ( + "received_allowances_earned_income", + "received_allowances_savings_income", + "received_allowances_dividend_income", + "trade_loss_relief_against_savings_and_dividends", +) +VARIABLES = ALLOCATION + ( + "net_income_before_trade_loss_relief", + "taxable_savings_interest_income", + "taxable_dividend_income", + "taxable_property_income", + "trade_loss_relief_against_general_income", + "allowances", + "income_tax_pre_charges", +) +# Hand-built people for the brute-force oracle: (year, region, incomes). +HAND_BUILT = [ + (2026, "LONDON", dict(savings_interest_income=4_000, dividend_income=20_000)), + (2026, "LONDON", dict(employment_income=50_000, savings_interest_income=10_000)), + (2026, "LONDON", dict(employment_income=50_000, dividend_income=20_000)), + ( + 2026, + "LONDON", + dict( + savings_interest_income=4_000, dividend_income=200_000, trading_loss=1_000 + ), + ), + ( + 2027, + "LONDON", + dict(property_income=20_000, dividend_income=200_000, trading_loss=1_000), + ), + ( + 2026, + "SCOTLAND", + dict( + employment_income=3_000, + savings_interest_income=4_000, + dividend_income=20_000, + ), + ), + ( + 2027, + "LONDON", + dict( + employment_income=9_000, + property_income=30_000, + savings_interest_income=6_000, + dividend_income=15_000, + ), + ), + ( + 2026, + "WALES", + dict( + employment_income=40_000, + savings_interest_income=3_000, + dividend_income=8_000, + gift_aid=2_000, + ), + ), +] +GRID = 21 + +amount = st.one_of( + st.just(0.0), + st.floats(min_value=0, max_value=20_000), + st.floats(min_value=0, max_value=150_000), + st.sampled_from([4_000.0, 6_000.0, 12_570.0, 37_700.0, 50_270.0, 125_140.0]), +) +person = st.tuples(*([amount] * 5), st.floats(min_value=0, max_value=5_000)) +people = st.lists(person, min_size=PEOPLE, max_size=PEOPLE) +year = st.sampled_from(YEARS) +SETTINGS = settings( + max_examples=20, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +def situation(count, year, regions): + return { + "people": {f"p{i}": {"age": {year: 40}} for i in range(count)}, + "benunits": {f"b{i}": {"members": [f"p{i}"]} for i in range(count)}, + "households": { + f"h{i}": {"members": [f"p{i}"], "region": {year: regions[i]}} + for i in range(count) + }, + } + + +@pytest.fixture(scope="module") +def sims(): + """Simulations built once and reused: one for the model's own share and + one with the share forced, for each year and set of regions.""" + cache = {} + + def get(year, regions, forced=False): + key = (year, tuple(regions), forced) + if key not in cache: + cache[key] = Simulation(situation=situation(len(regions), year, regions)) + return cache[key] + + return get + + +def run(sim, year, incomes, forced=None): + count = sim.populations["person"].count + for name in INCOMES: + values = np.asarray(incomes.get(name, 0.0), dtype=float) + sim.set_input(name, year, np.broadcast_to(values, (count,)).copy()) + for name, values in (forced or {}).items(): + sim.set_input(name, year, np.asarray(values, dtype=float)) + sim.reset_calculations() + return sim + + +def results(sim, year): + return {name: sim.calculate(name, year).astype(float) for name in VARIABLES} + + +def parameters_of(sim): + return lambda period: sim.tax_benefit_system.parameters(str(period)) + + +def draw(drawn): + return {name: np.array(values) for name, values in zip(INCOMES, zip(*drawn))} + + +def tolerance(*values): + """A penny plus float32 rounding on the amounts involved.""" + return 0.01 + 2e-7 * sum(np.abs(v) for v in values) + + +def default_order(sim, year): + default = default_order_deductions(sim.populations["person"], year) + return { + "received_allowances_savings_income": default.savings, + "received_allowances_dividend_income": default.dividends, + } + + +@SETTINGS +@given(year, people) +@example( + 2026, + [(0.0, 4_000.0, 20_000.0, 0.0, 0.0, 0.0), (50_000.0, 10_000.0, 0.0, 0.0, 0.0, 0.0)] + + [(0.0,) * 6] * (PEOPLE - 2), +) +def test_least_tax_never_exceeds_default_order_and_adds_up(sims, year, drawn): + incomes = draw(drawn) + regions = [REGIONS[i % len(REGIONS)] for i in range(PEOPLE)] + law_sim = run(sims(year, regions), year, incomes) + law = results(law_sim, year) + forced = run(sims(year, regions, True), year, incomes, default_order(law_sim, year)) + default = results(forced, year) + + # Invariant 2: never more tax than the order non-savings, savings, dividends. + # The share changes only the Step 5 tax: the Step 6 reductions and Step 7 + # charges do not depend on it. + tax, old_tax = law["income_tax_pre_charges"], default["income_tax_pre_charges"] + assert np.all(tax <= old_tax + tolerance(old_tax)), (tax, old_tax) + + # Invariant 3: the deductions add up and none exceeds its component. + savings = np.maximum(0, law["taxable_savings_interest_income"]) + dividends = np.maximum(0, law["taxable_dividend_income"]) + non_savings = np.maximum( + 0, law["net_income_before_trade_loss_relief"] - savings - dividends + ) + relief = law["trade_loss_relief_against_general_income"] + used = np.minimum(relief + law["allowances"], non_savings + savings + dividends) + to_savings = law["received_allowances_savings_income"] + to_dividends = law["received_allowances_dividend_income"] + relief_left = law["trade_loss_relief_against_savings_and_dividends"] + to_non_savings = law["received_allowances_earned_income"] + relief - relief_left + margin = tolerance(used) + assert np.all(np.abs(to_non_savings + to_savings + to_dividends - used) <= margin) + for share, component in [ + (to_non_savings, non_savings), + (to_savings, savings), + (to_dividends, dividends), + ]: + assert np.all(share >= -margin) and np.all(share <= component + margin) + assert np.all(relief_left <= relief + margin) + assert np.all(relief_left <= to_savings + to_dividends + margin) + + # Invariant 4: one kind of income, nothing to share. + kinds = (non_savings > 0).astype(int) + (savings > 0) + (dividends > 0) + single = kinds <= 1 + np.testing.assert_allclose(tax[single], old_tax[single], atol=0.01) + for name in ALLOCATION: + np.testing.assert_allclose(law[name][single], default[name][single], atol=0.01) + + +@pytest.mark.parametrize( + "year, region, incomes", + HAND_BUILT, + ids=[ + f"{year}-{region}-" + "-".join(incomes) for year, region, incomes in HAND_BUILT + ], +) +def test_tax_equals_brute_force_grid_minimum(sims, year, region, incomes): + law_sim = run(sims(year, [region]), year, incomes) + law = results(law_sim, year) + c = _context(law_sim.populations["person"], year, parameters_of(law_sim)) + savings, dividends = c.savings[0, 0], c.dividends[0, 0] + used, non_savings = c.used[0, 0], c.non_savings[0, 0] + # From 2027-28 s. 25(3A) sets the deductions against other income first. + at_least_non_savings = used - c.shared[0, 0] if c.other_first else 0 + to_savings, to_dividends = ( + grid.ravel() + for grid in np.meshgrid( + np.linspace(0, min(savings, used), GRID), + np.linspace(0, min(dividends, used), GRID), + ) + ) + to_non_savings = used - to_savings - to_dividends + feasible = (to_non_savings >= at_least_non_savings - 1e-6) & ( + to_non_savings <= non_savings + 1e-6 + ) + forced = run( + sims(year, [region] * GRID**2, True), + year, + incomes, + { + "received_allowances_savings_income": to_savings, + "received_allowances_dividend_income": to_dividends, + }, + ) + grid_tax = forced.calculate("income_tax_pre_charges", year).astype(float) + model_tax = law["income_tax_pre_charges"][0] + least = grid_tax[feasible].min() + step = min(savings, used) / (GRID - 1) + min(dividends, used) / (GRID - 1) + # Invariant 1: the model's share is the least; the grid comes within its + # step of it, since a pound moved between components changes the tax by + # less than a pound. + assert model_tax <= least + tolerance(model_tax) + assert model_tax >= least - step - tolerance(model_tax) + # Invariant 5: the search's tax function is the model's Step 5 tax. + forced_c = _context(forced.populations["person"], year, parameters_of(forced)) + x = (forced_c.step_3_income - forced_c.savings - forced_c.dividends)[:, 0] + x = x + to_savings + to_dividends + s = savings - to_savings + v = dividends - to_dividends + own = step_5_tax(forced_c, x[:, None], s[:, None], v[:, None])[:, 0] + np.testing.assert_allclose( + own[feasible], grid_tax[feasible], atol=tolerance(grid_tax).max() + ) + + +@SETTINGS +@given(year, people) +def test_vertex_search_equals_dense_grid(sims, year, drawn): + incomes = draw(drawn) + regions = [REGIONS[i % len(REGIONS)] for i in range(PEOPLE)] + sim = run(sims(year, regions), year, incomes) + c = _context(sim.populations["person"], year, parameters_of(sim)) + x, s, v = _least_tax_share(c) + found = step_5_tax(c, x, s, v)[:, 0] + # Invariant 6: a 101 x 101 grid over the first component and savings + # left, projected onto the feasible shares. + total = c.step_3_income + low = np.maximum(0, total - c.savings - c.dividends) + high = np.minimum(c.first, total) + fractions = np.linspace(0, 1, 101) + grid_x = (low + (high - low) * fractions[None, :]).repeat(101, axis=1) + grid_s = np.tile(c.savings * fractions[None, :], 101) + grid_x, grid_s, grid_v = _shares(c, grid_x, grid_x + grid_s) + grid = step_5_tax(c, grid_x, grid_s, grid_v).min(axis=1) + step = ((high - low) / 100 + c.savings / 100)[:, 0] + assert np.all(found <= grid + 1e-6) + assert np.all(found >= grid - 2 * step - 1e-6) diff --git a/policyengine_uk/utils/income_tax_allocation.py b/policyengine_uk/utils/income_tax_allocation.py new file mode 100644 index 0000000000..055edd076f --- /dev/null +++ b/policyengine_uk/utils/income_tax_allocation.py @@ -0,0 +1,448 @@ +"""Set Income Tax reliefs and allowances against the components of income. + +At Steps 2 and 3 of the Income Tax calculation (ITA 2007 s. 23), s. 25(2) +deducts reliefs and allowances "in the way which will result in the greatest +reduction in the taxpayer's liability to income tax". From 2027-28, s. 25(3A) +(Finance Act 2026 s. 6(6) and (8)) deducts them from income other than +property, savings and dividend income first, and s. 25(2) then chooses between +property, savings and dividend income. Before 2027-28, s. 25(2) chooses between +non-savings, savings and dividend income. Property income is non-savings income +taxed at the same rates in those years, so the deductions come off the rest of +non-savings income first, as they always have in the model. + +The model deducts trade loss relief against general income at Step 2 and the +amounts in ``allowances`` (the personal and blind person's allowances, gifts of +investments to charity, covenanted payments, other deductions and pension +relief). Together they are one sum to share between the components. Each part +of it can come off any component, and the tax depends only on how much comes +off each, so the search shares out the sum. + +How the search finds the least tax +---------------------------------- +Three components share what s. 25(3A) leaves: the first (non-savings income +before 2027-28, property income after), savings and dividends. Write ``x`` for +the first component left after deductions, ``w`` for ``x`` plus the savings +left, and ``T`` for the three together after deductions, which the sharing +does not change. The dividends left are ``T - w``. Savings sit above +non-savings income and dividends above savings (ss. 16 and 16A), so with ``q`` +the non-savings income below the savings (``x``, or property plus the other +non-savings income left before it from 2027-28): + +- tax on the first component changes slope where ``x`` crosses a rate limit; +- the starting rate for savings (s. 12) and the savings nil rate (s. 12A) cover + the savings from ``q`` up to ``max(q, L) + A``, where ``L`` is the starting + rate limit and ``A`` the personal savings allowance. That changes slope where + ``q`` reaches ``L``, where ``max(q, L) + A`` crosses a rate limit, and where + ``w`` reaches the top of that band: ``w - x = A`` above ``L``, ``w`` constant + below it; +- the rest of the savings change slope where their top, ``w``, crosses a rate + limit; +- the dividend nil rate (s. 13A) covers the lowest dividend allowance ``DA`` of + the dividends, so their tax changes slope where ``w + DA`` crosses a dividend + rate limit or reaches ``T``. + +The limits on the components add ``x = 0`` and ``x`` equal to the whole first +component, savings left from nil to all (``w - x = 0`` and ``w - x`` equal to +the savings), and dividends left from nil to all (``w = T`` and ``w = T`` less +the dividends). So the Step 5 tax is continuous and linear on each piece of the +feasible polygon cut out by vertical lines (``x`` constant), horizontal lines +(``w`` constant) and diagonal lines (``w - x`` constant). A linear function +takes its least value on a polygon at a corner, so the least tax is at a point +where two of these lines cross. ``_candidates`` lists every such crossing, and +the search evaluates the tax at all of them for all people at once. + +That argument needs the starting rate limit at or below the basic rate limit +and a phase-out of one pound of starting rate band for each pound of +non-savings income (s. 12), as in every year modelled. Before 2027-28 it also +needs no Marriage Allowance: the model subtracts that allowance from earned +income (#1947) rather than giving the s. 55B tax reduction, which moves the +savings and dividends down by up to that amount. The search still evaluates +every candidate with the full tax, so with Marriage Allowance it takes the best +of them. In every case it keeps the order non-savings, savings, dividends +unless another share gives less tax, so it never charges more than that order. +""" + +from types import SimpleNamespace + +import numpy as np + +from policyengine_uk.utils.income_tax import first_extended_scottish_threshold + +# A share replaces the order non-savings, savings, dividends only where it +# saves more than this (in pounds), so rounding never moves a deduction. +TOLERANCE = 1e-3 +# Shares whose tax differs by less than this (in pounds) are equally good. +TIE = 1e-6 +# Bound on people times candidates evaluated at once, to bound memory. +CHUNK = 2_000_000 +# The variables on earned_taxable_income_exclusions that the allocation sets +# rather than reads. +ALLOCATED_EXCLUSIONS = ( + "taxable_savings_interest_income", + "taxable_dividend_income", + "taxable_property_income", + "received_allowances_earned_income", +) + + +def _schedule(scale, gift, first=1, last=3): + """Thresholds and rate steps of a marginal-rate scale, with the limits from + index ``first`` up to ``last`` raised by the grossed-up Gift Aid gift (ITA + 2007 s. 414(2)(b)), as calc_income_tax_with_gift_aid applies it.""" + thresholds, steps, previous = [], [], 0 + stop = min(last, len(scale.thresholds)) + for index, (threshold, rate) in enumerate(zip(scale.thresholds, scale.rates)): + thresholds.append(threshold + (gift if first <= index < stop else 0)) + steps.append(rate - previous) + previous = rate + return thresholds, steps + + +def _tax(income, schedule): + thresholds, steps = schedule + return sum(step * np.maximum(income - t, 0) for t, step in zip(thresholds, steps)) + + +def _context(person, period, parameters): + """Everything the tax on a share depends on, as float64 columns.""" + income_tax = parameters(period).gov.hmrc.income_tax + rates = income_tax.rates + + def column(values): + return np.asarray(values, dtype=np.float64)[:, None] + + savings = np.maximum(0, column(person("taxable_savings_interest_income", period))) + dividends = np.maximum(0, column(person("taxable_dividend_income", period))) + non_savings = np.maximum( + 0, + column(person("net_income_before_trade_loss_relief", period)) + - savings + - dividends, + ) + property_income = np.minimum( + np.maximum(0, column(person("taxable_property_income", period))), non_savings + ) + deductions = column(person("trade_loss_relief_against_general_income", period)) + deductions = deductions + column(person("allowances", period)) + used = np.minimum(deductions, non_savings + savings + dividends) + exclusions = [ + variable + for variable in income_tax.earned_taxable_income_exclusions + if variable not in ALLOCATED_EXCLUSIONS + ] + earned_exclusions = sum( + (column(person(variable, period)) for variable in exclusions), + np.zeros_like(savings), + ) + gift = column(person("gift_aid_grossed_up", period)) + scottish = np.asarray(person("pays_scottish_income_tax", period))[:, None] + uk_thresholds = rates.uk.thresholds + c = SimpleNamespace( + other_first=bool(income_tax.reliefs.other_income_first), + savings=savings, + dividends=dividends, + non_savings=non_savings, + property=property_income, + used=used, + earned_exclusions=earned_exclusions, + gift=gift, + scottish=scottish, + uk=_schedule(rates.uk, gift), + scotland=_schedule( + rates.scotland.rates, + gift, + first_extended_scottish_threshold(rates.scotland.rates), + len(rates.scotland.rates.thresholds), + ), + dividend_scale=_schedule(rates.dividends, gift), + bands=( + uk_thresholds[0], + uk_thresholds[1] + gift, + uk_thresholds[2] + gift, + ), + savings_rates=rates.savings, + property_rates=rates.property, + starting_limit=rates.savings_starter_rate.allowance, + phase_out=rates.savings_starter_rate.phase_out, + savings_allowance=column(person("savings_allowance", period)), + dividend_allowance=income_tax.allowances.dividend_allowance, + ) + if c.other_first: + # s. 25(3A): other non-savings income takes the deductions first. + other = non_savings - property_income + to_other = np.minimum(other, used) + c.other_left = other - to_other + c.first = property_income + c.shared = used - to_other + # The savings stack on the other non-savings income left, as earned + # taxable income has it, plus the property income left. + c.offset = np.maximum(0, c.other_left - earned_exclusions) + else: + c.first = non_savings + c.shared = used + c.offset = np.zeros_like(savings) + c.step_3_income = c.first + savings + dividends - c.shared + return c + + +def _non_savings_tax(c, income): + return np.where(c.scottish, _tax(income, c.scotland), _tax(income, c.uk)) + + +def step_5_tax(c, x, s, v): + """Step 5 Income Tax on the share leaving ``x`` of the first component, + ``s`` of savings and ``v`` of dividends, as the model's + income_tax_pre_charges computes it from the variables the share sets.""" + if c.other_first: + other, property_left = c.other_left, x + else: + property_left = np.minimum(x, c.property) + other = x - property_left + earned = np.maximum(0, other - c.earned_exclusions) + earned_tax = _non_savings_tax(c, earned) + if c.other_first: + # property_income_tax.formula_2027: Scottish rates in Scotland, + # property rates on the UK bands elsewhere. + rates = c.property_rates + _, basic_upper, higher_upper = c.bands + in_basic = np.minimum(property_left, np.maximum(0, basic_upper - other)) + higher_space = np.maximum(0, higher_upper - np.maximum(other, basic_upper)) + in_higher = np.minimum(np.maximum(0, property_left - in_basic), higher_space) + in_additional = np.maximum(0, property_left - in_basic - in_higher) + ruk = ( + rates.basic * in_basic + + rates.higher * in_higher + + rates.additional * in_additional + ) + scottish = _tax(other + property_left, c.scotland) - _tax(other, c.scotland) + property_tax = np.where(c.scottish, scottish, ruk) + else: + property_tax = _non_savings_tax(c, other + property_left) - ( + _non_savings_tax(c, other) + ) + # Savings and dividends sit on non_savings_taxable_income. + below = earned + property_left + low, basic_limit, higher_limit = c.bands + top = below + s + in_basic_band = np.clip(top, low, basic_limit) - np.clip(below, low, basic_limit) + starting = np.clip( + np.minimum(in_basic_band, s), + 0, + np.maximum(0, c.starting_limit - np.maximum(0, below * c.phase_out)), + ) + start = below + np.minimum(s, starting + c.savings_allowance) + rates = c.savings_rates + savings_tax = ( + rates.basic + * np.maximum( + 0, + np.clip(top, low, basic_limit) - np.clip(start, low, basic_limit), + ) + + rates.higher + * np.maximum( + 0, + np.clip(top, basic_limit, higher_limit) + - np.clip(start, basic_limit, higher_limit), + ) + + rates.additional + * np.maximum(0, np.maximum(top, higher_limit) - np.maximum(start, higher_limit)) + ) + nil_rate = np.minimum(c.dividend_allowance, v) + dividend_tax = np.maximum( + 0, + _tax(top + v, c.dividend_scale) - _tax(top + nil_rate, c.dividend_scale), + ) + return earned_tax + property_tax + savings_tax + dividend_tax + + +def _candidates(c): + """The points where two of the lines the module docstring lists cross.""" + zero = np.zeros_like(c.savings) + offset = c.offset + low, basic_limit, higher_limit = c.bands + allowance = c.savings_allowance + if c.other_first: + first_limits = [ + np.where(c.scottish, scottish, ruk) - c.other_left + for scottish, ruk in _paired_limits( + c.scotland[0], [low, basic_limit, higher_limit], zero + ) + ] + else: + first_limits = [ + np.where(c.scottish, scottish, ruk) + for scottish, ruk in _paired_limits(c.scotland[0], c.uk[0], zero) + ] + vertical = [zero, c.first, c.starting_limit - offset + zero] + vertical += [limit - allowance - offset for limit in (basic_limit, higher_limit)] + vertical += first_limits + total = c.step_3_income + horizontal = [ + total - c.dividends, + total, + total - c.dividend_allowance, + c.starting_limit + allowance - offset, + basic_limit - offset, + higher_limit - offset, + ] + horizontal += [ + limit - c.dividend_allowance - offset + zero for limit in c.dividend_scale[0] + ] + diagonal = [zero, c.savings, allowance] + xs = np.concatenate([np.broadcast_to(v, zero.shape) for v in vertical], axis=1) + ws = np.concatenate([np.broadcast_to(h, zero.shape) for h in horizontal], axis=1) + ds = np.concatenate([np.broadcast_to(d, zero.shape) for d in diagonal], axis=1) + n = len(zero) + x = np.concatenate( + [ + np.repeat(xs, ws.shape[1], axis=1), + np.repeat(xs, ds.shape[1], axis=1), + (ws[:, :, None] - ds[:, None, :]).reshape(n, -1), + ], + axis=1, + ) + w = np.concatenate( + [ + np.tile(ws, xs.shape[1]), + (xs[:, :, None] + ds[:, None, :]).reshape(n, -1), + np.repeat(ws, ds.shape[1], axis=1), + ], + axis=1, + ) + return x, w + + +def _paired_limits(scottish, other, zero): + """Pair two lists of limits by position, padding the shorter with +inf.""" + length = max(len(scottish), len(other)) + pad = zero + np.inf + return [ + ( + scottish[i] + zero if i < len(scottish) else pad, + other[i] + zero if i < len(other) else pad, + ) + for i in range(length) + ] + + +def _shares(c, x, w): + """Project candidate points onto the feasible polygon and split them into + the first component, savings and dividends left.""" + total = c.step_3_income + x = np.clip( + x, np.maximum(0, total - c.savings - c.dividends), np.minimum(c.first, total) + ) + w = np.clip(w, np.maximum(x, total - c.dividends), np.minimum(x + c.savings, total)) + return x, w - x, total - w + + +def _least_tax_share(c): + """The candidate share with the least Step 5 tax, per person. Of shares + within TIE of it, the one setting most against the first component, then + most against savings: the nearest the least tax allows to the order + non-savings, savings, dividends.""" + x, w = _candidates(c) + n, count = x.shape + tax = np.empty((n, count)) + width = max(1, CHUNK // max(n, 1)) + for start in range(0, count, width): + part = slice(start, start + width) + x[:, part], w[:, part], _ = _shares(c, x[:, part], w[:, part]) + tax[:, part] = step_5_tax( + c, x[:, part], w[:, part], c.step_3_income - x[:, part] - w[:, part] + ) + near = tax <= tax.min(axis=1, keepdims=True) + TIE + x_near = np.where(near, x, np.inf) + near &= x_near <= x_near.min(axis=1, keepdims=True) + TIE + index = np.argmin(np.where(near, w, np.inf), axis=1)[:, None] + x, s = (np.take_along_axis(part, index, axis=1) for part in (x, w)) + return x, s, c.step_3_income - x - s + + +def default_order_deductions(person, period): + """The deductions from non-savings, savings and dividend income in the + order non-savings, savings, dividends, computed as the model always has.""" + allowances = person("allowances", period) + savings = person("taxable_savings_interest_income", period) + dividends = person("taxable_dividend_income", period) + non_savings = person("net_income", period) - savings - dividends + relief = person("trade_loss_relief_against_general_income", period) + relief_left = np.maximum( + 0, + relief + - np.maximum( + 0, + person("net_income_before_trade_loss_relief", period) - savings - dividends, + ), + ) + to_non_savings = np.where( + non_savings < allowances, np.maximum(0, non_savings), allowances + ) + left = allowances + relief_left - to_non_savings + to_savings = np.where(savings < left, np.maximum(0, savings), np.maximum(0, left)) + left = left - to_savings + to_dividends = np.where( + dividends < left, np.maximum(0, dividends), np.maximum(0, left) + ) + return SimpleNamespace( + earned=to_non_savings, + savings=to_savings, + dividends=to_dividends, + relief_against_savings_and_dividends=relief_left, + ) + + +def allocate_deductions(person, period, parameters): + """Deductions against savings and dividend income in the share that gives + the least Income Tax (ITA 2007 s. 25(2) and (3A)). Where no share gives less + tax than the order non-savings, savings, dividends, these are that order's + deductions exactly.""" + default = default_order_deductions(person, period) + c = _context(person, period, parameters) + x, s, v = _least_tax_share(c) + default_s = c.savings - np.asarray(default.savings, dtype=np.float64)[:, None] + default_v = c.dividends - np.asarray(default.dividends, dtype=np.float64)[:, None] + default_x = c.step_3_income - default_s - default_v + lower = ( + step_5_tax(c, x, s, v) + < step_5_tax(c, default_x, default_s, default_v) - TOLERANCE + )[:, 0] + return SimpleNamespace( + savings=np.where(lower, (c.savings - s)[:, 0], default.savings), + dividends=np.where(lower, (c.dividends - v)[:, 0], default.dividends), + ) + + +def non_savings_deductions(person, period): + """Allowances against non-savings income beyond trade loss relief, and the + relief set against savings and dividends, once received_allowances_savings_income + and received_allowances_dividend_income have taken their share. + + The relief comes off non-savings income first, as far as the share sets + anything against it, then the allowances. Where the share is the order + non-savings, savings, dividends, these are that order's amounts exactly. + """ + default = default_order_deductions(person, period) + to_savings = person("received_allowances_savings_income", period) + to_dividends = person("received_allowances_dividend_income", period) + moved = (to_savings != default.savings) | (to_dividends != default.dividends) + savings = np.maximum(0, person("taxable_savings_interest_income", period)) + dividends = np.maximum(0, person("taxable_dividend_income", period)) + non_savings = np.maximum( + 0, + person("net_income_before_trade_loss_relief", period) - savings - dividends, + ) + relief = person("trade_loss_relief_against_general_income", period) + used = np.minimum( + relief.astype(np.float64) + person("allowances", period), + non_savings.astype(np.float64) + savings + dividends, + ) + to_non_savings = used - to_savings - to_dividends + return SimpleNamespace( + earned=np.where(moved, np.maximum(0, to_non_savings - relief), default.earned), + relief_against_savings_and_dividends=np.where( + moved, + np.maximum(0, relief - to_non_savings), + default.relief_against_savings_and_dividends, + ), + ) diff --git a/policyengine_uk/utils/uc_income_tax.py b/policyengine_uk/utils/uc_income_tax.py index d8cab4feb3..99aaf14990 100644 --- a/policyengine_uk/utils/uc_income_tax.py +++ b/policyengine_uk/utils/uc_income_tax.py @@ -58,8 +58,13 @@ def uc_income_tax_on_earnings_slice(person, period, parameters, include_trade=Tr ) # The income left in earned_taxable_income before allowances. The # part of it above earnings is the person's other non-savings income. - income_in_base = person("net_income", period) - add( - person, period, excluded_incomes + # Trade loss relief comes off that other income first; net_income + # deducts all of it, so add back the part set against savings and + # dividends, as earned_taxable_income does. + income_in_base = ( + person("net_income", period) + + person("trade_loss_relief_against_savings_and_dividends", period) + - add(person, period, excluded_incomes) ) other_income = max_(0, income_in_base - earnings) taxable_earnings = max_(0, person("earned_taxable_income", period) - other_income) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_dividend_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_dividend_income.py index 8503f249b8..16164c7531 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_dividend_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_dividend_income.py @@ -1,35 +1,23 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.income_tax_allocation import allocate_deductions class received_allowances_dividend_income(Variable): value_type = float entity = Person - label = "The portion of all allowances (excluding those for a particular income type) calculated last, applied to dividends." + label = "Allowances and reliefs deducted from dividend income" + documentation = ( + "The part of the allowances and trade loss relief against general " + "income that Income Tax deducts from dividend income, in the share " + "that gives the least tax (ITA 2007 s. 25(2) and, from 2027-28, " + "s. 25(3A)). See received_allowances_savings_income." + ) unit = GBP definition_period = YEAR - reference = "Income Tax Act 2007 s. 35" + reference = dict( + title="Income Tax Act 2007 s. 25", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/25", + ) def formula(person, period, parameters): - all_allowances = person("allowances", period) - received_allowances_earned_income = person( - "received_allowances_earned_income", period - ) - received_allowances_savings_income = person( - "received_allowances_savings_income", period - ) - # Includes any trade loss relief beyond non-savings and savings - # income (ITA 2007 s.25(2)). - remaining_allowance = ( - all_allowances - + person("trade_loss_relief_against_savings_and_dividends", period) - - received_allowances_earned_income - - received_allowances_savings_income - ) - - dividend_income = person("taxable_dividend_income", period) - - return where( - dividend_income < remaining_allowance, - max_(0, dividend_income), - max_(0, remaining_allowance), - ) + return allocate_deductions(person, period, parameters).dividends diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_earned_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_earned_income.py index 3103af399a..62bf03a92d 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_earned_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_earned_income.py @@ -1,27 +1,25 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.income_tax_allocation import non_savings_deductions class received_allowances_earned_income(Variable): value_type = float entity = Person - label = "The portion of all allowances (minus those only applicable to certain types of income) that is applied to earned income" + label = "Allowances deducted from non-savings income" + documentation = ( + "The part of the allowances that Income Tax deducts from non-savings, " + "non-dividend income, property income included. Trade loss relief " + "against general income comes off that income first, inside " + "net_income, so this is whatever the share in " + "received_allowances_savings_income sets against that income beyond " + "the relief." + ) unit = GBP definition_period = YEAR - reference = "Income Tax Act 2007 s. 35" + reference = dict( + title="Income Tax Act 2007 s. 25", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/25", + ) def formula(person, period, parameters): - # This portion of all allowances, applied first, excludes savings and dividends - - all_allowances = person("allowances", period) - - all_income = person("net_income", period) - excluded_income = person("taxable_savings_interest_income", period) + person( - "taxable_dividend_income", period - ) - earned_income = all_income - excluded_income - - return where( - earned_income < all_allowances, - max_(0, earned_income), - all_allowances, - ) + return non_savings_deductions(person, period).earned diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_savings_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_savings_income.py index f306c5649a..8d0326ef64 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_savings_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_savings_income.py @@ -1,31 +1,25 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.income_tax_allocation import allocate_deductions class received_allowances_savings_income(Variable): value_type = float entity = Person - label = "The portion of all allowances (minus those only applicable to one income type) calculated after earned taxable income, but before dividends. This is applied to savings interest income." + label = "Allowances and reliefs deducted from savings income" + documentation = ( + "The part of the allowances and trade loss relief against general " + "income that Income Tax deducts from savings income. ITA 2007 s. 25(2) " + "deducts reliefs and allowances in the way that gives the least tax, " + "and from 2027-28 s. 25(3A) deducts them from income other than " + "property, savings and dividend income first. " + "policyengine_uk.utils.income_tax_allocation finds that share." + ) unit = GBP definition_period = YEAR - reference = "Income Tax Act 2007 s. 35" + reference = dict( + title="Income Tax Act 2007 s. 25", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/25", + ) def formula(person, period, parameters): - all_allowances = person("allowances", period) - received_allowances_earned_income = person( - "received_allowances_earned_income", period - ) - # Trade loss relief beyond non-savings income comes off savings income - # next, then dividends in the model's fixed allowance order (#2106). - remaining_allowance = ( - all_allowances - + person("trade_loss_relief_against_savings_and_dividends", period) - - received_allowances_earned_income - ) - - savings_income = person("taxable_savings_interest_income", period) - - return where( - savings_income < remaining_allowance, - max_(0, savings_income), - max_(0, remaining_allowance), - ) + return allocate_deductions(person, period, parameters).savings diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/earned_taxable_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/earned_taxable_income.py index 5de4387597..40900b49b1 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/earned_taxable_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/earned_taxable_income.py @@ -16,6 +16,11 @@ def formula(person, period, parameters): exclusion_list = parameters( period ).gov.hmrc.income_tax.earned_taxable_income_exclusions - ani = person("net_income", period) + # net_income deducts all trade loss relief against general income. + # Add back the part set against savings and dividends, which the + # exclusions take off at their full amounts (ITA 2007 s. 25). + income = person("net_income", period) + person( + "trade_loss_relief_against_savings_and_dividends", period + ) exclusions = add(person, period, exclusion_list) - return max_(0, ani - exclusions) + return max_(0, income - exclusions) 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 index daff67c55d..232a6ff67e 100644 --- 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 @@ -8,17 +8,25 @@ class non_savings_income_after_allowances(Variable): documentation = ( "Net income (before the Gift Aid step of adjusted net income, which " "does not reduce the income charged to tax) 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 " + "income and the reliefs and allowances set against non-savings " + "income, floored at nil: the income below savings and dividends " + "(ITA 2007 s. 16) once Steps 2 and 3 are done. ITA 2007 s. 25(2) " + "decides how much of the reliefs and allowances comes off it, and " + "from 2027-28 s. 25(3A) takes them off the rest of it before property " + "income. 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", - ) + 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. 25", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/25", + ), + ] unit = GBP def formula(person, period, parameters): @@ -27,8 +35,11 @@ def formula(person, period, parameters): # Not adjusted_net_income: it deducts the grossed-up Gift Aid # gift (ITA 2007 s. 58), which raises the rate limits instead # (s. 414(2)(b)) and is no deduction from taxable income. + # net_income deducts all trade loss relief, so add back the part + # set against savings and dividends (s. 25). person("net_income", period) + + person("trade_loss_relief_against_savings_and_dividends", period) - person("taxable_savings_interest_income", period) - person("taxable_dividend_income", period) - - person("allowances", period), + - person("received_allowances_earned_income", period), ) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_savings_and_dividends.py b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_savings_and_dividends.py index 201cc012df..b9e147f958 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_savings_and_dividends.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_savings_and_dividends.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.income_tax_allocation import non_savings_deductions class trade_loss_relief_against_savings_and_dividends(Variable): @@ -6,15 +7,15 @@ class trade_loss_relief_against_savings_and_dividends(Variable): entity = Person label = "Trade loss relief deducted from savings and dividend income" documentation = ( - "The part of trade loss relief against general income left after " - "non-savings income is used up; it joins the allowances applied to " - "savings income and then to dividends. ITA 2007 s.25(2) deducts " - "reliefs and allowances in the order giving the greatest reduction in " - "liability, and from 2027-28 s.25(3A) puts income other than property, " - "savings and dividends first. The model uses its fixed allowance order " - "(non-savings income, property last, then savings, then dividends), " - "which can overcharge where savings are taxed at 0%; that is model-wide " - "and tracked in #2106." + "The part of trade loss relief against general income that Income Tax " + "deducts from savings and dividend income. ITA 2007 s. 25(2) deducts " + "reliefs and allowances in the way that gives the least tax, and from " + "2027-28 s. 25(3A) deducts them from income other than property, " + "savings and dividend income first; received_allowances_savings_income " + "and received_allowances_dividend_income hold that share. The relief " + "comes off non-savings income first, as far as the share sets anything " + "against that income, and this is the rest. net_income deducts the " + "whole relief, so the non-savings bases add this back." ) definition_period = YEAR unit = GBP @@ -24,10 +25,6 @@ class trade_loss_relief_against_savings_and_dividends(Variable): ) def formula(person, period, parameters): - non_savings_income = ( - person("net_income_before_trade_loss_relief", period) - - person("taxable_savings_interest_income", period) - - person("taxable_dividend_income", period) - ) - relief = person("trade_loss_relief_against_general_income", period) - return max_(0, relief - max_(0, non_savings_income)) + return non_savings_deductions( + person, period + ).relief_against_savings_and_dividends From 2f5b183ca8f0a3ef7f3bc1a2175ae24fdb25cacc Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 23:51:42 -0400 Subject: [PATCH 02/21] Follow net income in the allocation, test s. 25(3A) and update tests the least-tax share moves Base non-savings income on net_income plus trade loss relief, as the default order does, so a supplied adjusted net income or a reform that sets net income is followed. Add a 2027-28 case where s. 25(3A) keeps the allowance against pay. Update the gift aid and integration cases, and the gift aid snapshot, whose tax falls under s. 25(2), with the arithmetic. Co-Authored-By: Claude Opus 5.5 --- .../least_tax_allocation.yaml | 17 ++++++++ .../gov/hmrc/income_tax/gift_aid.yaml | 42 ++++++++++++------- policyengine_uk/tests/test_gift_aid.py | 30 ++++++++----- .../tests/test_income_tax_allocation.py | 4 +- .../utils/income_tax_allocation.py | 21 +++++----- 5 files changed, 77 insertions(+), 37 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml index dff0428056..94e5ccba29 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml @@ -201,3 +201,20 @@ received_allowances_earned_income: 12_570 property_income_tax: 4_183.2 income_tax: 4_183.2 + +- name: Case 11, from 2027-28 the allowance comes off pay before dividends + period: 2027 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 50_000 + dividend_income: 20_000 + output: + # s. 25(3A) deducts the allowance from income other than property, + # savings and dividend income first, so all £12,570 comes off the pay, + # unlike case 8. Pay 37,430 x 20% = 7,486. Dividends from £37,430: £500 + # at the nil rate and (57,430 - 37,930) x 35.75% = 6,971.25. + received_allowances_earned_income: 12_570 + received_allowances_dividend_income: 0 + dividend_income_tax: 6_971.25 + income_tax: 14_457.25 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/gift_aid.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/gift_aid.yaml index e6384e97ee..b9052b16d6 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/gift_aid.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/gift_aid.yaml @@ -92,11 +92,15 @@ dividend_income: [10_000, 10_000] gift_aid: [0, 800] output: - earned_taxable_income: [37_430, 37_430] - # The £500 dividend allowance still occupies the band. The extension - # moves £770 of taxed dividends from 35.75% to 10.75%: £192.50 relief. - dividend_income_tax: [3_396.25, 3_203.75] - income_tax: [10_882.25, 10_689.75] + # ITA 2007 s. 25(2): the Personal Allowance keeps the pay up to the basic + # rate limit, £37,700, or £38,700 with the £1,000 grossed-up gift, and the + # rest of it comes off dividends, so the £500 dividend allowance covers + # dividends that would otherwise be taxed at 35.75%. Dividends left: + # 9,730 and 8,730; less £500 at the nil rate, at 35.75%. The extension + # keeps £1,000 more pay at 20% in place of upper-rate dividends: £157.50. + earned_taxable_income: [37_700, 38_700] + dividend_income_tax: [3_299.725, 2_942.225] + income_tax: [10_839.725, 10_682.225] - name: Gift Aid extends UK limits across savings and dividend slices period: 2026 @@ -109,12 +113,18 @@ dividend_income: [5_000, 5_000] gift_aid: [0, 1_600] output: - # £2,000 gross extension moves £800 taxed savings down 20 percentage - # points and £700 taxed dividends down 25 percentage points. + # ITA 2007 s. 25(2): the Personal Allowance keeps the pay up to the basic + # rate limit, £37,700, or £39,700 with the £2,000 grossed-up gift, and + # the rest comes off savings above it, taxed at 40%, keeping the £500 + # savings allowance in use. Without the gift: £800 of savings, £500 at + # the nil rate and £300 at 40% = 120; all £5,000 of dividends, £500 at the + # nil rate and £4,500 at 35.75% = 1,608.75; pay 7,540. With it: £500 of + # savings at the nil rate; £3,300 of dividends, (3,300 - 500) x 35.75% = + # 1,001; pay 39,700 x 20% = 7,940. savings_allowance: [500, 500] - savings_income_tax: [460, 300] - dividend_income_tax: [1_608.75, 1_433.75] - income_tax: [9_368.75, 9_033.75] + savings_income_tax: [120, 0] + dividend_income_tax: [1_608.75, 1_001] + income_tax: [9_268.75, 8_941] - name: Gift Aid extends UK savings and dividend limits for Scottish taxpayers period: 2026 @@ -461,8 +471,12 @@ members: [control] region: NORTH_WEST output: - # Both still have higher-rate income, so a £500 savings allowance. Without - # the gift, £9,770 at 20% and £9,730 at 40%; the gift moves £1,000 from - # 40% to 20%: £200. + # Both still have higher-rate income, so a £500 savings allowance. ITA + # 2007 s. 25(2): the Personal Allowance keeps the property income up to + # the basic rate limit, £38,700 with the £1,000 grossed-up gift and + # £37,700 without, and the rest comes off savings, so the savings + # allowance covers savings taxed at 40% rather than 20%. Savings left: + # 8,730 and 9,730, less £500 at the nil rate, at 40%. The gift moves + # £1,000 of income from 40% to 20%: £200. savings_allowance: [500, 500] - savings_income_tax: [5_646, 5_846] + savings_income_tax: [3_292, 3_692] diff --git a/policyengine_uk/tests/test_gift_aid.py b/policyengine_uk/tests/test_gift_aid.py index 34d7bb182c..1b74ef4d08 100644 --- a/policyengine_uk/tests/test_gift_aid.py +++ b/policyengine_uk/tests/test_gift_aid.py @@ -299,23 +299,33 @@ def test_zero_gift_preserves_baseline_outputs_bit_for_bit(): "personal_allowance": [12_570, 12_570, 0, 12_570, 12_570, 12_070] * 2, "allowances": [12_570, 12_570, 0, 12_570, 12_570, 12_070] * 2, "unused_personal_allowance": [0, 0, 0, 0, 0, 0] * 2, - "earned_taxable_income": [17_430, 47_430, 150_000, 17_430, 0, 88_930] * 2, - "received_allowances_earned_income": [12_570, 12_570, 0, 12_570, 10_000, 12_070] - * 2, - "received_allowances_savings_income": [0, 0, 0, 0, 2570, 0] * 2, + # The fourth row outside Scotland sets £1,300 of the Personal + # Allowance against pay and £11,270 against savings (ITA 2007 + # s. 25(2), #2106): pay and property reach the £37,700 basic rate + # limit, and the £500 savings allowance covers savings that would be + # taxed at 40%, so its tax falls by £100. In Scotland the pay above + # £31,092 would be taxed at 42%, so the order is unchanged. + "earned_taxable_income": [17_430, 47_430, 150_000, 28_700, 0, 88_930] + + [17_430, 47_430, 150_000, 17_430, 0, 88_930], + "received_allowances_earned_income": [12_570, 12_570, 0, 1_300, 10_000, 12_070] + + [12_570, 12_570, 0, 12_570, 10_000, 12_070], + "received_allowances_savings_income": [0, 0, 0, 11_270, 2570, 0] + + [0, 0, 0, 0, 2570, 0], "received_allowances_dividend_income": [0, 0, 0, 0, 0, 0] * 2, - "basic_rate_earned_income": [17_430, 37_700, 37_700, 17_430, 0, 37_700] * 2, + "basic_rate_earned_income": [17_430, 37_700, 37_700, 28_700, 0, 37_700] + + [17_430, 37_700, 37_700, 17_430, 0, 37_700], "higher_rate_earned_income": [0, 9730, 87_440, 0, 0, 51_230] * 2, "add_rate_earned_income": [0, 0, 24_860, 0, 0, 0] * 2, - "basic_rate_savings_income": [0, 0, 0, 10_770, 11_430, 0] * 2, - "higher_rate_savings_income": [0, 0, 0, 730, 0, 0] * 2, + "basic_rate_savings_income": [0, 0, 0, 0, 11_430, 0] + + [0, 0, 0, 10_770, 11_430, 0], + "higher_rate_savings_income": [0, 0, 0, 230, 0, 0] + [0, 0, 0, 730, 0, 0], "add_rate_savings_income": [0, 0, 0, 0, 0, 0] * 2, "savings_allowance": [1000, 500, 0, 500, 1000, 500] * 2, "earned_income_tax": [ 3486, 11_432, 53_703, - 3486, + 5740, 0, 28_032, 3451.070068359375, @@ -325,14 +335,14 @@ def test_zero_gift_preserves_baseline_outputs_bit_for_bit(): 0, 31407.05078125, ], - "savings_income_tax": [0, 0, 0, 2446, 2286, 0] * 2, + "savings_income_tax": [0, 0, 0, 92, 2286, 0] + [0, 0, 0, 2446, 2286, 0], "dividend_income_tax": [0, 0, 0, 6256.25, 1021.25, 0] * 2, "property_income_tax": [0, 0, 0, 1800, 0, 0, 0, 0, 0, 1890, 0, 0], "income_tax": [ 3486, 11_432, 53_703, - 13988.25, + 13888.25, 3307.25, 28_032, 3451.070068359375, diff --git a/policyengine_uk/tests/test_income_tax_allocation.py b/policyengine_uk/tests/test_income_tax_allocation.py index 2a462513e0..21923ce248 100644 --- a/policyengine_uk/tests/test_income_tax_allocation.py +++ b/policyengine_uk/tests/test_income_tax_allocation.py @@ -37,7 +37,7 @@ pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") YEARS = (2026, 2027) -PEOPLE = 12 +PEOPLE = 8 REGIONS = ["LONDON", "SCOTLAND", "WALES", "NORTH_EAST"] INCOMES = ( "employment_income", @@ -121,7 +121,7 @@ people = st.lists(person, min_size=PEOPLE, max_size=PEOPLE) year = st.sampled_from(YEARS) SETTINGS = settings( - max_examples=20, + max_examples=8, deadline=None, derandomize=True, suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], diff --git a/policyengine_uk/utils/income_tax_allocation.py b/policyengine_uk/utils/income_tax_allocation.py index 055edd076f..61f3238422 100644 --- a/policyengine_uk/utils/income_tax_allocation.py +++ b/policyengine_uk/utils/income_tax_allocation.py @@ -113,17 +113,16 @@ def column(values): savings = np.maximum(0, column(person("taxable_savings_interest_income", period))) dividends = np.maximum(0, column(person("taxable_dividend_income", period))) + relief = column(person("trade_loss_relief_against_general_income", period)) + # net_income, not its components, so a supplied adjusted net income or a + # reform that sets net income is followed, as the default order does. non_savings = np.maximum( - 0, - column(person("net_income_before_trade_loss_relief", period)) - - savings - - dividends, + 0, column(person("net_income", period)) + relief - savings - dividends ) property_income = np.minimum( np.maximum(0, column(person("taxable_property_income", period))), non_savings ) - deductions = column(person("trade_loss_relief_against_general_income", period)) - deductions = deductions + column(person("allowances", period)) + deductions = relief + column(person("allowances", period)) used = np.minimum(deductions, non_savings + savings + dividends) exclusions = [ variable @@ -428,14 +427,14 @@ def non_savings_deductions(person, period): moved = (to_savings != default.savings) | (to_dividends != default.dividends) savings = np.maximum(0, person("taxable_savings_interest_income", period)) dividends = np.maximum(0, person("taxable_dividend_income", period)) + relief = person("trade_loss_relief_against_general_income", period).astype( + np.float64 + ) non_savings = np.maximum( - 0, - person("net_income_before_trade_loss_relief", period) - savings - dividends, + 0, person("net_income", period) + relief - savings - dividends ) - relief = person("trade_loss_relief_against_general_income", period) used = np.minimum( - relief.astype(np.float64) + person("allowances", period), - non_savings.astype(np.float64) + savings + dividends, + relief + person("allowances", period), non_savings + savings + dividends ) to_non_savings = used - to_savings - to_dividends return SimpleNamespace( From 998486f4dca07e9777ab10a417eeeef18da03aa2 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 00:28:45 -0400 Subject: [PATCH 03/21] Allow the allocation search's tie tolerance in the dense-grid property test Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/tests/test_income_tax_allocation.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/tests/test_income_tax_allocation.py b/policyengine_uk/tests/test_income_tax_allocation.py index 21923ce248..8da0f18a56 100644 --- a/policyengine_uk/tests/test_income_tax_allocation.py +++ b/policyengine_uk/tests/test_income_tax_allocation.py @@ -318,5 +318,7 @@ def test_vertex_search_equals_dense_grid(sims, year, drawn): grid_x, grid_s, grid_v = _shares(c, grid_x, grid_x + grid_s) grid = step_5_tax(c, grid_x, grid_s, grid_v).min(axis=1) step = ((high - low) / 100 + c.savings / 100)[:, 0] - assert np.all(found <= grid + 1e-6) - assert np.all(found >= grid - 2 * step - 1e-6) + # The search may take a share within TIE (1e-6) of the least, for its + # tie-break. + assert np.all(found <= grid + 1e-5) + assert np.all(found >= grid - 2 * step - 1e-5) From 60de3303b49d2c16b28303a92b1392f9c3e3fc87 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 01:43:00 -0400 Subject: [PATCH 04/21] Skip the least-tax search for people who cannot gain from another share The search now runs only on people that three tests leave: one share exists (nothing to share, or no income left after the deductions); the old order sets every shared deduction against the first component and leaves no savings or dividends charged above the nil rates; or the rate on the first component just above where the old order leaves it covers the highest savings and dividend rates the moved deductions can reach. Each test gives the result the search would, and a property test checks that on synthetic people, with rising and with falling rates. The search evaluates its candidates only on the people left. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_income_tax_allocation.py | 223 ++++++++++++ .../utils/income_tax_allocation.py | 333 ++++++++++++++---- 2 files changed, 494 insertions(+), 62 deletions(-) diff --git a/policyengine_uk/tests/test_income_tax_allocation.py b/policyengine_uk/tests/test_income_tax_allocation.py index 8da0f18a56..27118b5bc9 100644 --- a/policyengine_uk/tests/test_income_tax_allocation.py +++ b/policyengine_uk/tests/test_income_tax_allocation.py @@ -18,6 +18,9 @@ share (a differential test of two implementations of Step 5). 6. The vertex search finds the least value of that function, checked against a dense grid. +7. Fast path: for everyone the tests in _old_order_is_least let through, the + result equals the full search's, bit for bit, and in current law those + tests let through everyone with one kind of income. """ import numpy as np @@ -27,10 +30,14 @@ from policyengine_uk import Simulation from policyengine_uk.utils.income_tax_allocation import ( + _build, _context, _least_tax_share, + _old_order_is_least, _shares, default_order_deductions, + least_tax_deductions, + old_order, step_5_tax, ) @@ -322,3 +329,219 @@ def test_vertex_search_equals_dense_grid(sims, year, drawn): # tie-break. assert np.all(found <= grid + 1e-5) assert np.all(found >= grid - 2 * step - 1e-5) + + +# The fast path, checked on many synthetic people at once without a simulation: +# the inputs the search reads, drawn freely, and the old order computed from +# them as the model does, in float32. +SYNTHETIC_YEARS = tuple(range(2016, 2031)) +SYNTHETIC_PEOPLE = 2_000 +LIMITS = [500, 1_000, 5_000, 6_000, 12_570, 17_570, 37_700, 50_270, 100_000, 125_140] +# Curated people at the edges of each test: other non-savings income, property, +# savings, dividends, trade loss relief, allowances beyond the Personal +# Allowance, Marriage Allowance received, grossed-up Gift Aid, Scottish, and +# personal savings allowance. +EDGES = [ + # Pay and savings across the basic rate limit: the search moves £270. + (50_000, 0, 10_000, 0, 0, 0, 0, 0, False, 500), + # Pay and dividends across the basic rate limit. + (50_000, 0, 0, 20_000, 0, 0, 0, 0, False, 500), + # Pay that ends exactly at the basic rate limit, and a pound below it. + (50_270, 0, 10_000, 0, 0, 0, 0, 0, False, 500), + (50_269, 0, 10_000, 0, 0, 0, 0, 0, False, 500), + # Savings and dividends with no other income. + (0, 0, 4_000, 20_000, 0, 0, 0, 0, False, 1_000), + # Marriage Allowance moves the savings below the basic rate limit. + (51_000, 0, 10_000, 0, 0, 0, 1_260, 0, False, 500), + (49_000, 0, 0, 20_000, 0, 0, 1_260, 0, False, 500), + # Savings exactly at the savings allowance, dividends at the allowance. + (30_000, 0, 1_000, 500, 0, 0, 0, 0, False, 1_000), + (30_000, 0, 1_001, 501, 0, 0, 0, 0, False, 1_000), + # Deductions that just fill the first component. + (12_570, 0, 3_000, 0, 0, 0, 0, 0, False, 1_000), + (12_570, 0, 3_000, 2_000, 0, 0, 0, 0, False, 1_000), + # A pensioner whose savings fill the starting rate band. + (14_000, 0, 8_000, 0, 0, 0, 0, 0, False, 1_000), + # Scottish pay in the starter, basic and intermediate bands. + (14_000, 0, 7_000, 0, 0, 0, 0, 0, True, 1_000), + (25_000, 0, 2_000, 3_000, 0, 0, 0, 0, True, 1_000), + (40_000, 0, 0, 5_000, 0, 0, 0, 0, True, 500), + (43_000, 0, 6_000, 0, 0, 0, 0, 0, True, 500), + # Property with other income, savings and dividends. + (9_000, 30_000, 6_000, 15_000, 0, 0, 0, 0, False, 500), + (9_000, 30_000, 6_000, 15_000, 0, 0, 0, 0, True, 500), + (0, 20_000, 0, 200_000, 1_000, 0, 0, 0, False, 0), + (45_000, 20_000, 3_000, 0, 0, 0, 1_260, 0, False, 500), + # Gift Aid raises the limits. + (40_000, 0, 3_000, 8_000, 0, 0, 0, 2_000, False, 1_000), + (52_000, 0, 9_000, 0, 0, 0, 0, 2_000, False, 500), + # Pension relief and a trading loss. + (60_000, 0, 9_000, 4_000, 3_000, 10_000, 0, 0, False, 500), + (110_000, 0, 20_000, 30_000, 0, 0, 0, 0, False, 0), +] + + +@pytest.fixture(scope="module") +def system(): + import policyengine_uk.simulation as uk_simulation + + return uk_simulation.CountryTaxBenefitSystem() + + +def synthetic_people(seed, count=SYNTHETIC_PEOPLE, mix="free"): + """Inputs for ``least_tax_deductions`` and the old order's deductions. + + "free" draws every amount from a wide mixture with values at the limits; + "realistic" gives most people pay alone and some savings or dividends.""" + rng = np.random.default_rng(seed) + + def amount(zero, scale): + rest = 1 - zero + kind = rng.choice(4, count, p=[zero, 0.3 * rest, 0.4 * rest, 0.3 * rest]) + limits = rng.choice(LIMITS, count) + rng.uniform(-300, 300, count) + values = np.select( + [kind == 0, kind == 1, kind == 2], + [0, rng.uniform(0, 2_000, count), rng.uniform(0, scale, count)], + limits, + ) + return np.maximum(0, values) + + if mix == "free": + other = amount(0.2, 200_000) + property_income = amount(0.6, 60_000) + savings = amount(0.4, 150_000) + dividends = amount(0.5, 250_000) + else: + other = rng.lognormal(10.2, 0.6, count) + property_income = np.where( + rng.random(count) < 0.05, rng.uniform(0, 20_000, count), 0 + ) + savings = np.where(rng.random(count) < 0.25, rng.lognormal(5, 1.5, count), 0) + dividends = np.where(rng.random(count) < 0.08, rng.lognormal(7, 1.5, count), 0) + income = other + property_income + savings + dividends + relief = np.where( + rng.random(count) < 0.1, np.minimum(rng.uniform(0, 30_000, count), income), 0 + ) + extra = np.where(rng.random(count) < 0.2, rng.uniform(0, 15_000, count), 0) + married = rng.random(count) < 0.1 + gift = np.where(rng.random(count) < 0.1, rng.uniform(0, 10_000, count), 0) + scottish = rng.random(count) < 0.3 + savings_allowance = rng.choice([0.0, 500.0, 1_000.0], count) + rows = np.array(EDGES, dtype=float) if mix == "free" else np.empty((0, 10)) + other, property_income, savings, dividends, relief, extra, marriage, gift = ( + np.concatenate([column, rows[:, i]]) + for i, column in enumerate( + [ + other, + property_income, + savings, + dividends, + relief, + extra, + married * 1_260.0, + gift, + ] + ) + ) + scottish = np.concatenate([scottish, rows[:, 8] > 0]) + savings_allowance = np.concatenate([savings_allowance, rows[:, 9]]) + f32 = np.float32 + before_relief = (other + property_income + savings + dividends).astype(f32) + relief = relief.astype(f32) + net_income = np.maximum(0, before_relief - relief) + personal_allowance = np.maximum(0, 12_570 - np.maximum(0, net_income - 100_000) / 2) + allowances = (personal_allowance + extra).astype(f32) + savings, dividends = savings.astype(f32), dividends.astype(f32) + default = old_order( + allowances, savings, dividends, net_income, relief, before_relief + ) + inputs = dict( + savings=savings, + dividends=dividends, + relief=relief, + net_income=net_income, + property=property_income.astype(f32), + allowances=allowances, + earned_exclusions=marriage.astype(f32), + gift=gift.astype(f32), + scottish=scottish, + savings_allowance=savings_allowance.astype(f32), + ) + inputs = { + name: values if name == "scottish" else values.astype(np.float64) + for name, values in inputs.items() + } + return inputs, default + + +def assert_fast_path_equals_search(income_tax, inputs, default): + fast = least_tax_deductions(inputs, income_tax, default.savings, default.dividends) + full = least_tax_deductions( + inputs, income_tax, default.savings, default.dividends, search_all=True + ) + for fast_part, full_part in zip(fast, full): + mismatch = np.flatnonzero(fast_part != full_part) + assert not len(mismatch), { + name: values[mismatch[:5]] for name, values in inputs.items() + } + + +@settings( + max_examples=30, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) +@given(st.sampled_from(SYNTHETIC_YEARS), st.integers(0, 2**32 - 1)) +@example(2026, 0) +@example(2027, 0) +@example(2028, 1) +def test_fast_path_equals_full_search(system, year, seed): + income_tax = system.parameters(str(year)).gov.hmrc.income_tax + inputs, default = synthetic_people(seed) + # Invariant 7: the people the tests let through get the search's result. + assert_fast_path_equals_search(income_tax, inputs, default) + + +@pytest.mark.parametrize("year", [2026, 2027]) +def test_fast_path_lets_most_realistic_people_through(system, year): + income_tax = system.parameters(str(year)).gov.hmrc.income_tax + inputs, default = synthetic_people(2106, count=20_000, mix="realistic") + c = _build(inputs, income_tax) + through = _old_order_is_least( + c, default.savings[:, None], default.dividends[:, None] + )[:, 0] + # With one kind of income nothing can move (invariant 4), so all of + # those go through; and few of the rest need the search. + one_kind = (inputs["savings"] == 0) & (inputs["dividends"] == 0) + assert through[one_kind].all() + assert (~through).mean() < 0.1, (~through).mean() + + +# Rates that fall with income break the arguments of tests 2 and 3, so the +# tests must then send everyone with something to share to the search. +FALLING_RATES = { + "gov.hmrc.income_tax.rates.scotland.rates[3].rate": 0.05, + "gov.hmrc.income_tax.rates.uk[1].rate": 0.1, + "gov.hmrc.income_tax.rates.dividends[1].rate": 0.05, +} + + +@pytest.mark.parametrize("year", [2026, 2027]) +def test_fast_path_equals_full_search_when_rates_fall(system, year): + parameters = system.parameters + old = {} + try: + for path, value in FALLING_RATES.items(): + parameter = parameters.get_child(path) + old[path] = parameter(str(year)) + parameter.update(period=str(year), value=value) + system.reset_parameter_caches() + income_tax = parameters(str(year)).gov.hmrc.income_tax + for seed in range(3): + inputs, default = synthetic_people(seed) + assert_fast_path_equals_search(income_tax, inputs, default) + finally: + for path, value in old.items(): + parameters.get_child(path).update(period=str(year), value=value) + system.reset_parameter_caches() diff --git a/policyengine_uk/utils/income_tax_allocation.py b/policyengine_uk/utils/income_tax_allocation.py index 61f3238422..bdf23a1d57 100644 --- a/policyengine_uk/utils/income_tax_allocation.py +++ b/policyengine_uk/utils/income_tax_allocation.py @@ -60,6 +60,42 @@ every candidate with the full tax, so with Marriage Allowance it takes the best of them. In every case it keeps the order non-savings, savings, dividends unless another share gives less tax, so it never charges more than that order. + +Who cannot gain +--------------- +Most people cannot gain from another share, and ``_old_order_is_least`` finds +them without a search, so the search runs only on the rest. + +1. One share: with no deduction left for s. 25(2) to place, or no income left + after the deductions, only one share exists, and the old order takes it. + +Tests 2 and 3 need the rates to rise with income in every scale, as Parliament +has set them, and the old order to set every shared deduction against the +first component, so that any other share leaves more of it (``x`` rises). + +2. Nothing taxed above the first component: if the old order leaves no savings + and no dividends charged above the nil rates, any other share charges them + at least nil, and tax on the first component rises with ``x``. So no share + gives less tax. +3. Rates: moving a pound of the deductions to savings or dividends raises + ``x`` by that pound and ``w`` by at most a pound. Write ``rho_n`` for the + rate on the first component just above where the old order leaves it, + ``rho_s`` for the savings rate just above the top of the savings, and + ``sigma_s`` and ``sigma_v`` for the highest savings and dividend rates the + moved pounds can reach above the nil rates. The tax then rises by at least + ``(rho_n - sigma_s) dx + (rho_s - sigma_v) dw`` for any move with + ``0 <= dw <= dx``, or ``rho_n dx - sigma_v dw`` where the old order charges + no savings above the nil rates. Where that is never negative, the old order + gives the least tax. The top of the nil rates moves by at most ``dx``, + because the starting rate band shrinks by at most a pound for each pound of + non-savings income (s. 12) and the savings and dividend nil rates cover + fixed amounts (ss. 12A and 13A). Before 2027-28 the test also needs no + Marriage Allowance or no property income: with property income, the + model's Marriage Allowance breaks the rising rate on the first component. + +Where any test holds, the search would keep the old order too, since it +changes the share only for a saving above ``TOLERANCE``. So the result is the +same, and ``test_income_tax_allocation.py`` checks that it is. """ from types import SimpleNamespace @@ -103,39 +139,57 @@ def _tax(income, schedule): return sum(step * np.maximum(income - t, 0) for t, step in zip(thresholds, steps)) -def _context(person, period, parameters): - """Everything the tax on a share depends on, as float64 columns.""" - income_tax = parameters(period).gov.hmrc.income_tax - rates = income_tax.rates +def _inputs(person, period, income_tax): + """The amounts the tax on a share depends on, one float64 value a person.""" - def column(values): - return np.asarray(values, dtype=np.float64)[:, None] + def values(variable): + return np.asarray(person(variable, period), dtype=np.float64) - savings = np.maximum(0, column(person("taxable_savings_interest_income", period))) - dividends = np.maximum(0, column(person("taxable_dividend_income", period))) - relief = column(person("trade_loss_relief_against_general_income", period)) - # net_income, not its components, so a supplied adjusted net income or a - # reform that sets net income is followed, as the default order does. - non_savings = np.maximum( - 0, column(person("net_income", period)) + relief - savings - dividends - ) - property_income = np.minimum( - np.maximum(0, column(person("taxable_property_income", period))), non_savings - ) - deductions = relief + column(person("allowances", period)) - used = np.minimum(deductions, non_savings + savings + dividends) exclusions = [ variable for variable in income_tax.earned_taxable_income_exclusions if variable not in ALLOCATED_EXCLUSIONS ] - earned_exclusions = sum( - (column(person(variable, period)) for variable in exclusions), - np.zeros_like(savings), + savings = values("taxable_savings_interest_income") + return dict( + savings=savings, + dividends=values("taxable_dividend_income"), + relief=values("trade_loss_relief_against_general_income"), + net_income=values("net_income"), + property=values("taxable_property_income"), + allowances=values("allowances"), + earned_exclusions=sum( + (values(variable) for variable in exclusions), np.zeros_like(savings) + ), + gift=values("gift_aid_grossed_up"), + scottish=np.asarray(person("pays_scottish_income_tax", period)), + savings_allowance=values("savings_allowance"), ) - gift = column(person("gift_aid_grossed_up", period)) - scottish = np.asarray(person("pays_scottish_income_tax", period))[:, None] + + +def _context(person, period, parameters): + """Everything the tax on a share depends on, as float64 columns.""" + income_tax = parameters(period).gov.hmrc.income_tax + return _build(_inputs(person, period, income_tax), income_tax) + + +def _build(inputs, income_tax): + """The context for the people in ``inputs`` (see ``_inputs``).""" + rates = income_tax.rates + columns = {name: values[:, None] for name, values in inputs.items()} + savings = np.maximum(0, columns["savings"]) + dividends = np.maximum(0, columns["dividends"]) + relief = columns["relief"] + # net_income, not its components, so a supplied adjusted net income or a + # reform that sets net income is followed, as the default order does. + non_savings = np.maximum(0, columns["net_income"] + relief - savings - dividends) + property_income = np.minimum(np.maximum(0, columns["property"]), non_savings) + deductions = relief + columns["allowances"] + used = np.minimum(deductions, non_savings + savings + dividends) + gift = columns["gift"] uk_thresholds = rates.uk.thresholds + savings_rates = rates.savings + property_rates = rates.property c = SimpleNamespace( other_first=bool(income_tax.reliefs.other_income_first), savings=savings, @@ -143,27 +197,40 @@ def column(values): non_savings=non_savings, property=property_income, used=used, - earned_exclusions=earned_exclusions, + earned_exclusions=columns["earned_exclusions"], gift=gift, - scottish=scottish, + scottish=columns["scottish"], uk=_schedule(rates.uk, gift), + uk_rates=list(rates.uk.rates), scotland=_schedule( rates.scotland.rates, gift, first_extended_scottish_threshold(rates.scotland.rates), len(rates.scotland.rates.thresholds), ), + scotland_rates=list(rates.scotland.rates.rates), dividend_scale=_schedule(rates.dividends, gift), + dividend_rates=list(rates.dividends.rates), bands=( uk_thresholds[0], uk_thresholds[1] + gift, uk_thresholds[2] + gift, ), - savings_rates=rates.savings, - property_rates=rates.property, + savings_rates=savings_rates, + savings_rate_list=[ + savings_rates.basic, + savings_rates.higher, + savings_rates.additional, + ], + property_rates=property_rates, + property_rate_list=[ + property_rates.basic, + property_rates.higher, + property_rates.additional, + ], starting_limit=rates.savings_starter_rate.allowance, phase_out=rates.savings_starter_rate.phase_out, - savings_allowance=column(person("savings_allowance", period)), + savings_allowance=columns["savings_allowance"], dividend_allowance=income_tax.allowances.dividend_allowance, ) if c.other_first: @@ -175,7 +242,7 @@ def column(values): c.shared = used - to_other # The savings stack on the other non-savings income left, as earned # taxable income has it, plus the property income left. - c.offset = np.maximum(0, c.other_left - earned_exclusions) + c.offset = np.maximum(0, c.other_left - c.earned_exclusions) else: c.first = non_savings c.shared = used @@ -188,16 +255,38 @@ def _non_savings_tax(c, income): return np.where(c.scottish, _tax(income, c.scotland), _tax(income, c.uk)) -def step_5_tax(c, x, s, v): - """Step 5 Income Tax on the share leaving ``x`` of the first component, - ``s`` of savings and ``v`` of dividends, as the model's - income_tax_pre_charges computes it from the variables the share sets.""" +def _split(c, x): + """The other non-savings income and the property income left, and the + earned taxable income, where the share leaves ``x`` of the first + component.""" if c.other_first: other, property_left = c.other_left, x else: property_left = np.minimum(x, c.property) other = x - property_left - earned = np.maximum(0, other - c.earned_exclusions) + return other, property_left, np.maximum(0, other - c.earned_exclusions) + + +def _savings_band(c, below, s): + """The top of the savings and the top of the part of them that the + starting rate (s. 12) and the savings nil rate (s. 12A) cover, for ``s`` + of savings above ``below`` of non-savings income.""" + low, basic_limit, _ = c.bands + top = below + s + in_basic_band = np.clip(top, low, basic_limit) - np.clip(below, low, basic_limit) + starting = np.clip( + np.minimum(in_basic_band, s), + 0, + np.maximum(0, c.starting_limit - np.maximum(0, below * c.phase_out)), + ) + return top, below + np.minimum(s, starting + c.savings_allowance) + + +def step_5_tax(c, x, s, v): + """Step 5 Income Tax on the share leaving ``x`` of the first component, + ``s`` of savings and ``v`` of dividends, as the model's + income_tax_pre_charges computes it from the variables the share sets.""" + other, property_left, earned = _split(c, x) earned_tax = _non_savings_tax(c, earned) if c.other_first: # property_income_tax.formula_2027: Scottish rates in Scotland, @@ -220,16 +309,8 @@ def step_5_tax(c, x, s, v): _non_savings_tax(c, other) ) # Savings and dividends sit on non_savings_taxable_income. - below = earned + property_left + top, start = _savings_band(c, earned + property_left, s) low, basic_limit, higher_limit = c.bands - top = below + s - in_basic_band = np.clip(top, low, basic_limit) - np.clip(below, low, basic_limit) - starting = np.clip( - np.minimum(in_basic_band, s), - 0, - np.maximum(0, c.starting_limit - np.maximum(0, below * c.phase_out)), - ) - start = below + np.minimum(s, starting + c.savings_allowance) rates = c.savings_rates savings_tax = ( rates.basic @@ -361,18 +442,23 @@ def _least_tax_share(c): def default_order_deductions(person, period): """The deductions from non-savings, savings and dividend income in the order non-savings, savings, dividends, computed as the model always has.""" - allowances = person("allowances", period) - savings = person("taxable_savings_interest_income", period) - dividends = person("taxable_dividend_income", period) - non_savings = person("net_income", period) - savings - dividends - relief = person("trade_loss_relief_against_general_income", period) + return old_order( + person("allowances", period), + person("taxable_savings_interest_income", period), + person("taxable_dividend_income", period), + person("net_income", period), + person("trade_loss_relief_against_general_income", period), + person("net_income_before_trade_loss_relief", period), + ) + + +def old_order( + allowances, savings, dividends, net_income, relief, net_income_before_relief +): + """``default_order_deductions`` from the amounts it reads.""" + non_savings = net_income - savings - dividends relief_left = np.maximum( - 0, - relief - - np.maximum( - 0, - person("net_income_before_trade_loss_relief", period) - savings - dividends, - ), + 0, relief - np.maximum(0, net_income_before_relief - savings - dividends) ) to_non_savings = np.where( non_savings < allowances, np.maximum(0, non_savings), allowances @@ -391,25 +477,148 @@ def default_order_deductions(person, period): ) +def _rate(thresholds, rates, income, above=True): + """The rate a scale charges just above ``income`` (or just below it).""" + rate = np.zeros(np.shape(income)) + for threshold, value in zip(thresholds, rates): + rate = np.where( + income >= threshold if above else income > threshold, value, rate + ) + return rate + + +def _rising(rates): + return rates[0] >= 0 and all(b >= a for a, b in zip(rates, rates[1:])) + + +def _old_order_is_least(c, to_savings, to_dividends): + """People for whom no share gives less Step 5 tax than the old order, by + the tests the module docstring sets out. ``to_savings`` and + ``to_dividends`` are the old order's deductions from savings and + dividends.""" + in_first = (to_savings == 0) & (to_dividends == 0) + # Test 1: nothing to share, or nothing left after the deductions, so only + # one share exists, and the old order takes it. + nothing_to_share = (in_first & (c.shared <= 0)) | ( + (to_savings == c.savings) + & (to_dividends == c.dividends) + & (c.step_3_income <= TIE) + ) + low, basic_limit, higher_limit = c.bands + if not ( + all( + _rising(rates) + for rates in ( + c.uk_rates, + c.scotland_rates, + c.dividend_rates, + c.savings_rate_list, + c.property_rate_list, + ) + ) + and low <= 0 + and c.phase_out >= 0 + ): + return nothing_to_share + in_first &= c.shared <= c.first + x = c.first - c.shared + other, property_left, earned = _split(c, x) + top, start = _savings_band(c, earned + property_left, c.savings) + savings_taxed = top > start + nil_rated = ~savings_taxed & (c.dividends <= c.dividend_allowance) + # Test 3. The first component's rate just above x. + if c.other_first: + _, basic_upper, higher_upper = c.bands + rho_n = np.where( + c.scottish, + _rate(c.scotland[0], c.scotland_rates, other + x), + _rate( + (-np.inf, basic_upper, higher_upper), c.property_rate_list, other + x + ), + ) + in_reach = in_first + else: + # With no property income, Marriage Allowance only moves every band + # position down by its amount. + position = x - c.earned_exclusions + rho_n = np.where( + c.scottish, + _rate(c.scotland[0], c.scotland_rates, position), + _rate(c.uk[0], c.uk_rates, position), + ) + in_reach = in_first & ((c.earned_exclusions == 0) | (c.property == 0)) + savings_scale = ((low, basic_limit, higher_limit), c.savings_rate_list) + # dx <= c.shared and dw <= min(dx, dividends). The top of the nil rates + # moves up by at most dx and never passes the top of the savings. + dw = np.minimum(c.shared, c.dividends) + sigma_s = _rate(*savings_scale, np.minimum(start + c.shared, top + dw), above=False) + rho_s = _rate(*savings_scale, top) + # The dividends end at the top of income, which no share moves. + sigma_v = np.where( + c.dividends > c.dividend_allowance, + _rate( + c.dividend_scale[0], + c.dividend_rates, + top + np.minimum(c.dividend_allowance + dw, c.dividends), + above=False, + ), + 0, + ) + rates_hold = np.where( + savings_taxed, + (rho_n >= sigma_s) & (rho_n - sigma_s + rho_s - sigma_v >= 0), + rho_n >= sigma_v, + ) + return nothing_to_share | (in_first & nil_rated) | (in_reach & rates_hold) + + def allocate_deductions(person, period, parameters): """Deductions against savings and dividend income in the share that gives the least Income Tax (ITA 2007 s. 25(2) and (3A)). Where no share gives less tax than the order non-savings, savings, dividends, these are that order's deductions exactly.""" default = default_order_deductions(person, period) - c = _context(person, period, parameters) + income_tax = parameters(period).gov.hmrc.income_tax + savings, dividends = least_tax_deductions( + _inputs(person, period, income_tax), + income_tax, + default.savings, + default.dividends, + ) + return SimpleNamespace(savings=savings, dividends=dividends) + + +def least_tax_deductions( + inputs, income_tax, to_savings, to_dividends, search_all=False +): + """The deductions from savings and dividends: the old order's + (``to_savings`` and ``to_dividends``) unless another share saves more than + TOLERANCE. The search runs only on people the tests in + ``_old_order_is_least`` leave, or on everyone with ``search_all``.""" + to_savings = np.asarray(to_savings, dtype=np.float64) + to_dividends = np.asarray(to_dividends, dtype=np.float64) + if search_all: + search = np.ones(len(to_savings), dtype=bool) + else: + search = ~_old_order_is_least( + _build(inputs, income_tax), to_savings[:, None], to_dividends[:, None] + )[:, 0] + if not search.any(): + return to_savings, to_dividends + c = _build({name: values[search] for name, values in inputs.items()}, income_tax) x, s, v = _least_tax_share(c) - default_s = c.savings - np.asarray(default.savings, dtype=np.float64)[:, None] - default_v = c.dividends - np.asarray(default.dividends, dtype=np.float64)[:, None] + default_s = c.savings - to_savings[search][:, None] + default_v = c.dividends - to_dividends[search][:, None] default_x = c.step_3_income - default_s - default_v lower = ( step_5_tax(c, x, s, v) < step_5_tax(c, default_x, default_s, default_v) - TOLERANCE )[:, 0] - return SimpleNamespace( - savings=np.where(lower, (c.savings - s)[:, 0], default.savings), - dividends=np.where(lower, (c.dividends - v)[:, 0], default.dividends), - ) + moved = np.flatnonzero(search)[lower] + to_savings, to_dividends = to_savings.copy(), to_dividends.copy() + to_savings[moved] = (c.savings - s)[lower, 0] + to_dividends[moved] = (c.dividends - v)[lower, 0] + return to_savings, to_dividends def non_savings_deductions(person, period): From 3ab4884597c92547cfc383b3a920883afcba0717 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 02:27:09 -0500 Subject: [PATCH 05/21] Read the fast path's rates as the slope of the tax schedule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fast path looked up each rate by the last threshold passed, which goes wrong once a Gift Aid gift lifts the UK basic and higher rate limits above the £10 million extra bracket: a person whose income sits below the lifted basic rate limit read as paying the additional rate, so the fast path could keep the old order where moving deductions saves tax. The fast path now sums the rate steps the income has passed, the slope of the tax the search evaluates, and requires every step to be non-negative. The property test gains that person. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_income_tax_allocation.py | 4 ++ .../utils/income_tax_allocation.py | 71 ++++++------------- 2 files changed, 26 insertions(+), 49 deletions(-) diff --git a/policyengine_uk/tests/test_income_tax_allocation.py b/policyengine_uk/tests/test_income_tax_allocation.py index 27118b5bc9..dc27183bd9 100644 --- a/policyengine_uk/tests/test_income_tax_allocation.py +++ b/policyengine_uk/tests/test_income_tax_allocation.py @@ -378,6 +378,9 @@ def test_vertex_search_equals_dense_grid(sims, year, drawn): # Pension relief and a trading loss. (60_000, 0, 9_000, 4_000, 3_000, 10_000, 0, 0, False, 500), (110_000, 0, 20_000, 30_000, 0, 0, 0, 0, False, 0), + # A gift that lifts the UK limits above the £10 million extra bracket, so + # the thresholds no longer ascend. + (15_050_070, 0, 0, 10_000, 0, 12_570, 0, 15_000_000, False, 0), ] @@ -493,6 +496,7 @@ def assert_fast_path_equals_search(income_tax, inputs, default): suppress_health_check=[HealthCheck.too_slow], ) @given(st.sampled_from(SYNTHETIC_YEARS), st.integers(0, 2**32 - 1)) +@example(2025, 0) @example(2026, 0) @example(2027, 0) @example(2028, 1) diff --git a/policyengine_uk/utils/income_tax_allocation.py b/policyengine_uk/utils/income_tax_allocation.py index bdf23a1d57..57ae81db5f 100644 --- a/policyengine_uk/utils/income_tax_allocation.py +++ b/policyengine_uk/utils/income_tax_allocation.py @@ -201,33 +201,20 @@ def _build(inputs, income_tax): gift=gift, scottish=columns["scottish"], uk=_schedule(rates.uk, gift), - uk_rates=list(rates.uk.rates), scotland=_schedule( rates.scotland.rates, gift, first_extended_scottish_threshold(rates.scotland.rates), len(rates.scotland.rates.thresholds), ), - scotland_rates=list(rates.scotland.rates.rates), dividend_scale=_schedule(rates.dividends, gift), - dividend_rates=list(rates.dividends.rates), bands=( uk_thresholds[0], uk_thresholds[1] + gift, uk_thresholds[2] + gift, ), savings_rates=savings_rates, - savings_rate_list=[ - savings_rates.basic, - savings_rates.higher, - savings_rates.additional, - ], property_rates=property_rates, - property_rate_list=[ - property_rates.basic, - property_rates.higher, - property_rates.additional, - ], starting_limit=rates.savings_starter_rate.allowance, phase_out=rates.savings_starter_rate.phase_out, savings_allowance=columns["savings_allowance"], @@ -477,18 +464,20 @@ def old_order( ) -def _rate(thresholds, rates, income, above=True): - """The rate a scale charges just above ``income`` (or just below it).""" - rate = np.zeros(np.shape(income)) - for threshold, value in zip(thresholds, rates): - rate = np.where( - income >= threshold if above else income > threshold, value, rate - ) - return rate +def _rate(schedule, income, above=True): + """The rate ``_tax`` charges on a schedule just above ``income`` (or just + below it): the sum of the steps passed, whatever order the thresholds + take once a Gift Aid gift raises some of them.""" + thresholds, steps = schedule + return sum( + step * (income >= threshold if above else income > threshold) + for threshold, step in zip(thresholds, steps) + ) -def _rising(rates): - return rates[0] >= 0 and all(b >= a for a, b in zip(rates, rates[1:])) +def _steps(rates): + """The steps of a basic, higher and additional rate.""" + return [rates.basic, rates.higher - rates.basic, rates.additional - rates.higher] def _old_order_is_least(c, to_savings, to_dividends): @@ -505,17 +494,12 @@ def _old_order_is_least(c, to_savings, to_dividends): & (c.step_3_income <= TIE) ) low, basic_limit, higher_limit = c.bands + savings_scale = ((low, basic_limit, higher_limit), _steps(c.savings_rates)) + property_scale = ((-np.inf, basic_limit, higher_limit), _steps(c.property_rates)) + scales = (c.uk, c.scotland, c.dividend_scale, savings_scale, property_scale) + # Rates that rise with income from nil: no step below nil. if not ( - all( - _rising(rates) - for rates in ( - c.uk_rates, - c.scotland_rates, - c.dividend_rates, - c.savings_rate_list, - c.property_rate_list, - ) - ) + all(step >= 0 for _, steps in scales for step in steps) and low <= 0 and c.phase_out >= 0 ): @@ -528,37 +512,26 @@ def _old_order_is_least(c, to_savings, to_dividends): nil_rated = ~savings_taxed & (c.dividends <= c.dividend_allowance) # Test 3. The first component's rate just above x. if c.other_first: - _, basic_upper, higher_upper = c.bands rho_n = np.where( - c.scottish, - _rate(c.scotland[0], c.scotland_rates, other + x), - _rate( - (-np.inf, basic_upper, higher_upper), c.property_rate_list, other + x - ), + c.scottish, _rate(c.scotland, other + x), _rate(property_scale, other + x) ) in_reach = in_first else: # With no property income, Marriage Allowance only moves every band # position down by its amount. position = x - c.earned_exclusions - rho_n = np.where( - c.scottish, - _rate(c.scotland[0], c.scotland_rates, position), - _rate(c.uk[0], c.uk_rates, position), - ) + rho_n = np.where(c.scottish, _rate(c.scotland, position), _rate(c.uk, position)) in_reach = in_first & ((c.earned_exclusions == 0) | (c.property == 0)) - savings_scale = ((low, basic_limit, higher_limit), c.savings_rate_list) # dx <= c.shared and dw <= min(dx, dividends). The top of the nil rates # moves up by at most dx and never passes the top of the savings. dw = np.minimum(c.shared, c.dividends) - sigma_s = _rate(*savings_scale, np.minimum(start + c.shared, top + dw), above=False) - rho_s = _rate(*savings_scale, top) + sigma_s = _rate(savings_scale, np.minimum(start + c.shared, top + dw), above=False) + rho_s = _rate(savings_scale, top) # The dividends end at the top of income, which no share moves. sigma_v = np.where( c.dividends > c.dividend_allowance, _rate( - c.dividend_scale[0], - c.dividend_rates, + c.dividend_scale, top + np.minimum(c.dividend_allowance + dw, c.dividends), above=False, ), From 16766c1e28f2de1675d71bec31fc6ea004bab836 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 03:35:24 -0500 Subject: [PATCH 06/21] Test pay that stops at the basic rate limit with nil-rate savings above it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Case 12 sets £270 of the allowance against dividends above the basic rate limit, with £300 of savings between the pay and the dividends inside the savings allowance. The search finds that share only through the line where the pay reaches the basic rate limit. The changelog fragment now names the model as the actor. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2106.fixed.md | 2 +- .../least_tax_allocation.yaml | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/changelog.d/2106.fixed.md b/changelog.d/2106.fixed.md index 7329fd05e7..fb0a881aa8 100644 --- a/changelog.d/2106.fixed.md +++ b/changelog.d/2106.fixed.md @@ -1 +1 @@ -Income Tax now sets the Personal Allowance, other allowances and trade loss relief against non-savings, savings and dividend income in the way that gives the least tax (ITA 2007 s. 25(2)), and from 2027-28 against other income before property, savings and dividend income (s. 25(3A)), instead of always in the order non-savings, savings, dividends. +The model now deducts the Personal Allowance, other allowances and trade loss relief from non-savings, savings and dividend income so that the taxpayer pays the least tax (ITA 2007 s. 25(2)). From 2027-28 it takes them off other income before property, savings and dividend income (s. 25(3A)). It previously always deducted them from non-savings income first, then savings, then dividends. diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml index 94e5ccba29..c6cbd7b012 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml @@ -218,3 +218,29 @@ received_allowances_dividend_income: 0 dividend_income_tax: 6_971.25 income_tax: 14_457.25 + +- name: Case 12, pay stops at the basic rate limit with nil-rate savings above it + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 50_000 + savings_interest_income: 300 + dividend_income: 20_000 + output: + # As in case 8, £270 of the allowance comes off dividends, which sit + # above the basic rate limit at 35.75%, rather than off pay at 20%, until + # the pay reaches the £37,700 limit; past it, pay would pay 40%. The + # £300 of savings sits between the pay and the dividends, within the + # £500 savings allowance of a higher rate taxpayer (s. 12B), so no + # allowance comes off it. Pay 37,700 x 20% = 7,540. Dividends of £19,730 + # from £38,000: £500 at the nil rate and (57,730 - 38,500) x 35.75% = + # 6,874.725. All the allowance against pay first would charge 7,486 + + # (57,730 - 38,230) x 35.75% = 14,457.25. + received_allowances_earned_income: 12_300 + received_allowances_savings_income: 0 + received_allowances_dividend_income: 270 + savings_income_tax: 0 + earned_income_tax: 7_540 + dividend_income_tax: 6_874.725 + income_tax: 14_414.725 From e6ba856af975f8a88062b45a9884461b46f76c29 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 06:15:18 -0500 Subject: [PATCH 07/21] Expect the least-tax share in the property income band base savings case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Property income of £40,000 and savings of £20,000 in 2026-27: under ITA 2007 s. 25(2) the model sets £10,270 of the Personal Allowance against savings, which keeps the property income at the basic rate limit and puts the £500 savings allowance against savings taxed at 40%. Savings tax falls from £5,846 to £3,692 and income tax from £11,332 to £11,232. Co-Authored-By: Claude Opus 5.5 --- .../hmrc/income_tax/property_income_band_base.yaml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) 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 5a7f5ad0fd..7e704dc489 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 @@ -39,9 +39,15 @@ 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 + # ITA 2007 s. 25(2): the Personal Allowance keeps the property income up + # to the basic rate limit, £37,700, and the other £10,270 comes off + # savings, so the £500 savings allowance covers savings taxed at 40% + # rather than 20%. Savings left: 9,730, less £500 at the nil rate + # (s. 12A), at 40%: 3,692. Property income 37,700 at 20%: 7,540. The old + # order, with all the allowance against property income, charged 5,486 + # and 5,846: £100 more. + savings_income_tax: 3_692 + income_tax: 7_540 + 3_692 - name: Case 3, pay and property income together form the band base. period: 2026 From 4522b5d5104cfdbfb743825a2be7028658138ebb Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 08:41:28 -0500 Subject: [PATCH 08/21] Restate the property income band base properties for the least-tax share MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under ITA 2007 s. 25(2) the model can set allowances against savings or dividends while property income is left, so: - the accounting invariant subtracts the savings and dividends left after the allowances set against them; - the monotonic invariant tests income tax, since more property income can lower savings tax: property income of £30,000 to £40,000 with savings of £20,000 takes savings tax from £3,800 to £3,692 while income tax rises from £7,286 to £11,232, which a new test pins; - the higher-rate check pins savings tax of £3,692, income tax of £11,232 and non-savings taxable income of £37,700 for property income of £40,000 and savings of £20,000. The file now loads the model once through cloned_uk_tax_benefit_system, as other test files do. Co-Authored-By: Claude Opus 5.5 --- ...st_property_income_band_base_properties.py | 61 +++++++++++++++---- 1 file changed, 48 insertions(+), 13 deletions(-) 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 7d5730f867..6eb0d6ac0e 100644 --- a/policyengine_uk/tests/test_property_income_band_base_properties.py +++ b/policyengine_uk/tests/test_property_income_band_base_properties.py @@ -11,19 +11,28 @@ 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. + adjusted net income less the allowances and the savings and dividends left + after the allowances set against them, floored at nil + (earned_taxable_income also subtracts Marriage Allowance; #1947). ITA 2007 + s. 25(2) can set allowances against savings or dividends while + non-savings income is left, so the savings and dividends left, not their + full amounts, sit above it. +3. Monotonic: more property income never lowers the income tax. Savings or + dividend tax alone can fall: s. 25(2) can move allowances from property + income to savings once the property income reaches the basic rate limit. (Main passes this too; the differential and the higher-rate check below are what catch the bug.) """ import numpy as np +import pytest from hypothesis import HealthCheck, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + YEAR = 2026 PEOPLE = 10 amount = st.integers(min_value=0, max_value=200_000) @@ -41,6 +50,9 @@ "taxable_savings_interest_income", "taxable_dividend_income", "allowances", + "received_allowances_savings_income", + "received_allowances_dividend_income", + "income_tax", ] @@ -100,8 +112,11 @@ def test_non_savings_taxable_income_is_the_rest_after_allowances(rows): expected = np.maximum( 0, v["adjusted_net_income"] - - v["taxable_savings_interest_income"] - - v["taxable_dividend_income"] + - ( + v["taxable_savings_interest_income"] + - v["received_allowances_savings_income"] + ) + - (v["taxable_dividend_income"] - v["received_allowances_dividend_income"]) - v["allowances"], ) np.testing.assert_allclose(v["non_savings_taxable_income"], expected, atol=0.01) @@ -109,19 +124,39 @@ def test_non_savings_taxable_income_is_the_rest_after_allowances(rows): @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): +def test_more_property_income_never_lowers_income_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) + assert np.all(after["income_tax"] >= before["income_tax"] - 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.""" + """£40,000 of property income and £20,000 of savings. ITA 2007 s. 25(2) + sets £2,300 of the Personal Allowance against the property income, which + leaves it at the £37,700 basic rate limit, and the other £10,270 against + savings. All the savings left, £9,730, sit above the limit: £500 at the + nil rate (s. 12A) and £9,230 at 40%, £3,692. Main once 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 + np.testing.assert_allclose(v["savings_income_tax"][0], 9_230 * 0.40, atol=0.01) + np.testing.assert_allclose(v["income_tax"][0], 37_700 * 0.20 + 3_692, atol=0.01) + # Non-savings taxable income is 37,700, not 60,000 - 20,000 - 12,570 = + # 27,430: invariant 2 subtracts only the savings left. + np.testing.assert_allclose(v["non_savings_taxable_income"][0], 37_700, atol=0.01) + + +def test_more_property_income_can_lower_savings_tax(): + """Why invariant 3 tests the income tax. With £30,000 of property income + and £20,000 of savings, taxable income of £37,430 stays within the basic + rate band, so the savings allowance is £1,000 (s. 12B(4)) and no share + changes the tax: £19,000 of savings at 20%, £3,800. £10,000 more property + income gives the £3,692 of the test above: savings tax falls by £108 while + income tax rises from £7,286 to £11,232.""" + before = _calculate([(0, 30_000, 20_000, 0)] * PEOPLE, "ENGLAND") + after = _calculate([(0, 40_000, 20_000, 0)] * PEOPLE, "ENGLAND") + np.testing.assert_allclose(before["savings_income_tax"], 3_800, atol=0.01) + np.testing.assert_allclose(after["savings_income_tax"], 3_692, atol=0.01) + np.testing.assert_allclose(before["income_tax"], 17_430 * 0.20 + 3_800, atol=0.01) + np.testing.assert_allclose(after["income_tax"], 7_540 + 3_692, atol=0.01) From e5003937cc21efe4b93ee4e60666b06963ff5a0d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 09:49:05 -0500 Subject: [PATCH 09/21] Name the model as the actor in the property income band base notes The notes now say the model sets the allowances under ITA 2007 s. 25(2), rather than the section or the allowance doing it. Comments and docstrings only; the code and the YAML data don't change. Co-Authored-By: Claude Opus 5.5 --- .../income_tax/property_income_band_base.yaml | 14 +++---- ...st_property_income_band_base_properties.py | 42 ++++++++++--------- 2 files changed, 29 insertions(+), 27 deletions(-) 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 7e704dc489..8bef478245 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 @@ -39,13 +39,13 @@ members: [person] country: ENGLAND output: - # ITA 2007 s. 25(2): the Personal Allowance keeps the property income up - # to the basic rate limit, £37,700, and the other £10,270 comes off - # savings, so the £500 savings allowance covers savings taxed at 40% - # rather than 20%. Savings left: 9,730, less £500 at the nil rate - # (s. 12A), at 40%: 3,692. Property income 37,700 at 20%: 7,540. The old - # order, with all the allowance against property income, charged 5,486 - # and 5,846: £100 more. + # Under ITA 2007 s. 25(2) the model sets £2,300 of the Personal Allowance + # against the property income, leaving it at the basic rate limit, + # £37,700, and the other £10,270 against savings, so the £500 savings + # allowance covers savings taxed at 40% rather than 20%. Savings left: + # 9,730, less £500 at the nil rate (s. 12A), at 40%: 3,692. Property + # income 37,700 at 20%: 7,540. The old order, with all the allowance + # against property income, charged 5,486 and 5,846: £100 more. savings_income_tax: 3_692 income_tax: 7_540 + 3_692 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 6eb0d6ac0e..5e4b716092 100644 --- a/policyengine_uk/tests/test_property_income_band_base_properties.py +++ b/policyengine_uk/tests/test_property_income_band_base_properties.py @@ -13,13 +13,13 @@ 2. Accounting: with no Marriage Allowance, non_savings_taxable_income equals adjusted net income less the allowances and the savings and dividends left after the allowances set against them, floored at nil - (earned_taxable_income also subtracts Marriage Allowance; #1947). ITA 2007 - s. 25(2) can set allowances against savings or dividends while - non-savings income is left, so the savings and dividends left, not their - full amounts, sit above it. + (earned_taxable_income also subtracts Marriage Allowance; #1947). Under + ITA 2007 s. 25(2) the model can set allowances against savings or + dividends while non-savings income is left, so the savings and dividends + left, not their full amounts, sit above it. 3. Monotonic: more property income never lowers the income tax. Savings or - dividend tax alone can fall: s. 25(2) can move allowances from property - income to savings once the property income reaches the basic rate limit. + dividend tax alone can fall, because under s. 25(2) the model can move + allowances from property income to savings as property income rises. (Main passes this too; the differential and the higher-rate check below are what catch the bug.) """ @@ -133,27 +133,29 @@ def test_more_property_income_never_lowers_income_tax(rows, rise): def test_savings_above_property_income_past_the_basic_rate_limit_pay_the_higher_rate(): - """£40,000 of property income and £20,000 of savings. ITA 2007 s. 25(2) - sets £2,300 of the Personal Allowance against the property income, which - leaves it at the £37,700 basic rate limit, and the other £10,270 against - savings. All the savings left, £9,730, sit above the limit: £500 at the - nil rate (s. 12A) and £9,230 at 40%, £3,692. Main once banded the savings - as if the property income were not there and charged £2,900.""" + """£40,000 of property income and £20,000 of savings. Under ITA 2007 + s. 25(2) the model sets £2,300 of the Personal Allowance against the + property income, which leaves it at the £37,700 basic rate limit, and the + other £10,270 against savings. All the savings left, £9,730, sit above the + limit: £500 at the nil rate (s. 12A) and £9,230 at 40%, £3,692. Main once + 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") np.testing.assert_allclose(v["savings_income_tax"][0], 9_230 * 0.40, atol=0.01) np.testing.assert_allclose(v["income_tax"][0], 37_700 * 0.20 + 3_692, atol=0.01) - # Non-savings taxable income is 37,700, not 60,000 - 20,000 - 12,570 = - # 27,430: invariant 2 subtracts only the savings left. + # Non-savings taxable income comes to 37,700, not 60,000 - 20,000 - 12,570 + # = 27,430: invariant 2 subtracts only the savings left. np.testing.assert_allclose(v["non_savings_taxable_income"][0], 37_700, atol=0.01) def test_more_property_income_can_lower_savings_tax(): - """Why invariant 3 tests the income tax. With £30,000 of property income - and £20,000 of savings, taxable income of £37,430 stays within the basic - rate band, so the savings allowance is £1,000 (s. 12B(4)) and no share - changes the tax: £19,000 of savings at 20%, £3,800. £10,000 more property - income gives the £3,692 of the test above: savings tax falls by £108 while - income tax rises from £7,286 to £11,232.""" + """More property income can lower savings tax while income tax rises. + With £30,000 of property income and £20,000 of savings, taxable income of + £37,430 stays within the basic rate band, so the savings allowance comes + to £1,000 (s. 12B(4)) and no share changes the tax: £19,000 of savings at + 20%, £3,800. £10,000 more property income gives the £3,692 of the test + above: savings tax falls by £108 while income tax rises from £7,286 to + £11,232.""" before = _calculate([(0, 30_000, 20_000, 0)] * PEOPLE, "ENGLAND") after = _calculate([(0, 40_000, 20_000, 0)] * PEOPLE, "ENGLAND") np.testing.assert_allclose(before["savings_income_tax"], 3_800, atol=0.01) From 9d8d3e08bcb0a84b43ac1451b913fe08c0c9cebc Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 12:14:36 -0500 Subject: [PATCH 10/21] Leave the Marriage Allowance out of the least-tax search ITA 2007 s. 55B(1) gives the Marriage Allowance as a tax reduction, which Step 6 of s. 23 deducts (s. 26 lists Chapter 3A), not as a deduction at Step 3. The search now leaves marriage_allowance out of the earned income exclusions it reads, so the s. 55B(2)(b) rate test can read the share without a cycle. Case 13 of least_tax_allocation.yaml fails when the search reads the allowance again. Co-Authored-By: Claude Opus 5.5 --- .../least_tax_allocation.yaml | 31 ++++++++++++++++++ .../tests/test_income_tax_allocation.py | 7 ++-- .../utils/income_tax_allocation.py | 32 ++++++++++++------- 3 files changed, 55 insertions(+), 15 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml index c6cbd7b012..65688c2112 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml @@ -244,3 +244,34 @@ earned_income_tax: 7_540 dividend_income_tax: 6_874.725 income_tax: 14_414.725 + +- name: Case 13, the share leaves out the Marriage Allowance a recipient receives + period: 2026 + absolute_error_margin: 0.01 + input: + people: + recipient: + age: 40 + employment_income: 5_000 + property_income: 15_000 + savings_interest_income: 15_000 + partner: + age: 40 + benunit: + members: [recipient, partner] + is_married: true + output: + # The partner's unused allowance gives the recipient a Marriage Allowance + # of £1,260: 10% of £12,570, rounded up to a multiple of £10 (s. 55B(4) + # and (5)). Section 55B(1) gives it as a tax reduction, which Step 6 of + # s. 23 deducts, not as a deduction at Step 3, so the model leaves it out + # of the share. Pay, property income and the savings above the £1,000 + # savings allowance all bear 20%, and the starting rate band has gone + # (s. 12), so no share lowers the Step 5 tax, and the allowance comes off + # the pay and then the property income. Property 7,430 x 20% = 1,486; + # savings of £15,000 from £7,430: £1,000 at the savings nil rate and + # 14,000 x 20% = 2,800. + marriage_allowance: [1_260, 0] + received_allowances_earned_income: [12_570, 0] + received_allowances_savings_income: [0, 0] + income_tax_pre_charges: [4_286, 0] diff --git a/policyengine_uk/tests/test_income_tax_allocation.py b/policyengine_uk/tests/test_income_tax_allocation.py index dc27183bd9..49ddc55e62 100644 --- a/policyengine_uk/tests/test_income_tax_allocation.py +++ b/policyengine_uk/tests/test_income_tax_allocation.py @@ -339,8 +339,8 @@ def test_vertex_search_equals_dense_grid(sims, year, drawn): LIMITS = [500, 1_000, 5_000, 6_000, 12_570, 17_570, 37_700, 50_270, 100_000, 125_140] # Curated people at the edges of each test: other non-savings income, property, # savings, dividends, trade loss relief, allowances beyond the Personal -# Allowance, Marriage Allowance received, grossed-up Gift Aid, Scottish, and -# personal savings allowance. +# Allowance, an exclusion from earned income, grossed-up Gift Aid, Scottish, +# and personal savings allowance. EDGES = [ # Pay and savings across the basic rate limit: the search moves £270. (50_000, 0, 10_000, 0, 0, 0, 0, 0, False, 500), @@ -351,7 +351,8 @@ def test_vertex_search_equals_dense_grid(sims, year, drawn): (50_269, 0, 10_000, 0, 0, 0, 0, 0, False, 500), # Savings and dividends with no other income. (0, 0, 4_000, 20_000, 0, 0, 0, 0, False, 1_000), - # Marriage Allowance moves the savings below the basic rate limit. + # An exclusion from earned income moves the savings below the basic rate + # limit. (51_000, 0, 10_000, 0, 0, 0, 1_260, 0, False, 500), (49_000, 0, 0, 20_000, 0, 0, 1_260, 0, False, 500), # Savings exactly at the savings allowance, dividends at the allowance. diff --git a/policyengine_uk/utils/income_tax_allocation.py b/policyengine_uk/utils/income_tax_allocation.py index 57ae81db5f..3087cc61f9 100644 --- a/policyengine_uk/utils/income_tax_allocation.py +++ b/policyengine_uk/utils/income_tax_allocation.py @@ -53,13 +53,16 @@ That argument needs the starting rate limit at or below the basic rate limit and a phase-out of one pound of starting rate band for each pound of -non-savings income (s. 12), as in every year modelled. Before 2027-28 it also -needs no Marriage Allowance: the model subtracts that allowance from earned -income (#1947) rather than giving the s. 55B tax reduction, which moves the -savings and dividends down by up to that amount. The search still evaluates -every candidate with the full tax, so with Marriage Allowance it takes the best -of them. In every case it keeps the order non-savings, savings, dividends -unless another share gives less tax, so it never charges more than that order. +non-savings income (s. 12), as in every year modelled. The search keeps the +order non-savings, savings, dividends unless another share gives less tax, so +it never charges more than that order. + +The search ignores the Marriage Allowance. Section 55B(1) gives it as a tax +reduction, which Step 6 deducts, not as a deduction at Step 3, and its rate +test (s. 55B(2)(b)) reads the share. The model still subtracts the allowance +from earned income (#1947), which moves a recipient's savings and dividends +down by up to that amount, so for a recipient the search's tax can differ from +the model's. Who cannot gain --------------- @@ -90,8 +93,9 @@ because the starting rate band shrinks by at most a pound for each pound of non-savings income (s. 12) and the savings and dividend nil rates cover fixed amounts (ss. 12A and 13A). Before 2027-28 the test also needs no - Marriage Allowance or no property income: with property income, the - model's Marriage Allowance breaks the rising rate on the first component. + property income or no exclusion from earned income that the search reads: + with property income, such an exclusion breaks the rising rate on the + first component. Where any test holds, the search would keep the old order too, since it changes the share only for a saving above ``TOLERANCE``. So the result is the @@ -119,6 +123,10 @@ "taxable_property_income", "received_allowances_earned_income", ) +# The exclusions the search leaves out. s. 55B(1) gives the Marriage Allowance +# as a tax reduction, not a deduction at Step 3, and its rate test reads the +# share, so the search shares a recipient's deductions as if they had none. +IGNORED_EXCLUSIONS = ("marriage_allowance",) def _schedule(scale, gift, first=1, last=3): @@ -148,7 +156,7 @@ def values(variable): exclusions = [ variable for variable in income_tax.earned_taxable_income_exclusions - if variable not in ALLOCATED_EXCLUSIONS + if variable not in ALLOCATED_EXCLUSIONS + IGNORED_EXCLUSIONS ] savings = values("taxable_savings_interest_income") return dict( @@ -517,8 +525,8 @@ def _old_order_is_least(c, to_savings, to_dividends): ) in_reach = in_first else: - # With no property income, Marriage Allowance only moves every band - # position down by its amount. + # With no property income, an exclusion from earned income only moves + # every band position down by its amount. position = x - c.earned_exclusions rho_n = np.where(c.scottish, _rate(c.scotland, position), _rate(c.uk, position)) in_reach = in_first & ((c.earned_exclusions == 0) | (c.property == 0)) From ea880ff7631cbb486a349ec197d8d624dc6e9169 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 14:37:30 -0500 Subject: [PATCH 11/21] Name Parliament as the actor in the Marriage Allowance notes Comments only: the module docstring, the IGNORED_EXCLUSIONS note and case 13's working. Co-Authored-By: Claude Opus 5.5 --- .../least_tax_allocation.yaml | 19 ++++++++++--------- .../utils/income_tax_allocation.py | 19 ++++++++++--------- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml index 65688c2112..57986c9c29 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml @@ -261,15 +261,16 @@ members: [recipient, partner] is_married: true output: - # The partner's unused allowance gives the recipient a Marriage Allowance - # of £1,260: 10% of £12,570, rounded up to a multiple of £10 (s. 55B(4) - # and (5)). Section 55B(1) gives it as a tax reduction, which Step 6 of - # s. 23 deducts, not as a deduction at Step 3, so the model leaves it out - # of the share. Pay, property income and the savings above the £1,000 - # savings allowance all bear 20%, and the starting rate band has gone - # (s. 12), so no share lowers the Step 5 tax, and the allowance comes off - # the pay and then the property income. Property 7,430 x 20% = 1,486; - # savings of £15,000 from £7,430: £1,000 at the savings nil rate and + # The model gives the recipient a Marriage Allowance of £1,260 from the + # partner's unused Personal Allowance: 10% of £12,570, rounded up to a + # multiple of £10 (s. 55B(4) and (5)). Parliament gives it as a tax + # reduction (s. 55B(1)), which Step 6 of s. 23 deducts, not as a deduction + # at Step 3, so the model leaves it out of the share. Pay, property income + # and the savings above the £1,000 savings allowance all bear 20%, and the + # pay and property income leave no starting rate band (s. 12), so no share + # lowers the Step 5 tax, and the model takes the Personal Allowance off the + # pay and then the property income. Property 7,430 x 20% = 1,486; savings + # of £15,000 from £7,430: £1,000 at the savings nil rate and # 14,000 x 20% = 2,800. marriage_allowance: [1_260, 0] received_allowances_earned_income: [12_570, 0] diff --git a/policyengine_uk/utils/income_tax_allocation.py b/policyengine_uk/utils/income_tax_allocation.py index 3087cc61f9..2f88edae93 100644 --- a/policyengine_uk/utils/income_tax_allocation.py +++ b/policyengine_uk/utils/income_tax_allocation.py @@ -57,12 +57,12 @@ order non-savings, savings, dividends unless another share gives less tax, so it never charges more than that order. -The search ignores the Marriage Allowance. Section 55B(1) gives it as a tax -reduction, which Step 6 deducts, not as a deduction at Step 3, and its rate -test (s. 55B(2)(b)) reads the share. The model still subtracts the allowance -from earned income (#1947), which moves a recipient's savings and dividends -down by up to that amount, so for a recipient the search's tax can differ from -the model's. +The search ignores the Marriage Allowance. Parliament gives it as a tax +reduction (s. 55B(1)), which Step 6 deducts, not as a deduction at Step 3, and +whether a person qualifies for it (s. 55B(2)(b)) depends on the share. The +model still subtracts the allowance from earned income (#1947), which moves a +recipient's savings and dividends down by up to that amount, so for a +recipient the search's tax can differ from the model's. Who cannot gain --------------- @@ -123,9 +123,10 @@ "taxable_property_income", "received_allowances_earned_income", ) -# The exclusions the search leaves out. s. 55B(1) gives the Marriage Allowance -# as a tax reduction, not a deduction at Step 3, and its rate test reads the -# share, so the search shares a recipient's deductions as if they had none. +# The search leaves these exclusions out. Parliament gives the Marriage +# Allowance as a tax reduction (s. 55B(1)), not a deduction at Step 3, and +# whether a person qualifies (s. 55B(2)(b)) depends on the share, so the search +# shares a recipient's deductions as if they had none. IGNORED_EXCLUSIONS = ("marriage_allowance",) From 5d9ce134348c1ebf7ce183c7239d5ebdba8582fa Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 19:41:54 -0500 Subject: [PATCH 12/21] Name who acts in the allowance-order comments Replace passive constructions in the branch's comments, two YAML case names, a test module docstring and the documentation of non_savings_income_after_allowances ("is taxed at 20%" becomes "bears 20%", "is left" becomes "remains"). No test input, expected value or formula changes. Co-Authored-By: Claude Opus 5.5 --- .../income_tax/reliefs/other_income_first.yaml | 2 +- .../least_tax_allocation.yaml | 16 ++++++++-------- .../received_allowances_dividend_income.yaml | 12 ++++++------ .../received_allowances_earned_income.yaml | 2 +- .../received_allowances_savings_income.yaml | 8 ++++---- .../baseline/gov/hmrc/income_tax/gift_aid.yaml | 2 +- ...trade_loss_relief_against_general_income.yaml | 4 ++-- .../tests/policy/integration/income.yaml | 2 +- policyengine_uk/tests/test_gift_aid.py | 6 +++--- .../test_property_income_band_base_properties.py | 2 +- policyengine_uk/utils/income_tax_allocation.py | 4 ++-- .../non_savings_income_after_allowances.py | 10 +++++----- 12 files changed, 35 insertions(+), 35 deletions(-) diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/other_income_first.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/other_income_first.yaml index aad1838fca..91ffd405cf 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/other_income_first.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/other_income_first.yaml @@ -1,6 +1,6 @@ description: Income Tax deducts reliefs and allowances from income other than property, savings and dividend income before it deducts them from property, savings or dividend income, and then in the way that gives the least tax. values: - # Before 2027-28, s. 25(2) alone decides how they are deducted. + # Before 2027-28, the taxpayer deducts them under s. 25(2) alone. 0000-01-01: false 2027-04-06: true metadata: diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml index 57986c9c29..5065fd29fd 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml @@ -32,7 +32,7 @@ dividend_income: 20_000 output: # The £4,000 of savings is the lowest part of Step 3 income, within the - # £5,000 starting rate band, so it is taxed at 0% (s. 12). Set against + # £5,000 starting rate band, so it bears 0% (s. 12). Set against # it, the allowance would save nothing; set against dividends it saves # 10.75%. So all £12,570 comes off dividends, and the tax is as in case 1: # (20,000 - 12,570 - 500) x 10.75%. @@ -41,7 +41,7 @@ savings_income_tax: 0 income_tax: 744.975 -- name: Case 3, a trading loss is set against dividends rather than savings at the starting rate +- name: Case 3, the taxpayer sets a trading loss against dividends rather than savings at the starting rate period: 2026 absolute_error_margin: 0.01 input: @@ -67,7 +67,7 @@ savings_income_tax: 0 income_tax: 65_466.71 -- name: Case 4, in 2027-28 a trading loss is set against property income rather than dividends +- name: Case 4, in 2027-28 the taxpayer sets a trading loss against property income rather than dividends period: 2027 absolute_error_margin: 0.01 input: @@ -126,11 +126,11 @@ savings_interest_income: 10_000 region: SCOTLAND output: - # Pay of £37,430 after the allowance is taxed at the Scottish rates: + # Pay of £37,430 after the allowance bears the Scottish rates: # 3,967 x 19% + 12,989 x 20% + 14,136 x 21% + 6,338 x 42% = 8,982.05. # Savings run from £37,430 to £47,430. Step 3 income would be higher # rate income outside Scotland, so the savings allowance is £500 - # (s. 12B(8)(b)(iv)), and the other £9,500 is above the UK basic rate + # (s. 12B(8)(b)(iv)), and the other £9,500 lies above the UK basic rate # limit, at 40% (s. 11D(6)): 3,800. Moving £270 of the allowance to # savings, as in case 7, would tax £270 more pay at 42% to take £270 of # savings out of the 40% band: £5.40 more. @@ -148,10 +148,10 @@ employment_income: 50_000 savings_interest_income: 10_000 output: - # With all £12,570 against pay, pay of £37,430 is taxed at 20% and the + # With all £12,570 against pay, pay of £37,430 bears 20% and the # £500 savings allowance covers savings from £37,430 to £37,930, all but - # £230 of it below the basic rate limit, where savings would be taxed - # at 20% anyway. Setting £270 against savings instead puts the pay at + # £230 of it below the basic rate limit, where savings would bear + # 20% anyway. Setting £270 against savings instead puts the pay at # £37,700 and the savings allowance above the limit, where it saves 40%: # pay 37,700 x 20% = 7,540; savings (47,430 - 38,200) x 40% = 3,692. # The order pay, savings, dividends charges 7,486 + 3,800 = 11,286. diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_dividend_income.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_dividend_income.yaml index ef45d15f0a..eba9397fb6 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_dividend_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_dividend_income.yaml @@ -46,10 +46,10 @@ taxable_savings_interest_income: 2_000 taxable_dividend_income: 1_000 output: - # After the pay, £1,000 of income is left. Left as £1,000 of dividends, - # £500 would be taxed at 8.75% (s. 13A nil rate on the first £500). Left + # After the pay, £1,000 of income remains. Left as £1,000 of dividends, + # £500 would bear 8.75% (s. 13A nil rate on the first £500). Left # as £500 of savings, in the starting rate band, and £500 of dividends, - # within the nil rate, it is all taxed at 0%. + # within the nil rate, it all bears 0%. received_allowances_savings_income: 1_500 received_allowances_dividend_income: 500 dividend_income_tax: 0 @@ -72,9 +72,9 @@ taxable_savings_interest_income: 2_000 taxable_dividend_income: 1_000 output: - # £1,430 is left after the pay. Up to £500 of dividends are taxed at 0% - # (s. 13A) and savings in the starting rate band at 0% (s. 12), so £930 - # of savings and £500 of dividends are left: £1,070 of the allowances + # £1,430 remains after the pay. Up to £500 of dividends bear 0% + # (s. 13A) and savings in the starting rate band 0% (s. 12), so £930 + # of savings and £500 of dividends remain: £1,070 of the allowances # go to savings and £500 to dividends. received_allowances_savings_income: 1_070 received_allowances_dividend_income: 500 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_earned_income.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_earned_income.yaml index 5abae01d67..55def4eade 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_earned_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_earned_income.yaml @@ -36,7 +36,7 @@ taxable_savings_interest_income: 8_000 taxable_dividend_income: 7_000 output: - # The pay would be taxed at 20%, so it takes £5,000 of the allowances + # The pay would bear 20%, so it takes £5,000 of the allowances # (ITA 2007 s. 25(2)). received_allowances_earned_income: 5_000 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_savings_income.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_savings_income.yaml index 708a4858f5..92c707d367 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_savings_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_savings_income.yaml @@ -29,9 +29,9 @@ taxable_employment_income: 11_570 taxable_savings_interest_income: 3_000 output: - # The pay takes £11,570, which would otherwise be taxed at 20%. The - # £2,000 of savings left is within the £5,000 starting rate band, so - # it is taxed at 0% whichever £1,000 of the allowances it takes. + # The pay takes £11,570, which would otherwise bear 20%. The + # £2,000 of savings left falls within the £5,000 starting rate band, so + # it bears 0% whichever £1,000 of the allowances it takes. received_allowances_savings_income: 1_000 - name: No allowances go to savings that the starting rate leaves untaxed @@ -61,6 +61,6 @@ taxable_employment_income: 13_000 taxable_savings_interest_income: 1_000 output: - # £430 of pay is taxed at 20% and the savings above it at the starting + # £430 of pay bears 20% and the savings above it the starting # rate of 0%, so the allowances are worth most against the pay. received_allowances_savings_income: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/gift_aid.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/gift_aid.yaml index b9052b16d6..05e7557487 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/gift_aid.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/gift_aid.yaml @@ -95,7 +95,7 @@ # ITA 2007 s. 25(2): the Personal Allowance keeps the pay up to the basic # rate limit, £37,700, or £38,700 with the £1,000 grossed-up gift, and the # rest of it comes off dividends, so the £500 dividend allowance covers - # dividends that would otherwise be taxed at 35.75%. Dividends left: + # dividends that would otherwise bear 35.75%. Dividends left: # 9,730 and 8,730; less £500 at the nil rate, at 35.75%. The extension # keeps £1,000 more pay at 20% in place of upper-rate dividends: £157.50. earned_taxable_income: [37_700, 38_700] diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.yaml index da87919870..90776b41bc 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.yaml @@ -123,13 +123,13 @@ trade_loss_relief_against_savings_and_dividends: 15_000 # ITA 2007 s. 25(2) sets the £32,570 of relief and Personal Allowance # against income in the way that gives the least tax. The pay takes - # £5,000. Of Step 3 income of £12,430, savings up to £6,000 are taxed at + # £5,000. Of Step 3 income of £12,430, savings up to £6,000 bear # 0%: £5,000 at the starting rate (s. 12) and £1,000 within the savings # allowance (s. 12A). So savings take £4,000 and dividends £23,570. received_allowances_earned_income: 0 received_allowances_savings_income: 4_000 received_allowances_dividend_income: 23_570 - # £6,430 of dividends is left: (6,430 - 500) x 10.75%. + # £6,430 of dividends remains: (6,430 - 500) x 10.75%. income_tax: 637.475 - name: Adjusted total income is total income after employment expenses diff --git a/policyengine_uk/tests/policy/integration/income.yaml b/policyengine_uk/tests/policy/integration/income.yaml index e86069230b..55afb8a955 100644 --- a/policyengine_uk/tests/policy/integration/income.yaml +++ b/policyengine_uk/tests/policy/integration/income.yaml @@ -23,7 +23,7 @@ # ITA 2007 s. 25(2): of the £12,500 Personal Allowance, £2,500 comes off # the pay and £10,000 off the savings. The pay left is £37,500, the basic # rate limit, so the £500 savings allowance (s. 12B(3)) covers savings - # that would otherwise be taxed at 40% rather than 20%: £100 less tax + # that would otherwise bear 40% rather than 20%: £100 less tax # than the allowance all against the pay. Savings of £5,000 run from # £37,500 to £42,500: £500 at the nil rate and £4,500 at 40%. Setting # more against the pay or the savings would not change the tax. diff --git a/policyengine_uk/tests/test_gift_aid.py b/policyengine_uk/tests/test_gift_aid.py index 1b74ef4d08..8a4d9995a1 100644 --- a/policyengine_uk/tests/test_gift_aid.py +++ b/policyengine_uk/tests/test_gift_aid.py @@ -302,9 +302,9 @@ def test_zero_gift_preserves_baseline_outputs_bit_for_bit(): # The fourth row outside Scotland sets £1,300 of the Personal # Allowance against pay and £11,270 against savings (ITA 2007 # s. 25(2), #2106): pay and property reach the £37,700 basic rate - # limit, and the £500 savings allowance covers savings that would be - # taxed at 40%, so its tax falls by £100. In Scotland the pay above - # £31,092 would be taxed at 42%, so the order is unchanged. + # limit, and the £500 savings allowance covers savings that would + # bear 40%, so its tax falls by £100. In Scotland the pay above + # £31,092 would bear 42%, so the model keeps the old order. "earned_taxable_income": [17_430, 47_430, 150_000, 28_700, 0, 88_930] + [17_430, 47_430, 150_000, 17_430, 0, 88_930], "received_allowances_earned_income": [12_570, 12_570, 0, 1_300, 10_000, 12_070] 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 5e4b716092..f3ebf5d301 100644 --- a/policyengine_uk/tests/test_property_income_band_base_properties.py +++ b/policyengine_uk/tests/test_property_income_band_base_properties.py @@ -15,7 +15,7 @@ after the allowances set against them, floored at nil (earned_taxable_income also subtracts Marriage Allowance; #1947). Under ITA 2007 s. 25(2) the model can set allowances against savings or - dividends while non-savings income is left, so the savings and dividends + dividends while non-savings income remains, so the savings and dividends left, not their full amounts, sit above it. 3. Monotonic: more property income never lowers the income tax. Savings or dividend tax alone can fall, because under s. 25(2) the model can move diff --git a/policyengine_uk/utils/income_tax_allocation.py b/policyengine_uk/utils/income_tax_allocation.py index 2f88edae93..d699e7d4f3 100644 --- a/policyengine_uk/utils/income_tax_allocation.py +++ b/policyengine_uk/utils/income_tax_allocation.py @@ -189,8 +189,8 @@ def _build(inputs, income_tax): savings = np.maximum(0, columns["savings"]) dividends = np.maximum(0, columns["dividends"]) relief = columns["relief"] - # net_income, not its components, so a supplied adjusted net income or a - # reform that sets net income is followed, as the default order does. + # net_income, not its components, so the search follows a supplied adjusted + # net income or a reform that sets net income, as the default order does. non_savings = np.maximum(0, columns["net_income"] + relief - savings - dividends) property_income = np.minimum(np.maximum(0, columns["property"]), non_savings) deductions = relief + columns["allowances"] 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 index 232a6ff67e..e1bb768e06 100644 --- 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 @@ -10,11 +10,11 @@ class non_savings_income_after_allowances(Variable): "does not reduce the income charged to tax) less savings and dividend " "income and the reliefs and allowances set against non-savings " "income, floored at nil: the income below savings and dividends " - "(ITA 2007 s. 16) once Steps 2 and 3 are done. ITA 2007 s. 25(2) " - "decides how much of the reliefs and allowances comes off it, and " - "from 2027-28 s. 25(3A) takes them off the rest of it before property " - "income. property_income_tax and taxable_property_income_after_allowances " - "both read it." + "(ITA 2007 s. 16) after Steps 2 and 3. The taxpayer sets the reliefs " + "and allowances against it in the way that gives the least tax (ITA " + "2007 s. 25(2)), and from 2027-28 against the rest of it before " + "property income (s. 25(3A)). property_income_tax and " + "taxable_property_income_after_allowances both read it." ) definition_period = YEAR reference = [ From 74fdacf1d4933f759f94d15999a28e64b4cc73e2 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 2 Nov 2026 01:13:46 -0500 Subject: [PATCH 13/21] Filter rate identities on the allowance order A Scottish taxpayer's property income stays on the Scottish rates while the Budget 2026 savings rates rise, so the least-tax order can move an allowance onto savings. Property tax and savings tax then both move, so the identities in test_budget_2026_savings_property_rates_properties.py hold only for people whose allowance order the rates leave unchanged. Pin that person with a worked case, and add them to the fast-path test's fixed people. Co-Authored-By: Claude Opus 5.5 --- ..._2026_savings_property_rates_properties.py | 69 ++++++++++++++++--- .../tests/test_income_tax_allocation.py | 2 + 2 files changed, 62 insertions(+), 9 deletions(-) diff --git a/policyengine_uk/tests/test_budget_2026_savings_property_rates_properties.py b/policyengine_uk/tests/test_budget_2026_savings_property_rates_properties.py index 09c62063dd..1de32e9688 100644 --- a/policyengine_uk/tests/test_budget_2026_savings_property_rates_properties.py +++ b/policyengine_uk/tests/test_budget_2026_savings_property_rates_properties.py @@ -4,7 +4,8 @@ 6 April 2027 (paragraphs 6.246 and 6.247), on top of Budget 2025's 22%, 42% and 47%. Each test compares current law with the Budget 2025 rates for the same generated people, and checks identities that hold for every input -whose property allowance election is the same under both sets of rates: +whose property allowance election and allowance order stay the same under +both sets of rates: - rUK property tax rises by exactly 2% of taxable property income, since every pound of it sits in one of the three rate bands; @@ -23,6 +24,11 @@ property tax and the relief both move (test_higher_relief_can_make_giving_up_the_property_allowance_worthwhile). The model makes that choice only where it lowers the person's income tax. + +The order of the allowances can change too. A Scottish taxpayer's property +income stays on the Scottish rates while the savings rates rise, so the +allowance can save more against savings, and then both taxes move +(test_higher_savings_rates_can_move_the_allowance_onto_savings). """ import numpy as np @@ -60,6 +66,10 @@ # property allowance at the higher rates. ELECTION_CHANGE = [(0.0, 0.0, 0.0, 0.0)] * CASES ELECTION_CHANGE[3] = (20_000.0, 500.0, 0.87, 0.0) +# The first property run's counterexample: the fourth person (Scotland) sets +# the allowance against savings at the higher rates. +ORDER_CHANGE = [(0.0, 0.0, 0.0, 0.0)] * CASES +ORDER_CHANGE[3] = (0.0, 58_000.0, 0.0, 24_000.0) def situation(): @@ -88,7 +98,12 @@ def sims(): ) -VARIABLES = [ +ALLOWANCE_ORDER = [ + "received_allowances_earned_income", + "received_allowances_savings_income", + "received_allowances_dividend_income", +] +VARIABLES = ALLOWANCE_ORDER + [ "pays_scottish_income_tax", "uses_property_allowance", "taxable_property_income_after_allowances", @@ -116,9 +131,13 @@ def run(sims, drawn): return results -def same_election(law, before): - """People whose property allowance election the rates leave unchanged.""" - return law["uses_property_allowance"] == before["uses_property_allowance"] +def same_choices(law, before): + """People whose property allowance election and allowance order the rates + leave unchanged.""" + same = law["uses_property_allowance"] == before["uses_property_allowance"] + for name in ALLOWANCE_ORDER: + same &= np.abs(law[name] - before[name]) <= tolerance(law[name]) + return same def tolerance(*values): @@ -138,7 +157,7 @@ def tolerance(*values): @example(ELECTION_CHANGE) def test_ruk_property_tax_rises_by_two_points_of_taxable_property(sims, drawn): law, before = run(sims, drawn) - ruk = ~law["pays_scottish_income_tax"].astype(bool) & same_election(law, before) + ruk = ~law["pays_scottish_income_tax"].astype(bool) & same_choices(law, before) rise = law["property_income_tax"] - before["property_income_tax"] expected = RISE * law["taxable_property_income_after_allowances"] tol = tolerance(law["property_income_tax"], expected) @@ -148,9 +167,10 @@ def test_ruk_property_tax_rises_by_two_points_of_taxable_property(sims, drawn): @SETTINGS @given(cases) @example(ELECTION_CHANGE) +@example(ORDER_CHANGE) def test_scottish_property_tax_ignores_the_property_rates(sims, drawn): law, before = run(sims, drawn) - scottish = law["pays_scottish_income_tax"].astype(bool) & same_election(law, before) + scottish = law["pays_scottish_income_tax"].astype(bool) & same_choices(law, before) rise = law["property_income_tax"] - before["property_income_tax"] tol = tolerance(law["property_income_tax"]) assert (np.abs(rise)[scottish] <= tol[scottish]).all() @@ -159,6 +179,7 @@ def test_scottish_property_tax_ignores_the_property_rates(sims, drawn): @SETTINGS @given(cases) @example(ELECTION_CHANGE) +@example(ORDER_CHANGE) def test_savings_tax_rises_by_two_points_in_every_country(sims, drawn): law, before = run(sims, drawn) banded = ( @@ -168,13 +189,14 @@ def test_savings_tax_rises_by_two_points_in_every_country(sims, drawn): ) rise = law["savings_income_tax"] - before["savings_income_tax"] tol = tolerance(law["savings_income_tax"], banded) - same = same_election(law, before) + same = same_choices(law, before) assert (np.abs(rise - RISE * banded) <= tol)[same].all() @SETTINGS @given(cases) @example(ELECTION_CHANGE) +@example(ORDER_CHANGE) def test_finance_cost_relief_rises_by_at_most_two_points(sims, drawn): law, before = run(sims, drawn) rise = law["property_finance_cost_relief"] - before["property_finance_cost_relief"] @@ -183,7 +205,36 @@ def test_finance_cost_relief_rises_by_at_most_two_points(sims, drawn): assert (rise >= -tol).all() full_before = 0.22 * before["property_finance_costs_relieved"] uncapped = np.abs(before["property_finance_cost_relief"] - full_before) <= tol - assert (rise <= cap + tol)[uncapped & same_election(law, before)].all() + assert (rise <= cap + tol)[uncapped & same_choices(law, before)].all() + + +def test_higher_savings_rates_can_move_the_allowance_onto_savings(sims): + """Property income of £58,000 and savings of £24,000 for a Scottish + taxpayer. Under the Budget 2025 rates the allowance saves 42% against + either income, and the old order sets it against property income. Under + the Budget 2026 rates savings pay 44% and this property income still pays + the Scottish 42%, so ITA 2007 s.25(2) sets the £12,570 against savings. + + Savings tax goes from 42% of £23,500 to 44% of £10,930, after the £500 + savings allowance. Property tax rises by 42% of £12,570. Income tax rises + by £218.60, against £470 had the allowance stayed on property income: the + move saves 2% of £12,570, £251.40.""" + law, before = run(sims, ORDER_CHANGE) + person = ORDER_CHANGE.index((0.0, 58_000.0, 0.0, 24_000.0)) + assert law["pays_scottish_income_tax"][person] + assert before["received_allowances_earned_income"][person] == 12_570 + assert before["received_allowances_savings_income"][person] == 0 + assert law["received_allowances_earned_income"][person] == 0 + assert law["received_allowances_savings_income"][person] == 12_570 + assert before["savings_income_tax"][person] == pytest.approx(9_870, abs=0.01) + assert law["savings_income_tax"][person] == pytest.approx(4_809.2, abs=0.01) + assert law["property_income_tax"][person] - before["property_income_tax"][ + person + ] == pytest.approx(5_279.4, abs=0.01) + assert law["income_tax"][person] - before["income_tax"][person] == pytest.approx( + 218.6, abs=0.01 + ) + assert not same_choices(law, before)[person] def test_capped_finance_cost_relief_can_rise_by_more_than_two_points(): diff --git a/policyengine_uk/tests/test_income_tax_allocation.py b/policyengine_uk/tests/test_income_tax_allocation.py index 49ddc55e62..15221cf83c 100644 --- a/policyengine_uk/tests/test_income_tax_allocation.py +++ b/policyengine_uk/tests/test_income_tax_allocation.py @@ -371,6 +371,8 @@ def test_vertex_search_equals_dense_grid(sims, year, drawn): # Property with other income, savings and dividends. (9_000, 30_000, 6_000, 15_000, 0, 0, 0, 0, False, 500), (9_000, 30_000, 6_000, 15_000, 0, 0, 0, 0, True, 500), + # Scottish property income on 42% beside savings on 44% from 2027-28. + (0, 58_000, 24_000, 0, 0, 0, 0, 0, True, 500), (0, 20_000, 0, 200_000, 1_000, 0, 0, 0, False, 0), (45_000, 20_000, 3_000, 0, 0, 0, 1_260, 0, False, 500), # Gift Aid raises the limits. From 6429896f9a2c0c45375c2a8c8f17c3d94c4dcc6e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 15:25:42 +0000 Subject: [PATCH 14/21] Hold pension relief on pay and keep the UC earnings slice in the old order Pension relief under net pay (FA 2004 s. 193(2)) and relief at source (s. 192(4)) is no Step 2 deduction the taxpayer can set against savings or dividends, so the least-tax search now holds min(pension relief, non-savings income) on non-savings income and shares only the rest. The income tax that Universal Credit deducts from earnings reads the old order's deductions, so more savings or dividends never raise it. Before 2027-28 the search adds the lines that an exclusion from earned income creates beside property income. non_savings_income_after_allowances comes from the allocation's own arithmetic, and earned_taxable_income never exceeds it, so float32 rounding in the relief added back leaves no residue. New tests: YAML cases for pension relief, the earned income add-back, a pension-exempting reform and the float32 residue; property tests for the UC slice, the search with exclusions and the Budget 2026 rate bound; tests that separate the fast path's supplied-net-income and Scottish starter rate checks. Docstrings name the actor. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6JoK8gQvt3JZzVN18g --- changelog.d/2106.fixed.md | 2 +- .../least_tax_allocation.yaml | 145 ++++++++++- .../tests/policy/integration/income.yaml | 6 +- ..._2026_savings_property_rates_properties.py | 38 ++- .../tests/test_income_tax_allocation.py | 192 +++++++++++++- .../tests/test_uc_income_tax_properties.py | 141 +++++++++++ .../utils/income_tax_allocation.py | 235 +++++++++++------- policyengine_uk/utils/uc_income_tax.py | 60 +++-- .../received_allowances_earned_income.py | 2 +- .../received_allowances_savings_income.py | 9 +- .../earned_taxable_income.py | 16 +- .../non_savings_income_after_allowances.py | 28 +-- ...ss_relief_against_savings_and_dividends.py | 17 +- 13 files changed, 735 insertions(+), 156 deletions(-) create mode 100644 policyengine_uk/tests/test_uc_income_tax_properties.py diff --git a/changelog.d/2106.fixed.md b/changelog.d/2106.fixed.md index fb0a881aa8..92b771fb40 100644 --- a/changelog.d/2106.fixed.md +++ b/changelog.d/2106.fixed.md @@ -1 +1 @@ -The model now deducts the Personal Allowance, other allowances and trade loss relief from non-savings, savings and dividend income so that the taxpayer pays the least tax (ITA 2007 s. 25(2)). From 2027-28 it takes them off other income before property, savings and dividend income (s. 25(3A)). It previously always deducted them from non-savings income first, then savings, then dividends. +The model now sets the Personal Allowance, other allowances and trade loss relief against non-savings, savings and dividend income in the way that gives the least tax (ITA 2007 s. 25(2)), and from 2027-28 against other income before property, savings and dividend income (s. 25(3A)), where it previously always took them from non-savings income first. Pension relief stays on non-savings income, and the income tax that Universal Credit deducts from earnings still follows the order non-savings, savings, dividends. diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml index 5065fd29fd..ba8b340e63 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml @@ -1,8 +1,8 @@ # ITA 2007 s. 25(2): "At Steps 2 and 3, deduct the reliefs and allowances in # the way which will result in the greatest reduction in the taxpayer's -# liability to income tax." From 2027-28, s. 25(3A) (Finance Act 2026 s. 6) -# deducts them from income other than property, savings and dividend income -# first. Savings sit above non-savings income and dividends above savings +# liability to income tax." From 2027-28, the taxpayer deducts them from +# income other than property, savings and dividend income first (s. 25(3A), +# inserted by Finance Act 2026 s. 6). Savings sit above non-savings income and dividends above savings # (s. 16), and from 2027-28 property income sits just below savings (s. 16A). # 2026-27 and 2027-28: Personal Allowance £12,570, tapered away by £1 for each # £2 of adjusted net income above £100,000 (s. 35); basic rate limit £37,700 @@ -76,8 +76,8 @@ dividend_income: 200_000 trading_loss: 1_000 output: - # No other income, so s. 25(3A) leaves the choice between property and - # dividends to s. 25(2). Property income sits below dividends (s. 16A). + # No other income, so the taxpayer chooses between property and dividends + # (s. 25(2)); s. 25(3A) changes nothing. Property income sits below dividends (s. 16A). # Against property, the £1,000 of relief saves 24% on property income in # the basic rate band, and moves £1,000 of dividends down into the # ordinary rate band at 10.75%: a net saving of £132.50 over setting it @@ -210,9 +210,9 @@ employment_income: 50_000 dividend_income: 20_000 output: - # s. 25(3A) deducts the allowance from income other than property, - # savings and dividend income first, so all £12,570 comes off the pay, - # unlike case 8. Pay 37,430 x 20% = 7,486. Dividends from £37,430: £500 + # The taxpayer deducts the allowance from income other than property, + # savings and dividend income first (s. 25(3A)), so all £12,570 comes off + # the pay, unlike case 8. Pay 37,430 x 20% = 7,486. Dividends from £37,430: £500 # at the nil rate and (57,430 - 37,930) x 35.75% = 6,971.25. received_allowances_earned_income: 12_570 received_allowances_dividend_income: 0 @@ -264,8 +264,8 @@ # The model gives the recipient a Marriage Allowance of £1,260 from the # partner's unused Personal Allowance: 10% of £12,570, rounded up to a # multiple of £10 (s. 55B(4) and (5)). Parliament gives it as a tax - # reduction (s. 55B(1)), which Step 6 of s. 23 deducts, not as a deduction - # at Step 3, so the model leaves it out of the share. Pay, property income + # reduction (s. 55B(1)), which the taxpayer deducts at Step 6 of s. 23, + # not as a deduction at Step 3, so the model leaves it out of the share. Pay, property income # and the savings above the £1,000 savings allowance all bear 20%, and the # pay and property income leave no starting rate band (s. 12), so no share # lowers the Step 5 tax, and the model takes the Personal Allowance off the @@ -276,3 +276,128 @@ received_allowances_earned_income: [12_570, 0] received_allowances_savings_income: [0, 0] income_tax_pre_charges: [4_286, 0] + +- name: Case 14, net pay pension relief stays on pay + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 40_000 + employee_pension_contributions: 10_000 + savings_interest_income: 30_000 + output: + # The model takes employee contributions as a net pay arrangement, which + # the employer deducts from employment income (FA 2004 s. 193(2)). The + # taxpayer can't set that relief against savings, so it stays on the + # pay, and the pay left for the share is £30,000, as if the person had + # pay of £30,000 and no pension. Adjusted net income of £60,000 leaves the + # full £12,570 allowance and a £500 savings allowance (s. 12B). Against + # pay: pay 17,430 x 20% = 3,486; savings from £17,430 to £47,430: £500 at + # the nil rate, (37,700 - 17,930) x 20% = 3,954 and 9,730 x 40% = 3,892. + # Every share keeps the savings allowance below the £37,700 limit, where + # it saves 20%, so no share lowers the tax of 11,332. Sharing the pension + # relief too would put the pay at the limit and charge £100 less. + pension_contributions_relief: 10_000 + received_allowances_earned_income: 22_570 + received_allowances_savings_income: 0 + earned_taxable_income: 17_430 + earned_income_tax: 3_486 + savings_income_tax: 7_846 + income_tax: 11_332 + +- name: Case 15, relief at source on personal pension contributions stays on non-savings income + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 40_000 + personal_pension_contributions: 10_000 + savings_interest_income: 30_000 + output: + # The model takes personal contributions as relief at source, which + # extends the rate bands (FA 2004 s. 192(4)) rather than giving a Step 2 + # deduction the taxpayer can set against savings. The model deducts the + # relief from non-savings income, so the tax matches case 14. + pension_contributions_relief: 10_000 + received_allowances_earned_income: 22_570 + received_allowances_savings_income: 0 + income_tax: 11_332 + +- name: Case 16, trading loss relief set against savings leaves the pay in earned income + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 40_000 + savings_interest_income: 30_000 + trading_loss: 10_000 + output: + # Net income of £60,000 leaves the full allowance and a £500 savings + # allowance. The £10,000 of relief (s. 64) and the £12,570 allowance + # share £22,570. The taxpayer sets £2,300 of the relief against pay, which + # leaves pay at the £37,700 limit, and the other £7,700 and the whole + # allowance against savings (s. 25(2)): savings of £9,730 from £37,700, + # £500 at the nil rate above the limit, where it saves 40%. Pay + # 37,700 x 20% = 7,540; savings 9,230 x 40% = 3,692. The relief against + # pay first charges 11,332. net_income deducts all the relief, so earned + # taxable income adds back the £7,700 set against savings. + trade_loss_relief_against_general_income: 10_000 + trade_loss_relief_against_savings_and_dividends: 7_700 + received_allowances_earned_income: 0 + received_allowances_savings_income: 20_270 + earned_taxable_income: 37_700 + non_savings_income_after_allowances: 37_700 + earned_income_tax: 7_540 + savings_income_tax: 3_692 + income_tax: 11_232 + +- name: Case 17, under a reform that exempts pensions the search stops pay at the exemption + period: 2026 + absolute_error_margin: 0.01 + input: + gov.hmrc.income_tax.earned_taxable_income_exclusions: + - taxable_savings_interest_income + - taxable_dividend_income + - taxable_property_income + - received_allowances_earned_income + - marriage_allowance + - taxable_pension_income + age: 40 + employment_income: 5_000 + private_pension_income: 10_000 + savings_interest_income: 30_000 + output: + # The reform takes the £10,000 pension out of earned income, so the + # model taxes non-savings income only above it. Net income of £45,000 + # leaves the full allowance and a £1,000 savings allowance. With £5,000 of + # the allowance against pay and pension, the £10,000 left is all pension, + # earned income is nil, and the savings start at nil: £22,430 of savings, + # £5,000 at the starting rate (s. 12), £1,000 at the savings nil rate + # (s. 12A) and 16,430 x 20% = 3,286. Setting more against savings makes + # each pound of pay taxable at 20% and shrinks the starting rate band by + # a pound; setting less leaves a pound more of savings at 20%. All the + # allowance against non-savings income first charges + # (30,000 - 6,000) x 20% = 4,800. + received_allowances_earned_income: 5_000 + received_allowances_savings_income: 7_570 + earned_taxable_income: 0 + savings_income_tax: 3_286 + income_tax: 3_286 + +- name: Case 18, trading loss relief past the non-savings income leaves none of it + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + self_employment_income: 30_016.63 + savings_interest_income: 27_285.95 + dividend_income: 74_747.51 + trading_loss: 33_973.11 + output: + # The £33,973.11 of relief exceeds the £30,016.63 of trading income, so + # no non-savings income is left, and the rest of the relief and the + # allowances come off savings and dividends. The model computes the non-savings income left from the + # share's own arithmetic, so float32 rounding in net income plus the + # relief added back leaves nothing above nil. + non_savings_income_after_allowances: 0 + earned_taxable_income: 0 diff --git a/policyengine_uk/tests/policy/integration/income.yaml b/policyengine_uk/tests/policy/integration/income.yaml index 55afb8a955..eb2c734fde 100644 --- a/policyengine_uk/tests/policy/integration/income.yaml +++ b/policyengine_uk/tests/policy/integration/income.yaml @@ -25,8 +25,10 @@ # rate limit, so the £500 savings allowance (s. 12B(3)) covers savings # that would otherwise bear 40% rather than 20%: £100 less tax # than the allowance all against the pay. Savings of £5,000 run from - # £37,500 to £42,500: £500 at the nil rate and £4,500 at 40%. Setting - # more against the pay or the savings would not change the tax. + # £37,500 to £42,500: £500 at the nil rate and £4,500 at 40%, so the tax + # is 7,500 + 1,800 = 9,300. Setting more against the savings leaves it at + # 9,300; setting £500 more against the pay would bring the savings + # allowance below the limit and charge 9,400. received_allowances_savings_income: 10_000 basic_rate_savings_income_pre_starter: 0 savings_starter_rate_income: 0 diff --git a/policyengine_uk/tests/test_budget_2026_savings_property_rates_properties.py b/policyengine_uk/tests/test_budget_2026_savings_property_rates_properties.py index 1de32e9688..4833c90e2f 100644 --- a/policyengine_uk/tests/test_budget_2026_savings_property_rates_properties.py +++ b/policyengine_uk/tests/test_budget_2026_savings_property_rates_properties.py @@ -28,7 +28,12 @@ The order of the allowances can change too. A Scottish taxpayer's property income stays on the Scottish rates while the savings rates rise, so the allowance can save more against savings, and then both taxes move -(test_higher_savings_rates_can_move_the_allowance_onto_savings). +(test_higher_savings_rates_can_move_the_allowance_onto_savings). For everyone, +whatever the share, income tax rises by at most 2% of the banded savings and +taxable property income under the Budget 2025 rates: the Budget 2025 share +costs at most that much more at the higher rates, and the model takes a share +that costs no more than it +(test_income_tax_rises_by_at_most_two_points_of_banded_income). """ import numpy as np @@ -62,12 +67,12 @@ st.one_of(st.floats(min_value=0, max_value=20_000), amount), # savings ) cases = st.lists(case, min_size=CASES, max_size=CASES) -# Review round 2's counterexample: the fourth person (Scotland) gives up the -# property allowance at the higher rates. +# The fourth person, a Scottish taxpayer, gives up the property allowance at +# the higher rates. ELECTION_CHANGE = [(0.0, 0.0, 0.0, 0.0)] * CASES ELECTION_CHANGE[3] = (20_000.0, 500.0, 0.87, 0.0) -# The first property run's counterexample: the fourth person (Scotland) sets -# the allowance against savings at the higher rates. +# The fourth person, a Scottish taxpayer, sets the allowance against savings at +# the higher rates. ORDER_CHANGE = [(0.0, 0.0, 0.0, 0.0)] * CASES ORDER_CHANGE[3] = (0.0, 58_000.0, 0.0, 24_000.0) @@ -116,6 +121,12 @@ def sims(): "property_finance_cost_relief", "income_tax", ] +BANDED = [ + "basic_rate_savings_income", + "higher_rate_savings_income", + "add_rate_savings_income", + "taxable_property_income_after_allowances", +] def run(sims, drawn): @@ -193,6 +204,23 @@ def test_savings_tax_rises_by_two_points_in_every_country(sims, drawn): assert (np.abs(rise - RISE * banded) <= tol)[same].all() +@SETTINGS +@given(cases) +@example(ELECTION_CHANGE) +@example(ORDER_CHANGE) +def test_income_tax_rises_by_at_most_two_points_of_banded_income(sims, drawn): + """Under the Budget 2025 share and property allowance election, the Budget + 2026 rates add 2% of the savings in the rate bands and, outside Scotland, + of the taxable property income, and the finance cost relief never falls. + The model's share and election give no more tax than that one (ITA 2007 + s. 25(2)), so the bound holds for everyone, including people whose share + the rates move.""" + law, before = run(sims, drawn) + bound = before["income_tax"] + RISE * sum(before[name] for name in BANDED) + tol = tolerance(law["income_tax"], bound) + assert (law["income_tax"] <= bound + tol).all() + + @SETTINGS @given(cases) @example(ELECTION_CHANGE) diff --git a/policyengine_uk/tests/test_income_tax_allocation.py b/policyengine_uk/tests/test_income_tax_allocation.py index 15221cf83c..5502554d4c 100644 --- a/policyengine_uk/tests/test_income_tax_allocation.py +++ b/policyengine_uk/tests/test_income_tax_allocation.py @@ -3,7 +3,7 @@ s. 25(3A); #2106). The invariants, for every person without Marriage Allowance, on the Step 5 -tax (income_tax_pre_charges), which is all the share changes: +tax (income_tax_pre_charges), the only tax the share changes: 1. Least tax: no share of the deductions between the components s. 25 lets them come off gives less Step 5 tax than the model's. A brute-force oracle @@ -288,12 +288,12 @@ def test_tax_equals_brute_force_grid_minimum(sims, year, region, incomes): model_tax = law["income_tax_pre_charges"][0] least = grid_tax[feasible].min() step = min(savings, used) / (GRID - 1) + min(dividends, used) / (GRID - 1) - # Invariant 1: the model's share is the least; the grid comes within its + # Invariant 1: the model's share gives the least tax; the grid comes within its # step of it, since a pound moved between components changes the tax by # less than a pound. assert model_tax <= least + tolerance(model_tax) assert model_tax >= least - step - tolerance(model_tax) - # Invariant 5: the search's tax function is the model's Step 5 tax. + # Invariant 5: the search's tax function equals the model's Step 5 tax. forced_c = _context(forced.populations["person"], year, parameters_of(forced)) x = (forced_c.step_3_income - forced_c.savings - forced_c.dividends)[:, 0] x = x + to_savings + to_dividends @@ -394,11 +394,13 @@ def system(): return uk_simulation.CountryTaxBenefitSystem() -def synthetic_people(seed, count=SYNTHETIC_PEOPLE, mix="free"): +def synthetic_people(seed, count=SYNTHETIC_PEOPLE, mix="free", exclusions=False): """Inputs for ``least_tax_deductions`` and the old order's deductions. "free" draws every amount from a wide mixture with values at the limits; - "realistic" gives most people pay alone and some savings or dividends.""" + "realistic" gives most people pay alone and some savings or dividends. + With ``exclusions``, half the people get an exclusion from earned income + of up to £20,000 in place of the Marriage Allowance's £1,260.""" rng = np.random.default_rng(seed) def amount(zero, scale): @@ -433,6 +435,8 @@ def amount(zero, scale): gift = np.where(rng.random(count) < 0.1, rng.uniform(0, 10_000, count), 0) scottish = rng.random(count) < 0.3 savings_allowance = rng.choice([0.0, 500.0, 1_000.0], count) + pension = np.where(rng.random(count) < 0.2, rng.uniform(0, 20_000, count), 0) + exclusion_draw = np.where(rng.random(count) < 0.5, rng.uniform(0, 20_000, count), 0) rows = np.array(EDGES, dtype=float) if mix == "free" else np.empty((0, 10)) other, property_income, savings, dividends, relief, extra, marriage, gift = ( np.concatenate([column, rows[:, i]]) @@ -451,12 +455,16 @@ def amount(zero, scale): ) scottish = np.concatenate([scottish, rows[:, 8] > 0]) savings_allowance = np.concatenate([savings_allowance, rows[:, 9]]) + pension = np.concatenate([pension, np.zeros(len(rows))]) + if exclusions: + # A reform's exclusion from earned income, such as an exempt pension. + marriage = np.concatenate([exclusion_draw, rows[:, 6]]) f32 = np.float32 before_relief = (other + property_income + savings + dividends).astype(f32) relief = relief.astype(f32) net_income = np.maximum(0, before_relief - relief) personal_allowance = np.maximum(0, 12_570 - np.maximum(0, net_income - 100_000) / 2) - allowances = (personal_allowance + extra).astype(f32) + allowances = (personal_allowance + extra + pension).astype(f32) savings, dividends = savings.astype(f32), dividends.astype(f32) default = old_order( allowances, savings, dividends, net_income, relief, before_relief @@ -468,6 +476,7 @@ def amount(zero, scale): net_income=net_income, property=property_income.astype(f32), allowances=allowances, + pension=pension.astype(f32), earned_exclusions=marriage.astype(f32), gift=gift.astype(f32), scottish=scottish, @@ -552,3 +561,174 @@ def test_fast_path_equals_full_search_when_rates_fall(system, year): for path, value in old.items(): parameters.get_child(path).update(period=str(year), value=value) system.reset_parameter_caches() + + +def vertex_search_and_grid(income_tax, inputs, points=101): + """The search's least Step 5 tax and a dense grid's least, per person, + with the grid's step.""" + c = _build(inputs, income_tax) + x, s, v = _least_tax_share(c) + found = step_5_tax(c, x, s, v)[:, 0] + total = c.step_3_income + low = np.maximum(0, total - c.savings - c.dividends) + high = np.minimum(c.first, total) + fractions = np.linspace(0, 1, points) + grid_x = (low + (high - low) * fractions[None, :]).repeat(points, axis=1) + grid_s = np.tile(c.savings * fractions[None, :], points) + grid_x, grid_s, grid_v = _shares(c, grid_x, grid_x + grid_s) + grid = step_5_tax(c, grid_x, grid_s, grid_v).min(axis=1) + step = ((high - low) / (points - 1) + c.savings / (points - 1))[:, 0] + return found, grid, step + + +@pytest.mark.parametrize("year", [2018, 2026, 2027]) +def test_vertex_search_equals_dense_grid_with_exclusions(system, year): + """Invariant 6 on synthetic people with exclusions from earned income of + up to £20,000 beside property income, as a reform that exempts pensions + gives. Before 2027-28 the exclusion splits the first component into three + pieces, each with its own lines.""" + income_tax = system.parameters(str(year)).gov.hmrc.income_tax + for seed in range(2): + inputs, _ = synthetic_people(seed, count=300, exclusions=True) + found, grid, step = vertex_search_and_grid(income_tax, inputs) + worst = np.argmax(found - grid) + assert np.all(found <= grid + 1e-5), { + name: values[worst] for name, values in inputs.items() + } | {"found": found[worst], "grid": grid[worst]} + assert np.all(found >= grid - 2 * step - 1e-5) + + +def one_person(**amounts): + """``least_tax_deductions`` inputs for one person, and the old order's + deductions, computed as the model computes them from net income and net + income before trade loss relief.""" + values = dict( + savings=0.0, + dividends=0.0, + relief=0.0, + net_income=0.0, + property=0.0, + allowances=12_570.0, + pension=0.0, + earned_exclusions=0.0, + gift=0.0, + savings_allowance=1_000.0, + ) + before_relief = amounts.pop("before_relief", None) + scottish = amounts.pop("scottish", False) + values.update(amounts) + if before_relief is None: + before_relief = values["net_income"] + values["relief"] + inputs = { + name: np.array([value], dtype=np.float64) for name, value in values.items() + } + inputs["scottish"] = np.array([scottish]) + f32 = np.float32 + default = old_order( + *( + np.array([value], dtype=f32) + for value in ( + values["allowances"], + values["savings"], + values["dividends"], + values["net_income"], + values["relief"], + before_relief, + ) + ) + ) + return inputs, default + + +def tax_saved(income_tax, inputs, default): + """The Step 5 tax under the old order less the tax under the full search's + share, in the search's own tax function.""" + full = least_tax_deductions( + inputs, income_tax, default.savings, default.dividends, search_all=True + ) + c = _build(inputs, income_tax) + + def tax(to_savings, to_dividends): + s = c.savings - np.asarray(to_savings, dtype=float)[:, None] + v = c.dividends - np.asarray(to_dividends, dtype=float)[:, None] + return step_5_tax(c, c.step_3_income - s - v, s, v)[0, 0] + + return tax(default.savings, default.dividends) - tax(*full) + + +@pytest.mark.parametrize( + "amounts, saving", + [ + # Savings of £1,000 the only income in net income before the relief, + # with £1,000 of trade loss relief, and a supplied adjusted net income + # that puts net income at £30,000. The old order sets the relief + # against the savings and leaves £17,430 of non-savings income at + # 20%; setting it against non-savings income instead leaves £16,430, + # and the £1,000 savings allowance covers the savings: £200 less. + ( + dict(savings=1_000, relief=1_000, net_income=30_000, before_relief=1_000), + 200, + ), + # Savings of £300 and £300 of relief, with net income supplied at + # £13,070: the old order leaves £500 of non-savings income at 20%, the + # search £200, with the savings in the savings allowance: £60 less. + (dict(savings=300, relief=300, net_income=13_070, before_relief=300), 60), + ], + ids=["relief-against-savings", "all-savings-used"], +) +def test_fast_path_follows_a_supplied_net_income(system, amounts, saving): + """Where a supplied adjusted net income sets net income apart from net + income before trade loss relief less the relief, the old order can set + relief against savings while non-savings income remains. The fast path + must send those people to the search.""" + income_tax = system.parameters("2026").gov.hmrc.income_tax + inputs, default = one_person(**amounts) + assert tax_saved(income_tax, inputs, default) == pytest.approx(saving, abs=1e-6) + assert_fast_path_equals_search(income_tax, inputs, default) + + +def test_fast_path_reads_the_scottish_starter_rate(system): + """A reform that lifts the top of the Scottish starter band to £9,000, + above the £5,000 starting rate limit for savings. A Scottish taxpayer with + £18,570 of pay and £20,000 of savings: the old order leaves £6,000 of pay + at the 19% starter rate, and the savings above it, past the starting rate + band, bear 20%. Moving £3,000 of the allowance to savings takes the pay to + £9,000 and saves 1% of £3,000, £30. The fast path must read the Scottish + rate on the pay, not the UK basic rate.""" + parameters = system.parameters + path = "gov.hmrc.income_tax.rates.scotland.rates[1].threshold" + parameter = parameters.get_child(path) + before = parameter("2026") + try: + parameter.update(period="2026", value=9_000) + system.reset_parameter_caches() + income_tax = parameters("2026").gov.hmrc.income_tax + inputs, default = one_person(savings=20_000, net_income=38_570, scottish=True) + assert tax_saved(income_tax, inputs, default) == pytest.approx(30, abs=1e-6) + assert_fast_path_equals_search(income_tax, inputs, default) + finally: + parameter.update(period="2026", value=before) + system.reset_parameter_caches() + + +def test_pension_relief_stays_on_non_savings_income(system): + """Pay of £40,000 with £10,000 of pension relief and £30,000 of savings in + 2026-27. The relief stays on the pay (FA 2004 ss. 192(4) and 193(2)), so + only the £12,570 allowance moves, and every share keeps the savings + allowance below the basic rate limit: the old order stands. Sharing the + pension relief would put the pay at the limit and save £100.""" + income_tax = system.parameters("2026").gov.hmrc.income_tax + inputs, default = one_person( + savings=30_000, + net_income=70_000, + allowances=22_570, + pension=10_000, + savings_allowance=500, + ) + full = least_tax_deductions( + inputs, income_tax, default.savings, default.dividends, search_all=True + ) + assert full[0][0] == 0 + assert tax_saved(income_tax, inputs, default) == 0 + shared = dict(inputs, pension=np.array([0.0])) + assert tax_saved(income_tax, shared, default) == pytest.approx(100, abs=1e-6) diff --git a/policyengine_uk/tests/test_uc_income_tax_properties.py b/policyengine_uk/tests/test_uc_income_tax_properties.py new file mode 100644 index 0000000000..1600b44762 --- /dev/null +++ b/policyengine_uk/tests/test_uc_income_tax_properties.py @@ -0,0 +1,141 @@ +"""Property tests for the income tax that Universal Credit deducts from +earnings (uc_income_tax_on_earnings and uc_income_tax_on_trade). + +The helper takes earnings as the lowest slice of non-savings income, after the +allowances set against non-savings income first, as PAYE codes set them +against the job. The least-tax share of the reliefs and allowances (ITA 2007 +s. 25(2)) can move the allowances onto savings or dividends to lower the +year's tax, but the slice follows the order non-savings, savings, dividends, +so tax on other income never comes off earnings: + +- adding savings or dividend income never raises either slice, for every + person whose allowances it leaves unchanged (more income can taper the + Personal Allowance, which raises the tax on earnings for a reason of its + own). +""" + +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") + +PEOPLE = 8 +YEARS = (2025, 2026, 2027, 2028) +REGIONS = ["LONDON", "SCOTLAND", "WALES", "NORTH_EAST"] +INCOMES = ( + "employment_income", + "self_employment_income", + "property_income", + "savings_interest_income", + "dividend_income", + "trading_loss", +) +SLICES = ("uc_income_tax_on_earnings", "uc_income_tax_on_trade") + +amount = st.one_of( + st.just(0.0), + st.floats(min_value=0, max_value=20_000), + st.floats(min_value=0, max_value=80_000), + st.sampled_from([5_000.0, 12_570.0, 37_406.0, 37_700.0, 50_270.0]), +) +person = st.tuples(*([amount] * len(INCOMES))) +extra = st.tuples(amount, amount) # savings and dividends added +people = st.lists(st.tuples(person, extra), min_size=PEOPLE, max_size=PEOPLE) +# Self-employment income of £37,406: £14,407 of savings would move the whole +# Personal Allowance onto the savings under the least-tax share. +WITNESS = [((0.0, 37_406.0, 0.0, 0.0, 0.0, 0.0), (14_407.0, 0.0))] + [ + ((0.0,) * len(INCOMES), (0.0, 0.0)) +] * (PEOPLE - 1) + + +def situation(year): + return { + "people": {f"p{i}": {"age": {year: 40}} for i in range(PEOPLE)}, + "benunits": {f"b{i}": {"members": [f"p{i}"]} for i in range(PEOPLE)}, + "households": { + f"h{i}": { + "members": [f"p{i}"], + "region": {year: REGIONS[i % len(REGIONS)]}, + } + for i in range(PEOPLE) + }, + } + + +@pytest.fixture(scope="module") +def sims(): + cache = {} + + def get(year): + if year not in cache: + cache[year] = Simulation(situation=situation(year)) + return cache[year] + + return get + + +def run(sim, year, incomes): + for name, values in incomes.items(): + sim.set_input(name, year, np.asarray(values, dtype=float)) + sim.reset_calculations() + return { + name: sim.calculate(name, year).astype(float) + for name in SLICES + ("allowances", "income_tax") + } + + +@settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +@given(st.sampled_from(YEARS), people) +@example(2025, WITNESS) +@example(2026, WITNESS) +def test_more_savings_or_dividends_never_raise_tax_on_earnings(sims, year, drawn): + base_people, added = zip(*drawn) + incomes = { + name: np.array(values) for name, values in zip(INCOMES, zip(*base_people)) + } + savings, dividends = (np.array(values) for values in zip(*added)) + sim = sims(year) + before = run(sim, year, incomes) + more = dict(incomes) + more["savings_interest_income"] = incomes["savings_interest_income"] + savings + more["dividend_income"] = incomes["dividend_income"] + dividends + after = run(sim, year, more) + same_allowances = np.abs(after["allowances"] - before["allowances"]) <= 0.01 + for name in SLICES: + rise = after[name] - before[name] + tolerance = 0.01 + 1e-6 * np.abs(before[name]) + assert (rise <= tolerance)[same_allowances].all(), ( + name, + rise[same_allowances].max(), + ) + + +def test_savings_leave_the_tax_on_self_employment_unchanged(sims): + """Self-employment income of £37,406, alone and with £14,407 of savings, + in 2026-27. With the savings, the least-tax share sets the whole Personal + Allowance against them, and income tax falls from £8,057.20 under the + order non-savings, savings, dividends to £8,016. The tax on the trade + stays at (37,406 - 12,570) x 20% = £4,967.20, as without the savings.""" + year = 2026 + sim = sims(year) + zeros = np.zeros(PEOPLE) + incomes = {name: zeros.copy() for name in INCOMES} + incomes["self_employment_income"][0] = 37_406 + alone = run(sim, year, incomes) + incomes["savings_interest_income"][0] = 14_407 + with_savings = run(sim, year, incomes) + assert alone["uc_income_tax_on_trade"][0] == pytest.approx(4_967.2, abs=0.01) + assert with_savings["uc_income_tax_on_trade"][0] == pytest.approx(4_967.2, abs=0.01) + assert with_savings["uc_income_tax_on_earnings"][0] == pytest.approx( + 4_967.2, abs=0.01 + ) + assert with_savings["income_tax"][0] == pytest.approx(8_016, abs=0.01) diff --git a/policyengine_uk/utils/income_tax_allocation.py b/policyengine_uk/utils/income_tax_allocation.py index d699e7d4f3..71cbd784d4 100644 --- a/policyengine_uk/utils/income_tax_allocation.py +++ b/policyengine_uk/utils/income_tax_allocation.py @@ -1,76 +1,98 @@ """Set Income Tax reliefs and allowances against the components of income. -At Steps 2 and 3 of the Income Tax calculation (ITA 2007 s. 23), s. 25(2) +At Steps 2 and 3 of the Income Tax calculation (ITA 2007 s. 23), the taxpayer deducts reliefs and allowances "in the way which will result in the greatest -reduction in the taxpayer's liability to income tax". From 2027-28, s. 25(3A) -(Finance Act 2026 s. 6(6) and (8)) deducts them from income other than -property, savings and dividend income first, and s. 25(2) then chooses between -property, savings and dividend income. Before 2027-28, s. 25(2) chooses between -non-savings, savings and dividend income. Property income is non-savings income -taxed at the same rates in those years, so the deductions come off the rest of -non-savings income first, as they always have in the model. +reduction in the taxpayer's liability to income tax" (s. 25(2)). From +2027-28, the taxpayer deducts them from income other than property, savings +and dividend income first (s. 25(3A), inserted by Finance Act 2026 s. 6(6) +with effect from 2027-28 under s. 6(8)), and then chooses between property, +savings and dividend income (s. 25(2)). Before 2027-28, the taxpayer chooses +between non-savings, savings and dividend income (s. 25(2)). In those years +Parliament taxes property income as non-savings income at the same rates, so +the model takes the deductions off the rest of non-savings income first, as +it always has. The model deducts trade loss relief against general income at Step 2 and the -amounts in ``allowances`` (the personal and blind person's allowances, gifts of -investments to charity, covenanted payments, other deductions and pension -relief). Together they are one sum to share between the components. Each part -of it can come off any component, and the tax depends only on how much comes -off each, so the search shares out the sum. +amounts in ``allowances`` (the personal and blind person's allowances, gifts +of investments to charity, covenanted payments, other deductions and pension +relief). The search shares their sum between the components. Each part of it +can come off any component, and the tax depends only on how much comes off +each, so the search shares out the sum. + +Pension relief is the exception. The model takes employee contributions as a +net pay arrangement, which the employer deducts from employment income (FA +2004 s. 193(2)), and personal contributions as relief at source, which +extends the rate bands (s. 192(4)). Neither is a Step 2 deduction the +taxpayer can set against savings or dividends, so the search holds +``min(pension relief, non-savings income)`` on non-savings income and shares +only the rest. How the search finds the least tax ---------------------------------- Three components share what s. 25(3A) leaves: the first (non-savings income -before 2027-28, property income after), savings and dividends. Write ``x`` for -the first component left after deductions, ``w`` for ``x`` plus the savings -left, and ``T`` for the three together after deductions, which the sharing -does not change. The dividends left are ``T - w``. Savings sit above -non-savings income and dividends above savings (ss. 16 and 16A), so with ``q`` -the non-savings income below the savings (``x``, or property plus the other -non-savings income left before it from 2027-28): +before 2027-28, property income after), savings and dividends. Write ``x`` +for the first component left after deductions, ``w`` for ``x`` plus the +savings left, and ``T - w`` for the dividends left, where ``T``, the three +together after deductions, stays the same in every share. Savings sit above +non-savings income and dividends above savings (ss. 16 and 16A), so with +``q`` the non-savings income below the savings (``x``, or property plus the +other non-savings income left before it from 2027-28): - tax on the first component changes slope where ``x`` crosses a rate limit; -- the starting rate for savings (s. 12) and the savings nil rate (s. 12A) cover - the savings from ``q`` up to ``max(q, L) + A``, where ``L`` is the starting - rate limit and ``A`` the personal savings allowance. That changes slope where - ``q`` reaches ``L``, where ``max(q, L) + A`` crosses a rate limit, and where - ``w`` reaches the top of that band: ``w - x = A`` above ``L``, ``w`` constant - below it; +- the starting rate for savings (s. 12) and the savings nil rate (s. 12A) + cover the savings from ``q`` up to ``max(q, L) + A``, where ``L`` stands + for the starting rate limit and ``A`` for the personal savings allowance. + That changes slope where ``q`` reaches ``L``, where ``max(q, L) + A`` + crosses a rate limit, and where ``w`` reaches the top of that band: + ``w - x = A`` above ``L``, ``w`` constant below it; - the rest of the savings change slope where their top, ``w``, crosses a rate limit; -- the dividend nil rate (s. 13A) covers the lowest dividend allowance ``DA`` of - the dividends, so their tax changes slope where ``w + DA`` crosses a dividend - rate limit or reaches ``T``. +- the dividend nil rate (s. 13A) covers the lowest dividend allowance ``DA`` + of the dividends, so their tax changes slope where ``w + DA`` crosses a + dividend rate limit or reaches ``T``. The limits on the components add ``x = 0`` and ``x`` equal to the whole first component, savings left from nil to all (``w - x = 0`` and ``w - x`` equal to -the savings), and dividends left from nil to all (``w = T`` and ``w = T`` less -the dividends). So the Step 5 tax is continuous and linear on each piece of the -feasible polygon cut out by vertical lines (``x`` constant), horizontal lines -(``w`` constant) and diagonal lines (``w - x`` constant). A linear function -takes its least value on a polygon at a corner, so the least tax is at a point -where two of these lines cross. ``_candidates`` lists every such crossing, and -the search evaluates the tax at all of them for all people at once. - -That argument needs the starting rate limit at or below the basic rate limit -and a phase-out of one pound of starting rate band for each pound of -non-savings income (s. 12), as in every year modelled. The search keeps the +the savings), and dividends left from nil to all (``w = T`` and ``w = T`` +less the dividends). So the Step 5 tax runs continuous and linear on each +piece of the feasible polygon that vertical lines (``x`` constant), +horizontal lines (``w`` constant) and diagonal lines (``w - x`` constant) +cut. A linear function takes its least value on a polygon at a corner, so the +least tax lies where two of these lines cross. ``_candidates`` lists every +such crossing, and the search evaluates the tax at all of them for all people +at once. + +Before 2027-28, a reform can add an exclusion ``E`` from earned income. With +property income ``P``, the savings then sit on ``q = x`` up to ``x = P``, on +``q = P`` up to ``x = P + E``, and on ``q = x - E`` beyond. ``_candidates`` +adds the lines of each of those pieces: the two boundaries, the limits moved +by ``P`` and ``E``, and the diagonal lines that a fixed ``q = P`` gives. + +That argument needs the starting rate limit at or below the basic rate limit, +a phase-out of one pound of starting rate band for each pound of non-savings +income (s. 12) and a first UK rate threshold at nil, as in every year +modelled. A reform that sets that threshold above nil leaves savings below it +untaxed and adds lines on which ``x + w`` stays constant, which the search +doesn't list, so the search can then miss the least tax; the fast path sends +everyone to the search under such a reform. The search keeps the order non-savings, savings, dividends unless another share gives less tax, so it never charges more than that order. The search ignores the Marriage Allowance. Parliament gives it as a tax -reduction (s. 55B(1)), which Step 6 deducts, not as a deduction at Step 3, and -whether a person qualifies for it (s. 55B(2)(b)) depends on the share. The -model still subtracts the allowance from earned income (#1947), which moves a -recipient's savings and dividends down by up to that amount, so for a -recipient the search's tax can differ from the model's. +reduction (s. 55B(1)), which the taxpayer deducts at Step 6, not as a +deduction at Step 3, and whether a person qualifies for it (s. 55B(2)(b)) +depends on the share. The model still subtracts the allowance from earned +income (#1947), which moves a recipient's savings and dividends down by up to +that amount, so for a recipient the search's tax can differ from the model's. Who cannot gain --------------- Most people cannot gain from another share, and ``_old_order_is_least`` finds them without a search, so the search runs only on the rest. -1. One share: with no deduction left for s. 25(2) to place, or no income left - after the deductions, only one share exists, and the old order takes it. +1. One share: with no deduction left for the taxpayer to place under + s. 25(2), or no income left after the deductions, only one share exists, + and the old order takes it. Tests 2 and 3 need the rates to rise with income in every scale, as Parliament has set them, and the old order to set every shared deduction against the @@ -88,18 +110,18 @@ moved pounds can reach above the nil rates. The tax then rises by at least ``(rho_n - sigma_s) dx + (rho_s - sigma_v) dw`` for any move with ``0 <= dw <= dx``, or ``rho_n dx - sigma_v dw`` where the old order charges - no savings above the nil rates. Where that is never negative, the old order - gives the least tax. The top of the nil rates moves by at most ``dx``, - because the starting rate band shrinks by at most a pound for each pound of - non-savings income (s. 12) and the savings and dividend nil rates cover - fixed amounts (ss. 12A and 13A). Before 2027-28 the test also needs no - property income or no exclusion from earned income that the search reads: - with property income, such an exclusion breaks the rising rate on the - first component. + no savings above the nil rates. Where that bound never falls below zero, + the old order gives the least tax. The top of the nil rates moves by at + most ``dx``, because the starting rate band shrinks by at most a pound for + each pound of non-savings income (s. 12) and the savings and dividend nil + rates cover fixed amounts (ss. 12A and 13A). Before 2027-28 the test also + needs no property income or no exclusion from earned income that the + search reads: with property income, such an exclusion breaks the rising + rate on the first component. Where any test holds, the search would keep the old order too, since it -changes the share only for a saving above ``TOLERANCE``. So the result is the -same, and ``test_income_tax_allocation.py`` checks that it is. +changes the share only for a saving above ``TOLERANCE``. So both paths give +the same result, and ``test_income_tax_allocation.py`` checks it. """ from types import SimpleNamespace @@ -111,7 +133,8 @@ # A share replaces the order non-savings, savings, dividends only where it # saves more than this (in pounds), so rounding never moves a deduction. TOLERANCE = 1e-3 -# Shares whose tax differs by less than this (in pounds) are equally good. +# The search treats shares whose tax differs by less than this (in pounds) +# as equally good. TIE = 1e-6 # Bound on people times candidates evaluated at once, to bound memory. CHUNK = 2_000_000 @@ -167,6 +190,7 @@ def values(variable): net_income=values("net_income"), property=values("taxable_property_income"), allowances=values("allowances"), + pension=values("pension_contributions_relief"), earned_exclusions=sum( (values(variable) for variable in exclusions), np.zeros_like(savings) ), @@ -192,8 +216,18 @@ def _build(inputs, income_tax): # net_income, not its components, so the search follows a supplied adjusted # net income or a reform that sets net income, as the default order does. non_savings = np.maximum(0, columns["net_income"] + relief - savings - dividends) + # Net pay comes off employment income (FA 2004 s. 193(2)) and relief at + # source extends the bands (s. 192(4)), so pension relief stays on + # non-savings income as far as it reaches; the search shares the rest. + # allowances includes the relief, so it never holds more than that. + pension = np.clip( + columns.get("pension", np.zeros_like(relief)), + 0, + np.minimum(np.maximum(0, columns["allowances"]), non_savings), + ) + non_savings = non_savings - pension property_income = np.minimum(np.maximum(0, columns["property"]), non_savings) - deductions = relief + columns["allowances"] + deductions = relief + columns["allowances"] - pension used = np.minimum(deductions, non_savings + savings + dividends) gift = columns["gift"] uk_thresholds = rates.uk.thresholds @@ -334,9 +368,10 @@ def step_5_tax(c, x, s, v): def _candidates(c): """The points where two of the lines the module docstring lists cross.""" zero = np.zeros_like(c.savings) - offset = c.offset low, basic_limit, higher_limit = c.bands + band_limits = (basic_limit, higher_limit) allowance = c.savings_allowance + dividend_allowance = c.dividend_allowance if c.other_first: first_limits = [ np.where(c.scottish, scottish, ruk) - c.other_left @@ -349,22 +384,40 @@ def _candidates(c): np.where(c.scottish, scottish, ruk) for scottish, ruk in _paired_limits(c.scotland[0], c.uk[0], zero) ] - vertical = [zero, c.first, c.starting_limit - offset + zero] - vertical += [limit - allowance - offset for limit in (basic_limit, higher_limit)] - vertical += first_limits total = c.step_3_income - horizontal = [ - total - c.dividends, - total, - total - c.dividend_allowance, - c.starting_limit + allowance - offset, - basic_limit - offset, - higher_limit - offset, - ] - horizontal += [ - limit - c.dividend_allowance - offset + zero for limit in c.dividend_scale[0] - ] + vertical = [zero, c.first] + first_limits + horizontal = [total - c.dividends, total, total - dividend_allowance] diagonal = [zero, c.savings, allowance] + # The non-savings income below the savings is x plus each offset. + offsets = [c.offset] + if not c.other_first and np.any(c.earned_exclusions > 0): + # Before 2027-28 an exclusion E from earned income splits x into three + # pieces: q = x up to the property income P, q = P up to P + E, and + # q = x - E beyond. Tax on the property income changes slope where x + # or x - P crosses a limit, and tax on earned income where x - P - E + # does. With q fixed at P, the savings band and dividend lines run + # diagonal, and the top of the dividends moves with x. + property_income, exclusion = c.property, c.earned_exclusions + offsets.append(-exclusion) + vertical += [property_income, property_income + exclusion] + vertical += [property_income + limit for limit in first_limits] + vertical += [property_income + exclusion + limit for limit in first_limits] + vertical += [property_income + total - limit for limit in c.dividend_scale[0]] + starting = np.maximum(0, c.starting_limit - property_income * c.phase_out) + diagonal += [limit - property_income for limit in band_limits] + diagonal += [starting + allowance] + diagonal += [ + limit - dividend_allowance - property_income + for limit in c.dividend_scale[0] + ] + for offset in offsets: + vertical += [c.starting_limit - offset + zero] + vertical += [limit - allowance - offset for limit in band_limits] + horizontal += [c.starting_limit + allowance - offset] + horizontal += [limit - offset + zero for limit in band_limits] + horizontal += [ + limit - dividend_allowance - offset + zero for limit in c.dividend_scale[0] + ] xs = np.concatenate([np.broadcast_to(v, zero.shape) for v in vertical], axis=1) ws = np.concatenate([np.broadcast_to(h, zero.shape) for h in horizontal], axis=1) ds = np.concatenate([np.broadcast_to(d, zero.shape) for d in diagonal], axis=1) @@ -557,8 +610,8 @@ def _old_order_is_least(c, to_savings, to_dividends): def allocate_deductions(person, period, parameters): """Deductions against savings and dividend income in the share that gives the least Income Tax (ITA 2007 s. 25(2) and (3A)). Where no share gives less - tax than the order non-savings, savings, dividends, these are that order's - deductions exactly.""" + tax than the order non-savings, savings, dividends, the function returns + that order's deductions exactly.""" default = default_order_deductions(person, period) income_tax = parameters(period).gov.hmrc.income_tax savings, dividends = least_tax_deductions( @@ -604,13 +657,17 @@ def least_tax_deductions( def non_savings_deductions(person, period): - """Allowances against non-savings income beyond trade loss relief, and the - relief set against savings and dividends, once received_allowances_savings_income - and received_allowances_dividend_income have taken their share. + """Allowances against non-savings income beyond trade loss relief, the + relief set against savings and dividends, and the non-savings income left + after the deductions, once received_allowances_savings_income and + received_allowances_dividend_income have taken their share. The relief comes off non-savings income first, as far as the share sets - anything against it, then the allowances. Where the share is the order - non-savings, savings, dividends, these are that order's amounts exactly. + anything against it, then the allowances. Where the share keeps the order + non-savings, savings, dividends, the function returns that order's + amounts exactly. Elsewhere it takes the non-savings income left from the + share's own arithmetic, in float64, so float32 rounding in a sum of + net income and the relief added back leaves no residue above nil. """ default = default_order_deductions(person, period) to_savings = person("received_allowances_savings_income", period) @@ -621,13 +678,14 @@ def non_savings_deductions(person, period): relief = person("trade_loss_relief_against_general_income", period).astype( np.float64 ) - non_savings = np.maximum( - 0, person("net_income", period) + relief - savings - dividends - ) - used = np.minimum( - relief + person("allowances", period), non_savings + savings + dividends - ) + net_income = person("net_income", period) + allowances = person("allowances", period) + non_savings = np.maximum(0, net_income + relief - savings - dividends) + used = np.minimum(relief + allowances, non_savings + savings + dividends) to_non_savings = used - to_savings - to_dividends + # The old order: net income less savings, dividends and allowances, as the + # model has always computed it. + default_left = np.maximum(0, net_income - savings - dividends - allowances) return SimpleNamespace( earned=np.where(moved, np.maximum(0, to_non_savings - relief), default.earned), relief_against_savings_and_dividends=np.where( @@ -635,4 +693,7 @@ def non_savings_deductions(person, period): np.maximum(0, relief - to_non_savings), default.relief_against_savings_and_dividends, ), + non_savings_left=np.where( + moved, np.maximum(0, non_savings - to_non_savings), default_left + ), ) diff --git a/policyengine_uk/utils/uc_income_tax.py b/policyengine_uk/utils/uc_income_tax.py index 99aaf14990..70ea63aba6 100644 --- a/policyengine_uk/utils/uc_income_tax.py +++ b/policyengine_uk/utils/uc_income_tax.py @@ -10,6 +10,7 @@ calc_income_tax_with_gift_aid, first_extended_scottish_threshold, ) +from policyengine_uk.utils.income_tax_allocation import default_order_deductions def uc_income_tax_on_earnings_slice(person, period, parameters, include_trade=True): @@ -23,15 +24,16 @@ def uc_income_tax_on_earnings_slice(person, period, parameters, include_trade=Tr # Reg. 55(5)(b) deducts income tax paid by the person "in respect of # the employment" and reg. 57(2) step 3 income tax paid "in respect # of any trade". Neither says how to split a person's tax when they - # also have other income. Earnings are taken as the lowest slice of - # their non-savings income, after the allowances they actually have: - # savings and dividends sit above earnings and other non-savings - # income (as in ITA 2007 s. 16), property income above the rest of - # it (as in s. 16A from 2027-28), and other non-savings income - # (private pensions, State Pension, taxable benefits) above - # earnings. So tax on other income never comes off earnings. Under RTI, DWP deducts the PAYE - # actually taken on the job, which can include tax on a State - # Pension coded against it; that is not modelled. + # also have other income. The helper takes earnings as the lowest + # slice of their non-savings income, after the allowances set against + # non-savings income first: savings and dividends sit above earnings + # and other non-savings income (as in ITA 2007 s. 16), property + # income above the rest of it (as in s. 16A from 2027-28), and other + # non-savings income (private pensions, State Pension, taxable + # benefits) above earnings. So tax on other income never comes off + # earnings. Under RTI, DWP deducts the PAYE actually taken on the job, + # which can include tax on a State Pension coded against it; the + # model leaves that out. p = parameters(period) income_tax = p.gov.hmrc.income_tax # earned_taxable_income is net income less the incomes and @@ -56,18 +58,44 @@ def uc_income_tax_on_earnings_slice(person, period, parameters, include_trade=Tr period, [variable for variable in earnings_components if variable not in exclusions], ) - # The income left in earned_taxable_income before allowances. The - # part of it above earnings is the person's other non-savings income. - # Trade loss relief comes off that other income first; net_income - # deducts all of it, so add back the part set against savings and - # dividends, as earned_taxable_income does. + # The slice follows the order non-savings, savings, dividends, not the + # least-tax share of the reliefs and allowances (ITA 2007 s. 25(2)): + # PAYE codes set the allowances against the job, and moving them onto + # savings or dividends to lower the year's tax would otherwise raise + # the tax on earnings. So the earnings slice reads the old order's + # deductions, and more savings or dividends never raise it. + default = default_order_deductions(person, period) + # The income left in earned taxable income before allowances, under + # that order. The part of it above earnings is the person's other + # non-savings income. Trade loss relief comes off that other income + # first; net_income deducts all of it, so add back the part the order + # sets against savings and dividends. income_in_base = ( person("net_income", period) - + person("trade_loss_relief_against_savings_and_dividends", period) + + default.relief_against_savings_and_dividends - add(person, period, excluded_incomes) ) other_income = max_(0, income_in_base - earnings) - taxable_earnings = max_(0, person("earned_taxable_income", period) - other_income) + # Earned taxable income under that order: the exclusions with the old + # order's allowances against non-savings income in place of the share's. + excluded = add( + person, + period, + [ + variable + for variable in exclusions + if variable != "received_allowances_earned_income" + ], + ) + if "received_allowances_earned_income" in exclusions: + excluded = excluded + default.earned + earned_taxable = max_( + 0, + person("net_income", period) + + default.relief_against_savings_and_dividends + - excluded, + ) + taxable_earnings = max_(0, earned_taxable - other_income) rates = income_tax.rates gross_gift = person("gift_aid_grossed_up", period) tax = where( diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_earned_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_earned_income.py index 62bf03a92d..00fd878f87 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_earned_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_earned_income.py @@ -10,7 +10,7 @@ class received_allowances_earned_income(Variable): "The part of the allowances that Income Tax deducts from non-savings, " "non-dividend income, property income included. Trade loss relief " "against general income comes off that income first, inside " - "net_income, so this is whatever the share in " + "net_income, so it holds what the share in " "received_allowances_savings_income sets against that income beyond " "the relief." ) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_savings_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_savings_income.py index 8d0326ef64..4f306ce65a 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_savings_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_savings_income.py @@ -8,10 +8,11 @@ class received_allowances_savings_income(Variable): label = "Allowances and reliefs deducted from savings income" documentation = ( "The part of the allowances and trade loss relief against general " - "income that Income Tax deducts from savings income. ITA 2007 s. 25(2) " - "deducts reliefs and allowances in the way that gives the least tax, " - "and from 2027-28 s. 25(3A) deducts them from income other than " - "property, savings and dividend income first. " + "income that Income Tax deducts from savings income. The taxpayer " + "deducts reliefs and allowances in the way that gives the least tax " + "(ITA 2007 s. 25(2)), and from 2027-28 deducts them from income other " + "than property, savings and dividend income first (s. 25(3A)). Pension " + "relief stays on non-savings income as far as it reaches. " "policyengine_uk.utils.income_tax_allocation finds that share." ) unit = GBP diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/earned_taxable_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/earned_taxable_income.py index 40900b49b1..3da7a2609d 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/earned_taxable_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/earned_taxable_income.py @@ -1,5 +1,13 @@ from policyengine_uk.model_api import * +# The exclusions that take earned income down to the non-savings income left +# after the allowances. +NON_SAVINGS_BASE = ( + "taxable_savings_interest_income", + "taxable_dividend_income", + "received_allowances_earned_income", +) + class earned_taxable_income(Variable): value_type = float @@ -23,4 +31,10 @@ def formula(person, period, parameters): "trade_loss_relief_against_savings_and_dividends", period ) exclusions = add(person, period, exclusion_list) - return max_(0, income - exclusions) + earned = max_(0, income - exclusions) + if all(variable in exclusion_list for variable in NON_SAVINGS_BASE): + # Earned income is part of the non-savings income left after + # the allowances, which the allocation computes without the + # float32 rounding of the sum above. + return min_(earned, person("non_savings_income_after_allowances", period)) + return earned 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 index e1bb768e06..f5c5467c75 100644 --- 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 @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.income_tax_allocation import non_savings_deductions class non_savings_income_after_allowances(Variable): @@ -13,8 +14,10 @@ class non_savings_income_after_allowances(Variable): "(ITA 2007 s. 16) after Steps 2 and 3. The taxpayer sets the reliefs " "and allowances against it in the way that gives the least tax (ITA " "2007 s. 25(2)), and from 2027-28 against the rest of it before " - "property income (s. 25(3A)). property_income_tax and " - "taxable_property_income_after_allowances both read it." + "property income (s. 25(3A)). The allocation computes it from its own " + "share, without the float32 rounding of a sum that adds the relief " + "back. property_income_tax, taxable_property_income_after_allowances " + "and earned_taxable_income read it." ) definition_period = YEAR reference = [ @@ -30,16 +33,11 @@ class non_savings_income_after_allowances(Variable): unit = GBP def formula(person, period, parameters): - return max_( - 0, - # Not adjusted_net_income: it deducts the grossed-up Gift Aid - # gift (ITA 2007 s. 58), which raises the rate limits instead - # (s. 414(2)(b)) and is no deduction from taxable income. - # net_income deducts all trade loss relief, so add back the part - # set against savings and dividends (s. 25). - person("net_income", period) - + person("trade_loss_relief_against_savings_and_dividends", period) - - person("taxable_savings_interest_income", period) - - person("taxable_dividend_income", period) - - person("received_allowances_earned_income", period), - ) + # Not adjusted_net_income: it deducts the grossed-up Gift Aid gift + # (ITA 2007 s. 58), which raises the rate limits instead + # (s. 414(2)(b)) and is no deduction from taxable income. The + # allocation takes net income, adds back the trade loss relief that + # the share sets against savings and dividends (s. 25), and deducts + # savings, dividends and the reliefs and allowances the share sets + # against non-savings income. + return non_savings_deductions(person, period).non_savings_left diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_savings_and_dividends.py b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_savings_and_dividends.py index b9e147f958..57183792c2 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_savings_and_dividends.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_savings_and_dividends.py @@ -8,14 +8,15 @@ class trade_loss_relief_against_savings_and_dividends(Variable): label = "Trade loss relief deducted from savings and dividend income" documentation = ( "The part of trade loss relief against general income that Income Tax " - "deducts from savings and dividend income. ITA 2007 s. 25(2) deducts " - "reliefs and allowances in the way that gives the least tax, and from " - "2027-28 s. 25(3A) deducts them from income other than property, " - "savings and dividend income first; received_allowances_savings_income " - "and received_allowances_dividend_income hold that share. The relief " - "comes off non-savings income first, as far as the share sets anything " - "against that income, and this is the rest. net_income deducts the " - "whole relief, so the non-savings bases add this back." + "deducts from savings and dividend income. The taxpayer deducts " + "reliefs and allowances in the way that gives the least tax (ITA 2007 " + "s. 25(2)), and from 2027-28 deducts them from income other than " + "property, savings and dividend income first (s. 25(3A)); " + "received_allowances_savings_income and " + "received_allowances_dividend_income hold that share. The relief comes " + "off non-savings income first, as far as the share sets anything " + "against that income, and this variable holds the rest. net_income " + "deducts the whole relief, so the non-savings bases add this back." ) definition_period = YEAR unit = GBP From def18d3a3043d1b65e74de2a69b169d4f0a550db Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 16:01:05 +0000 Subject: [PATCH 15/21] Rename the property income band base test in the shard durations The branch renamed test_more_property_income_never_lowers_savings_or_dividend_tax to test_more_property_income_never_lowers_income_tax, and the checked-in durations still named the old test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6JoK8gQvt3JZzVN18g --- policyengine_uk/tests/shard_durations.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/policyengine_uk/tests/shard_durations.toml b/policyengine_uk/tests/shard_durations.toml index 2c52eb6701..7adf4b7794 100644 --- a/policyengine_uk/tests/shard_durations.toml +++ b/policyengine_uk/tests/shard_durations.toml @@ -285,7 +285,7 @@ "policyengine_uk/tests/test_trading_loss_properties.py::test_zero_profit_trade_loss_cannot_raise_tax_or_reduce_programme_award[tax_credits-2024]" = 65 "policyengine_uk/tests/test_legacy_award_readers_properties.py::test_other_members_awards_never_change_the_readers_for_young_singles" = 65 "policyengine_uk/tests/test_carer_overlapping_benefits_properties.py::test_claiming_a_carer_benefit_reduced_to_nil_increases_pension_credit" = 65 -"policyengine_uk/tests/test_property_income_band_base_properties.py::test_more_property_income_never_lowers_savings_or_dividend_tax" = 65 +"policyengine_uk/tests/test_property_income_band_base_properties.py::test_more_property_income_never_lowers_income_tax" = 65 "policyengine_uk/tests/test_legacy_award_readers_properties.py::test_claimant_or_partner_awards_bounded_by_benefit_unit_awards" = 65 "policyengine_uk/tests/test_labour_supply_response_baseline.py::test_no_policy_change_gives_no_response" = 65 "policyengine_uk/tests/test_legacy_award_readers_properties.py::test_other_members_awards_never_change_the_readers_for_working_parents" = 64 From 70633f0897957186ff9128aac91eb98ea9d7c5ec Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 17:40:48 +0000 Subject: [PATCH 16/21] State the relief at source law and the search's limits as run Quote what FA 2004 s. 192(1) and (4) give for relief at source in the allocation's docstring and comment, YAML case 15 and the pension test. Give the first-threshold sample's counts, bound the float32 residue at under a penny, name the model's choice of PAYE coding for the Universal Credit slice, and note that non_savings_taxable_income reads non_savings_income_after_allowances through its two parts. No formula changes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01N2tyHPRRwcYXiPudqKR4bf --- .../least_tax_allocation.yaml | 11 +++--- .../tests/test_income_tax_allocation.py | 11 +++--- .../tests/test_uc_income_tax_properties.py | 11 +++--- .../utils/income_tax_allocation.py | 35 ++++++++++++------- policyengine_uk/utils/uc_income_tax.py | 9 ++--- .../non_savings_income_after_allowances.py | 3 +- 6 files changed, 49 insertions(+), 31 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml index ba8b340e63..93084b4b88 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml @@ -314,10 +314,13 @@ personal_pension_contributions: 10_000 savings_interest_income: 30_000 output: - # The model takes personal contributions as relief at source, which - # extends the rate bands (FA 2004 s. 192(4)) rather than giving a Step 2 - # deduction the taxpayer can set against savings. The model deducts the - # relief from non-savings income, so the tax matches case 14. + # The model takes personal contributions as relief at source. Parliament + # deducts nothing from income: the payer keeps basic rate tax on the + # contribution (FA 2004 s. 192(1)) and, on a claim, the basic and higher + # rate limits rise by the contribution (s. 192(4)). Under the law the + # liability is £13,332 and the payer keeps £2,000; the model deducts the + # relief from non-savings income instead, which gives the same net + # £11,332, as in case 14. pension_contributions_relief: 10_000 received_allowances_earned_income: 22_570 received_allowances_savings_income: 0 diff --git a/policyengine_uk/tests/test_income_tax_allocation.py b/policyengine_uk/tests/test_income_tax_allocation.py index 5502554d4c..d4d2b64896 100644 --- a/policyengine_uk/tests/test_income_tax_allocation.py +++ b/policyengine_uk/tests/test_income_tax_allocation.py @@ -713,10 +713,13 @@ def test_fast_path_reads_the_scottish_starter_rate(system): def test_pension_relief_stays_on_non_savings_income(system): """Pay of £40,000 with £10,000 of pension relief and £30,000 of savings in - 2026-27. The relief stays on the pay (FA 2004 ss. 192(4) and 193(2)), so - only the £12,570 allowance moves, and every share keeps the savings - allowance below the basic rate limit: the old order stands. Sharing the - pension relief would put the pay at the limit and save £100.""" + 2026-27. The relief stays on the pay: the employer deducts net pay + contributions from employment income (FA 2004 s. 193(2)), and for relief + at source Parliament raises the basic and higher rate limits instead + (s. 192(4)). So only the £12,570 allowance moves, and every share keeps + the savings allowance below the basic rate limit: the old order stands. + Sharing the pension relief would put the pay at the limit and save + £100.""" income_tax = system.parameters("2026").gov.hmrc.income_tax inputs, default = one_person( savings=30_000, diff --git a/policyengine_uk/tests/test_uc_income_tax_properties.py b/policyengine_uk/tests/test_uc_income_tax_properties.py index 1600b44762..97f4b05284 100644 --- a/policyengine_uk/tests/test_uc_income_tax_properties.py +++ b/policyengine_uk/tests/test_uc_income_tax_properties.py @@ -2,11 +2,12 @@ earnings (uc_income_tax_on_earnings and uc_income_tax_on_trade). The helper takes earnings as the lowest slice of non-savings income, after the -allowances set against non-savings income first, as PAYE codes set them -against the job. The least-tax share of the reliefs and allowances (ITA 2007 -s. 25(2)) can move the allowances onto savings or dividends to lower the -year's tax, but the slice follows the order non-savings, savings, dividends, -so tax on other income never comes off earnings: +allowances set against non-savings income first: the model follows PAYE +coding, which sets them against the job. The least-tax share of the reliefs +and allowances (ITA 2007 s. 25(2)) can move the allowances onto savings or +dividends to lower the year's tax, but the slice follows the order +non-savings, savings, dividends, so tax on other income never comes off +earnings: - adding savings or dividend income never raises either slice, for every person whose allowances it leaves unchanged (more income can taper the diff --git a/policyengine_uk/utils/income_tax_allocation.py b/policyengine_uk/utils/income_tax_allocation.py index 71cbd784d4..834a47c799 100644 --- a/policyengine_uk/utils/income_tax_allocation.py +++ b/policyengine_uk/utils/income_tax_allocation.py @@ -21,11 +21,15 @@ Pension relief is the exception. The model takes employee contributions as a net pay arrangement, which the employer deducts from employment income (FA -2004 s. 193(2)), and personal contributions as relief at source, which -extends the rate bands (s. 192(4)). Neither is a Step 2 deduction the -taxpayer can set against savings or dividends, so the search holds -``min(pension relief, non-savings income)`` on non-savings income and shares -only the rest. +2004 s. 193(2)), and personal contributions as relief at source, under which +the payer keeps basic rate tax on the contribution (s. 192(1)) and, on a +claim where higher or additional rate tax is chargeable, Parliament raises +the basic and higher rate limits by the contribution (s. 192(4)). Deducting +the contribution from the bottom of non-savings income moves every slice of +income down against those limits, as raising them does. Neither arrangement +gives a Step 2 deduction the taxpayer can set against savings or dividends, +so the search holds ``min(pension relief, non-savings income)`` on +non-savings income and shares only the rest. How the search finds the least tax ---------------------------------- @@ -73,10 +77,12 @@ income (s. 12) and a first UK rate threshold at nil, as in every year modelled. A reform that sets that threshold above nil leaves savings below it untaxed and adds lines on which ``x + w`` stays constant, which the search -doesn't list, so the search can then miss the least tax; the fast path sends -everyone to the search under such a reform. The search keeps the -order non-savings, savings, dividends unless another share gives less tax, so -it never charges more than that order. +doesn't list, so the search can then miss the least tax. On 6,520 synthetic +people with that threshold at £2,000, a 101 x 101 grid found less tax than +the search for 32 in 2026-27 (by up to £181.70) and 38 in 2027-28 (by up to +£214.75). The fast path sends everyone to the search under such a reform. +The search keeps the order non-savings, savings, dividends unless another +share gives less tax, so it never charges more than that order. The search ignores the Marriage Allowance. Parliament gives it as a tax reduction (s. 55B(1)), which the taxpayer deducts at Step 6, not as a @@ -216,9 +222,11 @@ def _build(inputs, income_tax): # net_income, not its components, so the search follows a supplied adjusted # net income or a reform that sets net income, as the default order does. non_savings = np.maximum(0, columns["net_income"] + relief - savings - dividends) - # Net pay comes off employment income (FA 2004 s. 193(2)) and relief at - # source extends the bands (s. 192(4)), so pension relief stays on - # non-savings income as far as it reaches; the search shares the rest. + # Net pay comes off employment income (FA 2004 s. 193(2)); for relief at + # source Parliament raises the basic and higher rate limits instead + # (s. 192(4)), which deducting the relief from the bottom of non-savings + # income matches. So pension relief stays on non-savings income as far + # as it reaches; the search shares the rest. # allowances includes the relief, so it never holds more than that. pension = np.clip( columns.get("pension", np.zeros_like(relief)), @@ -667,7 +675,8 @@ def non_savings_deductions(person, period): non-savings, savings, dividends, the function returns that order's amounts exactly. Elsewhere it takes the non-savings income left from the share's own arithmetic, in float64, so float32 rounding in a sum of - net income and the relief added back leaves no residue above nil. + net income and the relief added back leaves no residue above nil beyond + the float32 rounding of the moved share, under a penny. """ default = default_order_deductions(person, period) to_savings = person("received_allowances_savings_income", period) diff --git a/policyengine_uk/utils/uc_income_tax.py b/policyengine_uk/utils/uc_income_tax.py index 70ea63aba6..e47f8d13d3 100644 --- a/policyengine_uk/utils/uc_income_tax.py +++ b/policyengine_uk/utils/uc_income_tax.py @@ -60,10 +60,11 @@ def uc_income_tax_on_earnings_slice(person, period, parameters, include_trade=Tr ) # The slice follows the order non-savings, savings, dividends, not the # least-tax share of the reliefs and allowances (ITA 2007 s. 25(2)): - # PAYE codes set the allowances against the job, and moving them onto - # savings or dividends to lower the year's tax would otherwise raise - # the tax on earnings. So the earnings slice reads the old order's - # deductions, and more savings or dividends never raise it. + # the model follows PAYE coding, which sets the allowances against the + # job; moving them onto savings or dividends to lower the year's tax + # would otherwise raise the tax on earnings. So the earnings slice reads + # the old order's deductions, and more savings or dividends never raise + # it. default = default_order_deductions(person, period) # The income left in earned taxable income before allowances, under # that order. The part of it above earnings is the person's other 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 index f5c5467c75..663cc3ddc3 100644 --- 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 @@ -17,7 +17,8 @@ class non_savings_income_after_allowances(Variable): "property income (s. 25(3A)). The allocation computes it from its own " "share, without the float32 rounding of a sum that adds the relief " "back. property_income_tax, taxable_property_income_after_allowances " - "and earned_taxable_income read it." + "and earned_taxable_income read it, and non_savings_taxable_income " + "reads it through the last two." ) definition_period = YEAR reference = [ From 056a674b9348c6656de2f64fb218eed6c374f938 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 20:06:30 +0000 Subject: [PATCH 17/21] Let the trading loss and paternity pay tests follow the least-tax share MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trading loss differential now bounds the tax with a loss by the tax on income cut in the old order, and keeps the equality where the person has no savings or dividend income. The paternity pay test expects the £100.20 rise that setting £1,500 of the Personal Allowance against savings gives. The allocation docs cite FA 2004 s. 192(4A) for the Scottish rate limits. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6KkfvQBfSHTvh7rcen --- policyengine_uk/tests/shard_durations.toml | 2 +- ...ory_paternity_pay_income_tax_properties.py | 20 +++++++----- .../tests/test_trading_loss_properties.py | 31 +++++++++++++------ .../utils/income_tax_allocation.py | 11 ++++--- 4 files changed, 41 insertions(+), 23 deletions(-) diff --git a/policyengine_uk/tests/shard_durations.toml b/policyengine_uk/tests/shard_durations.toml index 7adf4b7794..3b27671620 100644 --- a/policyengine_uk/tests/shard_durations.toml +++ b/policyengine_uk/tests/shard_durations.toml @@ -450,7 +450,7 @@ "policyengine_uk/tests/test_housing_benefit_guarantee_credit_passport_properties.py::test_receipt_means_a_paid_guarantee_credit_and_passports" = 39 "policyengine_uk/tests/test_labour_supply_response_baseline.py::test_each_response_is_measured_before_the_other" = 39 "policyengine_uk/tests/test_pension_annual_allowance_taper.py::test_allowance_bounds_and_statutory_two_gate_reference" = 39 -"policyengine_uk/tests/test_trading_loss_properties.py::test_relief_taxes_like_income_cut_in_section_25_order" = 38 +"policyengine_uk/tests/test_trading_loss_properties.py::test_relief_never_taxes_more_than_income_cut_in_the_old_order" = 38 "policyengine_uk/tests/test_salary_sacrifice_needs_pay_properties.py::test_ni_excess_never_falls_with_sacrifice_or_pay" = 38 "policyengine_uk/tests/test_statutory_pension_edges.py::test_trade_losses_do_not_leak_nics_reclassification_into_aa[DC-2030]" = 38 "policyengine_uk/tests/test_severe_disability_afcs_properties.py::test_severe_disability_matches_the_tax_credit_condition" = 38 diff --git a/policyengine_uk/tests/test_statutory_paternity_pay_income_tax_properties.py b/policyengine_uk/tests/test_statutory_paternity_pay_income_tax_properties.py index ad32b4fee5..c3de54856d 100644 --- a/policyengine_uk/tests/test_statutory_paternity_pay_income_tax_properties.py +++ b/policyengine_uk/tests/test_statutory_paternity_pay_income_tax_properties.py @@ -28,7 +28,7 @@ - ITA 2007 s. 12B cuts the Personal Savings Allowance from £1,000 to £500 once the recipient has higher-rate income, and to nil with additional-rate income, so one pound across the basic rate limit can - cost £100 of tax on savings. + cost £100 of tax. - ITEPA 2003 s. 681C(3)(b) rounds the charge's percentage down to a whole number, so one pound across a £200 step adds 1% of the year's Child Benefit at once. @@ -440,17 +440,23 @@ def rise(results, variable): def test_a_pound_of_statutory_paternity_pay_can_halve_the_savings_allowance(): - # Wages of £48,270 and £2,000 of savings interest give taxable income of - # 50,270 - 12,570 = £37,700, exactly the basic rate limit, so the - # Personal Savings Allowance is £1,000 (ITA 2007 s. 12B). A pound of - # statutory paternity pay gives higher-rate income and a £500 allowance. - # Non-savings income goes from £35,700 to £35,701 (20p more). Savings tax + # Wages of £48,270 and £2,000 of savings interest give taxable income + # of 50,270 - 12,570 = £37,700, exactly the basic rate limit, so the + # Personal Savings Allowance is £1,000 (ITA 2007 s. 12B). A pound + # of statutory paternity pay gives higher-rate income and a £500 + # allowance. Under the order non-savings, savings, dividends, savings tax # goes from 20% of 1,000 = £200 to 20% of 1,499 plus 40% of 1 = £300.20. + # Setting £1,500 of the Personal Allowance against savings instead leaves + # the last pound within the £500 savings allowance. Pay of 37,201 then + # bears 20%, so income tax rises from £7,340 to £7,440.20, not £7,440.40 + # (ITA 2007 s. 25(2)). That pound would bear 40% but for the savings nil + # rate, so it still counts as higher-rate income (s. 12B(8)(b)(ii)), and + # the allowance stays at £500. results = Results([single_earner(48_270.0, savings=2_000.0)]) assert results.recipient("savings_allowance", 0, LOW) == 1_000 assert results.recipient("savings_allowance", 0, HIGH) == 500 assert ( - abs(rise(results, "income_tax_before_winter_fuel_payment_charge") - 100.4) + abs(rise(results, "income_tax_before_winter_fuel_payment_charge") - 100.2) < 0.01 ) diff --git a/policyengine_uk/tests/test_trading_loss_properties.py b/policyengine_uk/tests/test_trading_loss_properties.py index 1209ab80a9..4596b51680 100644 --- a/policyengine_uk/tests/test_trading_loss_properties.py +++ b/policyengine_uk/tests/test_trading_loss_properties.py @@ -13,12 +13,15 @@ is that less the pension contributions given relief once (own contributions up to the greater of £3,600 and pay plus profits, none from age 75). 2. Differential: a person's Income Tax before the winter fuel payment - charge with a loss equals the same tax of the same person without the - loss and with their income cut by the relief, taken from non-savings - income first, then savings, then dividends: the model's fixed allowance - order (s.25(2) asks for the order giving the greatest reduction; #2106). - The charge itself can differ between the two: it tests total income, - which the relief, at Step 2, does not lower but the cut does. + charge with a loss never exceeds the same tax of the same person without + the loss and with their income cut by the relief, taken from non-savings + income first, then savings, then dividends. The model sets the relief and + allowances together where they give the least tax (s. 25(2)), and the + cut, with the allowances set against it, is one share the model weighs. + Where the person has no savings or dividend income, every share is that + cut, so the two taxes agree. The charge itself can differ between the + two: it tests total income, which the relief, at Step 2, does not lower + but the cut does. 3. Income Tax before the winter fuel payment charge never rises with the loss and falls by at most the relief (for people without savings income: the personal savings allowance steps up by £500 when someone stops being @@ -310,8 +313,10 @@ def portfolio_holders(draw): def reduced(person): - """The person's income cut by the relief, in the s.25 order the model - uses, computed outside the model.""" + """The person's income cut by the relief in the order non-savings, + savings, dividends, which this test computes outside the model. The model + sets the relief and allowances where they give the least tax (ITA 2007 + s. 25(2)), so it charges no more tax than on this cut.""" pay = person["employment_income"] pension = person["private_pension_income"] savings = person["savings_interest_income"] @@ -378,7 +383,7 @@ def reduced(person): st.lists(portfolio_holders(), min_size=8, max_size=16), st.lists(st.sampled_from(REGIONS), min_size=16, max_size=16), ) -def test_relief_taxes_like_income_cut_in_section_25_order(people, regions): +def test_relief_never_taxes_more_than_income_cut_in_the_old_order(people, regions): twins = [reduced(p)[0] for p in people] # The winter fuel payment charge tests total income, before Step 2 # reliefs (ITEPA 2003 s.681I(1)(b), ITA 2007 s.23), so a relief and an @@ -392,9 +397,15 @@ def test_relief_taxes_like_income_cut_in_section_25_order(people, regions): assert abs(a["trade_loss_relief_against_general_income"][i] - relief) <= ( tolerance(relief) ), person - assert abs(a[tax][i] - b[tax][i]) <= 0.5 + tolerance( + assert a[tax][i] - b[tax][i] <= 0.5 + tolerance( a[tax][i], person["employment_income"] ), (person, a[tax][i], b[tax][i]) + # With no savings or dividend income, the relief and allowances can + # come only off non-savings income, so every share is the cut. + if person["savings_interest_income"] == 0 and person["dividend_income"] == 0: + assert abs(a[tax][i] - b[tax][i]) <= 0.5 + tolerance( + a[tax][i], person["employment_income"] + ), (person, a[tax][i], b[tax][i]) # A pensioner with a tiny Pension Credit award: Pension Credit income diff --git a/policyengine_uk/utils/income_tax_allocation.py b/policyengine_uk/utils/income_tax_allocation.py index 834a47c799..f98c2d06d3 100644 --- a/policyengine_uk/utils/income_tax_allocation.py +++ b/policyengine_uk/utils/income_tax_allocation.py @@ -24,9 +24,10 @@ 2004 s. 193(2)), and personal contributions as relief at source, under which the payer keeps basic rate tax on the contribution (s. 192(1)) and, on a claim where higher or additional rate tax is chargeable, Parliament raises -the basic and higher rate limits by the contribution (s. 192(4)). Deducting -the contribution from the bottom of non-savings income moves every slice of -income down against those limits, as raising them does. Neither arrangement +the basic and higher rate limits by the contribution (s. 192(4), and for +the Scottish rate limits s. 192(4A)). Deducting the contribution from the +bottom of non-savings income moves every slice of income down against those +limits, as raising them does. Neither arrangement gives a Step 2 deduction the taxpayer can set against savings or dividends, so the search holds ``min(pension relief, non-savings income)`` on non-savings income and shares only the rest. @@ -224,8 +225,8 @@ def _build(inputs, income_tax): non_savings = np.maximum(0, columns["net_income"] + relief - savings - dividends) # Net pay comes off employment income (FA 2004 s. 193(2)); for relief at # source Parliament raises the basic and higher rate limits instead - # (s. 192(4)), which deducting the relief from the bottom of non-savings - # income matches. So pension relief stays on non-savings income as far + # (s. 192(4), and for the Scottish rate limits s. 192(4A)), which + # deducting the relief from the bottom of non-savings income matches. So pension relief stays on non-savings income as far # as it reaches; the search shares the rest. # allowances includes the relief, so it never holds more than that. pension = np.clip( From 3b54d5397e6da2c28357b84ec5e3b49476d5d27f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 2 Nov 2026 15:28:29 -0500 Subject: [PATCH 18/21] Rewrap the pension relief comment in the allowance search Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/utils/income_tax_allocation.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/utils/income_tax_allocation.py b/policyengine_uk/utils/income_tax_allocation.py index f98c2d06d3..6358bdd92a 100644 --- a/policyengine_uk/utils/income_tax_allocation.py +++ b/policyengine_uk/utils/income_tax_allocation.py @@ -226,8 +226,9 @@ def _build(inputs, income_tax): # Net pay comes off employment income (FA 2004 s. 193(2)); for relief at # source Parliament raises the basic and higher rate limits instead # (s. 192(4), and for the Scottish rate limits s. 192(4A)), which - # deducting the relief from the bottom of non-savings income matches. So pension relief stays on non-savings income as far - # as it reaches; the search shares the rest. + # deducting the relief from the bottom of non-savings income matches. So + # pension relief stays on non-savings income as far as it reaches; the + # search shares the rest. # allowances includes the relief, so it never holds more than that. pension = np.clip( columns.get("pension", np.zeros_like(relief)), From 6602ccce488f1302972331d61ba94f02528d08e7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 2 Nov 2026 17:14:26 -0500 Subject: [PATCH 19/21] Narrow the s. 192(4A) citation in the allocation docstring FA 2004 s. 192(4A) raises the Scottish basic rate limit and the Scottish rate limits above it, where a Scottish rate above the Scottish basic rate is chargeable. The docstring and one comment said "the Scottish rate limits". Text only; the AST with docstrings blanked matches 3b54d5397. Co-Authored-By: Claude Opus 5.5 --- .../utils/income_tax_allocation.py | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/policyengine_uk/utils/income_tax_allocation.py b/policyengine_uk/utils/income_tax_allocation.py index 6358bdd92a..c0c8967397 100644 --- a/policyengine_uk/utils/income_tax_allocation.py +++ b/policyengine_uk/utils/income_tax_allocation.py @@ -22,15 +22,17 @@ Pension relief is the exception. The model takes employee contributions as a net pay arrangement, which the employer deducts from employment income (FA 2004 s. 193(2)), and personal contributions as relief at source, under which -the payer keeps basic rate tax on the contribution (s. 192(1)) and, on a -claim where higher or additional rate tax is chargeable, Parliament raises -the basic and higher rate limits by the contribution (s. 192(4), and for -the Scottish rate limits s. 192(4A)). Deducting the contribution from the -bottom of non-savings income moves every slice of income down against those -limits, as raising them does. Neither arrangement -gives a Step 2 deduction the taxpayer can set against savings or dividends, -so the search holds ``min(pension relief, non-savings income)`` on -non-savings income and shares only the rest. +the payer keeps basic rate tax on the contribution (s. 192(1)). On a claim, +Parliament raises the basic and higher rate limits by the contribution where +higher or additional rate tax is chargeable (s. 192(4)), and for a Scottish +taxpayer the Scottish basic rate limit and the Scottish rate limits above it +where a Scottish rate above the Scottish basic rate is chargeable +(s. 192(4A)). Deducting the contribution from the bottom of non-savings +income moves every slice of income down against those limits, as raising +them does. Neither arrangement gives a Step 2 deduction the taxpayer can set +against savings or dividends, so the search holds +``min(pension relief, non-savings income)`` on non-savings income and shares +only the rest. How the search finds the least tax ---------------------------------- @@ -225,10 +227,10 @@ def _build(inputs, income_tax): non_savings = np.maximum(0, columns["net_income"] + relief - savings - dividends) # Net pay comes off employment income (FA 2004 s. 193(2)); for relief at # source Parliament raises the basic and higher rate limits instead - # (s. 192(4), and for the Scottish rate limits s. 192(4A)), which - # deducting the relief from the bottom of non-savings income matches. So - # pension relief stays on non-savings income as far as it reaches; the - # search shares the rest. + # (s. 192(4); for a Scottish taxpayer, the Scottish basic rate limit and + # those above it, s. 192(4A)), which deducting the relief from the bottom + # of non-savings income matches. So pension relief stays on non-savings + # income as far as it reaches; the search shares the rest. # allowances includes the relief, so it never holds more than that. pension = np.clip( columns.get("pension", np.zeros_like(relief)), From aed86e9a3154db7cdfbc015603dcccd825130c7f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 6 Nov 2026 05:21:05 -0500 Subject: [PATCH 20/21] Update the child benefit charge case for the least-tax share Main added this case after this branch's last CI run. With the allowances set for the least tax, the parent's income tax falls by 100, so the case now expects 15,935, with 7,540 on the pay and a deduction of 13,649. Co-Authored-By: Claude Opus 5.5 --- .../income_tax_caused_by_earnings.yaml | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/income_tax_caused_by_earnings.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/income_tax_caused_by_earnings.yaml index 9a50ebbd02..6940037519 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/income_tax_caused_by_earnings.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/income_tax_caused_by_earnings.yaml @@ -103,14 +103,17 @@ # A parent aged 35 with one child, pay 40,000 and interest 30,000. Child # Benefit 27.05 x 52 = 1,406.60 (1,406 in whole pounds). Adjusted net income # 70,000 is half-way through the 60,000 to 80,000 taper: charge 50% x 1,406 - # = 703 (ITEPA 2003 s.681C). Pay: (40,000 - 12,570) x 20% = 5,486. Interest, - # higher-rate (savings allowance 500): 500 at nil, 37,700 - 27,430 - 500 = - # 9,770 at 20% = 1,954, 30,000 - 10,270 = 19,730 at 40% = 7,892; 9,846. - # Income tax before the winter fuel payment charge: 5,486 + 9,846 + 703 = - # 16,035. Without the pay: adjusted net income 30,000, no charge; interest - # 17,430 taxable, 5,000 at the starting rate and 1,000 savings allowance at - # nil, 11,430 at 20% = 2,286. Tax deducted: 16,035 - 2,286 = 13,749, which - # includes the whole 703 charge. + # = 703 (ITEPA 2003 s.681C). The least tax sets 2,300 of the personal + # allowance against the pay and 10,270 against the interest (ITA 2007 + # s.25(2)). Pay: 37,700 at 20% = 7,540. Interest, higher-rate (savings + # allowance 500): 19,730 above the basic rate limit, 500 at nil and 19,230 + # at 40% = 7,692. Income tax before the winter fuel payment charge: 7,540 + + # 7,692 + 703 = 15,935. Setting the whole allowance against the pay leaves + # the 500 at nil in the basic rate band and charges 100 more. Without the + # pay: adjusted net income 30,000, no charge; interest 17,430 taxable, 5,000 + # at the starting rate and 1,000 savings allowance at nil, 11,430 at 20% = + # 2,286. Tax deducted: 15,935 - 2,286 = 13,649, which includes the whole 703 + # charge. period: 2026 absolute_error_margin: 0.01 input: @@ -130,10 +133,10 @@ region: NORTH_EAST output: CB_HITC: [703, 0] - income_tax_before_winter_fuel_payment_charge: [16_035, 0] - legacy_means_test_income_tax: [5_486, 0] + income_tax_before_winter_fuel_payment_charge: [15_935, 0] + legacy_means_test_income_tax: [7_540, 0] legacy_means_test_uncounted_income_tax_without_earnings: [2_286, 0] - legacy_means_test_deducted_income_tax: [13_749, 0] + legacy_means_test_deducted_income_tax: [13_649, 0] - name: Only the part of the High Income Child Benefit Charge that the pay adds is deducted # A parent aged 35 with one child, pay 10,000 and interest 65,000. With the From 6c91a3cb07b24a8a5fe97e03f646a5e8af4e4b2e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 9 Nov 2026 17:11:11 -0500 Subject: [PATCH 21/21] Weigh the Marriage Allowance in the least-tax share MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Section 25(2) asks for the greatest reduction in liability, which s. 23 fixes after the Step 6 tax reductions. The share decides whether a recipient meets the Marriage Allowance rate test (s. 55B(2)(b) and (ba)), so the search now compares the Step 5 tax less the reduction each share wins. marriage_allowance_reduction_at_stake gives it that reduction whatever the share, and the CPS expansion sets it to nil where the reform gives the allowance at any share. The search adds the rate test's limit on non-savings income to its lines; the test's other edges, no dividends left and savings left at the savings allowance, already lie among them. It counts a share as meeting the test only with room below each limit, and sets savings or dividends exactly where the test reads them, so float32 rounding can't deny the reduction. It judges the old order by the test's own arithmetic, so a recipient the old order qualifies moves only for a lower Step 5 tax. The rate test moves to utils/marriage_allowance.py, which the allocation and meets_marriage_allowance_income_conditions share. YAML cases 19 to 23 and new property tests cover recipients, and case 13's comment, the module docstring and the changelog describe the Step 6 reduction. Since #2828 the model no longer subtracts the allowance from earned income, so the synthetic people's £1,260 exclusion from earned income drops the allowance's name. The Universal Credit earnings slice takes off the Step 6 reductions, so more savings or dividends can raise it by failing a recipient on the rate test; its comment and property-test docstring now say so. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2106.fixed.md | 2 +- .../reforms/cps/marriage_tax_reforms.py | 20 + ...marriage_allowance_reduction_at_stake.yaml | 76 ++++ .../least_tax_allocation.yaml | 157 +++++++- .../tests/test_income_tax_allocation.py | 381 +++++++++++++++++- .../test_marriage_allowance_cps_switch.py | 54 +++ .../tests/test_uc_income_tax_properties.py | 8 +- .../utils/income_tax_allocation.py | 225 +++++++++-- policyengine_uk/utils/marriage_allowance.py | 98 +++++ policyengine_uk/utils/uc_income_tax.py | 5 +- .../allowances/marriage_allowance.py | 26 +- ...iage_allowance_if_income_conditions_met.py | 38 ++ .../marriage_allowance_reduction_at_stake.py | 33 ++ ...ts_marriage_allowance_income_conditions.py | 93 +---- 14 files changed, 1066 insertions(+), 150 deletions(-) create mode 100644 policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance_reduction_at_stake.yaml create mode 100644 policyengine_uk/utils/marriage_allowance.py create mode 100644 policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_if_income_conditions_met.py create mode 100644 policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_reduction_at_stake.py diff --git a/changelog.d/2106.fixed.md b/changelog.d/2106.fixed.md index 92b771fb40..0950505e2d 100644 --- a/changelog.d/2106.fixed.md +++ b/changelog.d/2106.fixed.md @@ -1 +1 @@ -The model now sets the Personal Allowance, other allowances and trade loss relief against non-savings, savings and dividend income in the way that gives the least tax (ITA 2007 s. 25(2)), and from 2027-28 against other income before property, savings and dividend income (s. 25(3A)), where it previously always took them from non-savings income first. Pension relief stays on non-savings income, and the income tax that Universal Credit deducts from earnings still follows the order non-savings, savings, dividends. +The model now sets the Personal Allowance, other allowances and trade loss relief against non-savings, savings and dividend income in the way that gives the least tax (ITA 2007 s. 25(2)), and from 2027-28 against other income before property, savings and dividend income (s. 25(3A)), where it previously always took them from non-savings income first. When it compares ways, it counts the Marriage Allowance tax reduction that each wins or loses (s. 55B(2)). Pension relief stays on non-savings income, and the income tax that Universal Credit deducts from earnings still follows the order non-savings, savings, dividends. diff --git a/policyengine_uk/reforms/cps/marriage_tax_reforms.py b/policyengine_uk/reforms/cps/marriage_tax_reforms.py index 2dd2427fa5..f3850c3843 100644 --- a/policyengine_uk/reforms/cps/marriage_tax_reforms.py +++ b/policyengine_uk/reforms/cps/marriage_tax_reforms.py @@ -95,11 +95,31 @@ def formula(person, period, parameters): takes_up = person("would_claim_marriage_allowance", period) return eligible * amount_if_eligible * takes_up + class marriage_allowance_reduction_at_stake(Variable): + value_type = float + entity = Person + label = "Marriage Allowance tax reduction at stake in the share of deductions" + documentation = ( + "Nil for a person who meets the reform's expansion conditions, " + "who receives the allowance at any share of reliefs and allowances." + ) + definition_period = YEAR + unit = GBP + + def formula(person, period, parameters): + return where( + person("meets_expanded_ma_conditions", period), + 0, + person("marriage_allowance_appropriate_percentage", period) + * person("marriage_allowance_if_income_conditions_met", period), + ) + class reform(Reform): def apply(self): self.add_variable(meets_expanded_ma_conditions) self.update_variable(meets_marriage_allowance_income_conditions) self.update_variable(marriage_allowance) + self.update_variable(marriage_allowance_reduction_at_stake) return reform diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance_reduction_at_stake.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance_reduction_at_stake.yaml new file mode 100644 index 0000000000..baccc11ece --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance_reduction_at_stake.yaml @@ -0,0 +1,76 @@ +# The Marriage Allowance tax reduction the share of reliefs and allowances can +# win or lose (ITA 2007 ss. 25(2) and 55B). 2026-27: Personal Allowance £12,570, +# so the transfer reaches 10%, £1,257, rounded up to £1,260 (s. 55B(4) and (5)), +# and the reduction 20% of that, £252. + +- name: A higher-rate earner has the reduction at stake, though the rate test fails + period: 2026 + absolute_error_margin: 0.01 + input: + people: + earner: + age: 40 + employment_income: 60_000 + partner: + age: 40 + benunit: + members: [earner, partner] + is_married: true + output: + marriage_allowance_if_income_conditions_met: [1_260, 0] + marriage_allowance_reduction_at_stake: [252, 0] + marriage_allowance: [0, 0] + +- name: The partner's unused allowance limits the amount at stake + period: 2026 + absolute_error_margin: 0.01 + input: + people: + earner: + age: 40 + employment_income: 30_000 + partner: + age: 40 + employment_income: 12_000 + benunit: + members: [earner, partner] + is_married: true + output: + # The partner leaves £570 unused: 20% of it is £114. + marriage_allowance_if_income_conditions_met: [570, 0] + marriage_allowance_reduction_at_stake: [114, 0] + +- name: An unmarried couple has nothing at stake + period: 2026 + absolute_error_margin: 0.01 + input: + people: + earner: + age: 40 + employment_income: 30_000 + partner: + age: 40 + benunit: + members: [earner, partner] + is_married: false + output: + marriage_allowance_if_income_conditions_met: [0, 0] + marriage_allowance_reduction_at_stake: [0, 0] + +- name: A person who would not claim has nothing at stake + period: 2026 + absolute_error_margin: 0.01 + input: + people: + earner: + age: 40 + employment_income: 30_000 + would_claim_marriage_allowance: false + partner: + age: 40 + benunit: + members: [earner, partner] + is_married: true + output: + marriage_allowance_if_income_conditions_met: [0, 0] + marriage_allowance_reduction_at_stake: [0, 0] diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml index 36886a92af..0d4bc2a9b1 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml @@ -245,7 +245,7 @@ dividend_income_tax: 6_874.725 income_tax: 14_414.725 -- name: Case 13, the share leaves out the Marriage Allowance a recipient receives +- name: Case 13, a Marriage Allowance recipient the old order already qualifies keeps it period: 2026 absolute_error_margin: 0.01 input: @@ -263,9 +263,9 @@ output: # The model gives the recipient a Marriage Allowance of £1,260 from the # partner's unused Personal Allowance: 10% of £12,570, rounded up to a - # multiple of £10 (s. 55B(4) and (5)). Parliament gives it as a tax - # reduction (s. 55B(1)), which the taxpayer deducts at Step 6 of s. 23, - # not as a deduction at Step 3, so the model leaves it out of the share. Pay, property income + # multiple of £10 (s. 55B(4) and (5)). The old order leaves £22,430, all + # within the basic rate limit, so it already meets the rate test + # (s. 55B(2)(b)) and no share wins more. Pay, property income # and the savings above the £1,000 savings allowance all bear 20%, and the # pay and property income leave no starting rate band (s. 12), so no share # lowers the Step 5 tax, and the model takes the Personal Allowance off the @@ -403,3 +403,152 @@ # relief added back leaves nothing above nil. non_savings_income_after_allowances: 0 earned_taxable_income: 0 + +- name: Case 19, a recipient's share wins the Marriage Allowance at a higher Step 5 tax + period: 2026 + absolute_error_margin: 0.01 + input: + people: + recipient: + age: 40 + employment_income: 40_000 + savings_interest_income: 9_000 + dividend_income: 1_570 + partner: + age: 40 + benunit: + members: [recipient, partner] + is_married: true + output: + # Section 25(2) asks for the greatest reduction in liability, and s. 23 + # fixes liability after the Step 6 tax reductions. The whole allowance + # against pay gives the least Step 5 tax, but £300 of dividends then bear + # the dividend upper rate, so the recipient fails the rate test + # (s. 55B(2)(b) and (ba)): 27,430 x 20% = 5,486, 8,500 of savings x 20% + # = 1,700, and 770 x 10.75% + 300 x 35.75% = 190.025, £7,376.025 in all. + # Setting £2,500 against pay, £8,500 against savings and all £1,570 + # against dividends leaves £37,500 of pay at 20%, £500 of savings at the + # savings nil rate and no dividends. The rate test then holds, and the + # tax is 7,500 less 20% of 1,260 = 252: £7,248. + received_allowances_earned_income: [2_500, 0] + received_allowances_savings_income: [8_500, 0] + received_allowances_dividend_income: [1_570, 0] + income_tax_pre_charges: [7_500, 0] + meets_marriage_allowance_income_conditions: [true, true] + marriage_allowance: [1_260, 0] + marriage_allowance_tax_reduction: [252, 0] + income_tax: [7_248, 0] + +- name: Case 20, a recipient clears dividends and stops savings at the nil rate + period: 2026 + absolute_error_margin: 0.01 + input: + people: + recipient: + age: 40 + employment_income: 49_870 + savings_interest_income: 600 + dividend_income: 200 + partner: + age: 40 + benunit: + members: [recipient, partner] + is_married: true + output: + # £38,100 is left after the allowance, above the £37,700 basic rate limit, + # so any dividends left fail the rate test, and savings left above the + # £500 savings allowance fail it too. Setting £200 against dividends and + # £100 against savings leaves £37,600 of pay at 20% = 7,520 and £500 of + # savings at the nil rate. The tax is 7,520 less 252 = 7,268, against + # 7,500 under the old order, which keeps the £200 of dividends and fails. + received_allowances_earned_income: [12_270, 0] + received_allowances_savings_income: [100, 0] + received_allowances_dividend_income: [200, 0] + income_tax_pre_charges: [7_520, 0] + marriage_allowance_tax_reduction: [252, 0] + income_tax: [7_268, 0] + +- name: Case 21, no share wins the Marriage Allowance when pay alone exceeds the limit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + recipient: + age: 40 + employment_income: 60_000 + savings_interest_income: 5_000 + partner: + age: 40 + benunit: + members: [recipient, partner] + is_married: true + output: + # The old order leaves £47,430 of pay, the least non-savings income any + # share leaves, and that already exceeds the basic rate limit. So the + # recipient has £252 at stake and no share wins it, and the old order + # stands. 37,700 x 20% + 9,730 x 40% = 11,432, plus the savings above the + # £500 savings allowance: 4,500 x 40% = 1,800. + marriage_allowance_reduction_at_stake: [252, 0] + marriage_allowance: [0, 0] + received_allowances_earned_income: [12_570, 0] + received_allowances_savings_income: [0, 0] + income_tax: [13_232, 0] + +- name: Case 22, from 2027-28 a recipient's share wins the Marriage Allowance from property income + period: 2027 + absolute_error_margin: 0.01 + input: + people: + recipient: + age: 40 + property_income: 49_870 + savings_interest_income: 600 + dividend_income: 200 + uses_property_allowance: false + partner: + age: 40 + benunit: + members: [recipient, partner] + is_married: true + output: + # Section 25(3A) sets the deductions against other income first, and this + # recipient has none, so the allowance can come off property, savings or + # dividends. As in Case 20, clearing dividends and stopping savings at the + # £500 savings allowance meets the rate test: £37,600 of property income + # at 24% = 9,024, less 252 = 8,772. The old order fails it and charges + # 37,300 x 24% = 8,952 plus £100 of savings above the limit at 44% = 44: + # 8,996. + received_allowances_savings_income: [100, 0] + received_allowances_dividend_income: [200, 0] + income_tax_pre_charges: [9_024, 0] + marriage_allowance_tax_reduction: [252, 0] + income_tax: [8_772, 0] + +- name: Case 23, a recipient the old order qualifies exactly at the basic rate limit keeps that order + period: 2026 + absolute_error_margin: 0.01 + input: + people: + recipient: + age: 40 + employment_income: 38_570 + savings_interest_income: 11_700 + partner: + age: 40 + benunit: + members: [recipient, partner] + is_married: true + output: + # The old order leaves £26,000 of pay and £11,700 of savings, which end + # exactly at the £37,700 basic rate limit, so it meets the rate test + # (s. 55B(2)(b)). Moving the allowance from pay to savings leaves the + # Step 5 tax at 7,340 until the savings left fall to the £1,000 savings + # allowance, so no share lowers the tax after the reduction, and the old + # order stands: 26,000 x 20% = 5,200, and 10,700 of savings x 20% = + # 2,140, less 252: £7,088. + received_allowances_earned_income: [12_570, 0] + received_allowances_savings_income: [0, 0] + income_tax_pre_charges: [7_340, 0] + meets_marriage_allowance_income_conditions: [true, true] + marriage_allowance_tax_reduction: [252, 0] + income_tax: [7_088, 0] diff --git a/policyengine_uk/tests/test_income_tax_allocation.py b/policyengine_uk/tests/test_income_tax_allocation.py index d4d2b64896..cd34ba7a18 100644 --- a/policyengine_uk/tests/test_income_tax_allocation.py +++ b/policyengine_uk/tests/test_income_tax_allocation.py @@ -2,8 +2,9 @@ in the way that gives the least tax (ITA 2007 s. 25(2) and, from 2027-28, s. 25(3A); #2106). -The invariants, for every person without Marriage Allowance, on the Step 5 -tax (income_tax_pre_charges), the only tax the share changes: +The first seven invariants hold for every person with no Marriage Allowance +reduction at stake, on the Step 5 tax (income_tax_pre_charges), the only tax +the share then changes: 1. Least tax: no share of the deductions between the components s. 25 lets them come off gives less Step 5 tax than the model's. A brute-force oracle @@ -21,24 +22,45 @@ 7. Fast path: for everyone the tests in _old_order_is_least let through, the result equals the full search's, bit for bit, and in current law those tests let through everyone with one kind of income. + +The rest concern Marriage Allowance recipients, whose share also decides the +reduction at Step 6: + +8. Income tax never exceeds what the order non-savings, savings, dividends + charges. +9. No share forced through the model's own variables gives less income tax + than the model's share. +10. Where the search moves a share and counts it as meeting the rate test, the + model's own variables meet the test at that share, through float32 rounding. +11. The vertex search finds the least Step 5 tax less the reduction a share + wins, checked against a dense grid and the thin sets on which the rate test + can hold: no dividends left, and savings left at the savings allowance. +12. Fast path: invariant 7 holds with recipients among the people. """ import numpy as np import pytest from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st +from policyengine_core import periods from policyengine_uk import Simulation from policyengine_uk.utils.income_tax_allocation import ( _build, _context, _least_tax_share, + _meets_rate_test, _old_order_is_least, _shares, default_order_deductions, least_tax_deductions, old_order, step_5_tax, + tax_after_marriage_allowance, +) +from policyengine_uk.utils.marriage_allowance import ( + marriage_allowance_rate_limits, + meets_marriage_allowance_rate_test, ) pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") @@ -394,13 +416,17 @@ def system(): return uk_simulation.CountryTaxBenefitSystem() -def synthetic_people(seed, count=SYNTHETIC_PEOPLE, mix="free", exclusions=False): +def synthetic_people( + seed, count=SYNTHETIC_PEOPLE, mix="free", exclusions=False, basic_rate_limit=37_700 +): """Inputs for ``least_tax_deductions`` and the old order's deductions. "free" draws every amount from a wide mixture with values at the limits; - "realistic" gives most people pay alone and some savings or dividends. - With ``exclusions``, half the people get an exclusion from earned income - of up to £20,000 in place of the Marriage Allowance's £1,260.""" + "realistic" gives most people pay alone and some savings or dividends; + "near-limit" puts income after the Personal Allowance just around the basic + rate limit, where a share can win the Marriage Allowance. + A tenth of the people get an exclusion from earned income of £1,260; with + ``exclusions``, half get one of up to £20,000 instead.""" rng = np.random.default_rng(seed) def amount(zero, scale): @@ -419,6 +445,23 @@ def amount(zero, scale): property_income = amount(0.6, 60_000) savings = amount(0.4, 150_000) dividends = amount(0.5, 250_000) + elif mix == "near-limit": + savings = np.where( + rng.random(count) < 0.5, + rng.uniform(0, 1_200, count), + rng.uniform(0, 12_570, count), + ) + dividends = np.where(rng.random(count) < 0.5, 0, rng.uniform(0, 5_000, count)) + non_savings = np.maximum( + 0, + basic_rate_limit + + rng.uniform(-3, 900, count) + + 12_570 + - savings + - dividends, + ) + property_income = np.where(rng.random(count) < 0.5, non_savings, 0) + other = non_savings - property_income else: other = rng.lognormal(10.2, 0.6, count) property_income = np.where( @@ -431,14 +474,14 @@ def amount(zero, scale): rng.random(count) < 0.1, np.minimum(rng.uniform(0, 30_000, count), income), 0 ) extra = np.where(rng.random(count) < 0.2, rng.uniform(0, 15_000, count), 0) - married = rng.random(count) < 0.1 + excluded = rng.random(count) < 0.1 gift = np.where(rng.random(count) < 0.1, rng.uniform(0, 10_000, count), 0) scottish = rng.random(count) < 0.3 savings_allowance = rng.choice([0.0, 500.0, 1_000.0], count) pension = np.where(rng.random(count) < 0.2, rng.uniform(0, 20_000, count), 0) exclusion_draw = np.where(rng.random(count) < 0.5, rng.uniform(0, 20_000, count), 0) rows = np.array(EDGES, dtype=float) if mix == "free" else np.empty((0, 10)) - other, property_income, savings, dividends, relief, extra, marriage, gift = ( + other, property_income, savings, dividends, relief, extra, exclusion, gift = ( np.concatenate([column, rows[:, i]]) for i, column in enumerate( [ @@ -448,7 +491,7 @@ def amount(zero, scale): dividends, relief, extra, - married * 1_260.0, + excluded * 1_260.0, gift, ] ) @@ -458,7 +501,7 @@ def amount(zero, scale): pension = np.concatenate([pension, np.zeros(len(rows))]) if exclusions: # A reform's exclusion from earned income, such as an exempt pension. - marriage = np.concatenate([exclusion_draw, rows[:, 6]]) + exclusion = np.concatenate([exclusion_draw, rows[:, 6]]) f32 = np.float32 before_relief = (other + property_income + savings + dividends).astype(f32) relief = relief.astype(f32) @@ -477,7 +520,7 @@ def amount(zero, scale): property=property_income.astype(f32), allowances=allowances, pension=pension.astype(f32), - earned_exclusions=marriage.astype(f32), + earned_exclusions=exclusion.astype(f32), gift=gift.astype(f32), scottish=scottish, savings_allowance=savings_allowance.astype(f32), @@ -735,3 +778,319 @@ def test_pension_relief_stays_on_non_savings_income(system): assert tax_saved(income_tax, inputs, default) == 0 shared = dict(inputs, pension=np.array([0.0])) assert tax_saved(income_tax, shared, default) == pytest.approx(100, abs=1e-6) + + +# Marriage Allowance recipients: each married to a partner with no income, so +# the partner's unused Personal Allowance funds the transfer. +COUPLE_INCOMES = ( + "employment_income", + "savings_interest_income", + "dividend_income", + "property_income", +) +FORCED_SHARE = ( + "received_allowances_savings_income", + "received_allowances_dividend_income", +) +COUPLE_GRID = 11 +# Hand-built recipients: (year, region, (pay, savings, dividends, property)). +RECIPIENTS = [ + # The share wins the reduction: all dividends and most savings covered. + (2026, "LONDON", (40_000, 9_000, 1_570, 0)), + (2026, "LONDON", (49_870, 600, 200, 0)), + (2026, "LONDON", (45_000, 300, 200, 5_000)), + (2026, "LONDON", (49_000, 1_000, 500, 0)), + # The Scottish limit on non-savings income falls below the basic rate limit. + (2026, "SCOTLAND", (40_000, 9_000, 1_570, 0)), + # From 2027-28 the allowances come off other income first (s. 25(3A)). + (2027, "LONDON", (0, 9_000, 1_570, 40_000)), + (2027, "LONDON", (9_000, 6_000, 1_500, 30_000)), + # No share wins it: non-savings income alone exceeds the limit. + (2026, "WALES", (60_000, 5_000, 0, 0)), + # The old order already meets the test. + (2026, "LONDON", (30_000, 400, 100, 0)), +] + + +def couple_situation(year, regions): + people, benunits, households = {}, {}, {} + for i, region in enumerate(regions): + members = [f"recipient_{i}", f"partner_{i}"] + people[members[0]] = {"age": {year: 40}} + people[members[1]] = {"age": {year: 40}} + benunits[f"benunit_{i}"] = {"members": members, "is_married": {year: True}} + households[f"household_{i}"] = {"members": members, "region": {year: region}} + return {"people": people, "benunits": benunits, "households": households} + + +@pytest.fixture +def couple_sims(): + """Simulations reused within a test and released after it: each holds its + own copy of the system, about 130 MB.""" + cache = {} + + def get(year, regions, kind="law"): + key = (year, tuple(regions), kind) + if key not in cache: + cache[key] = Simulation(situation=couple_situation(year, regions)) + return cache[key] + + return get + + +def run_couples(sim, year, rows, forced=None): + """Set one (pay, savings, dividends, property) per recipient, and optionally + force the recipients' deductions from savings and dividends.""" + count = sim.populations["person"].count + columns = zip(*rows) + for name, values in zip(COUPLE_INCOMES, columns): + inputs = np.zeros(count) + inputs[0::2] = values + sim.set_input(name, year, inputs) + for name, values in zip(FORCED_SHARE, forced or ()): + inputs = np.zeros(count) + inputs[0::2] = values + sim.set_input(name, year, inputs) + sim.reset_calculations() + return sim + + +def check_recipients(couple_sims, year, regions, rows): + """Invariants 8, 9 and 10 for one recipient a row.""" + count = len(rows) + law = run_couples(couple_sims(year, regions), year, rows) + person = law.populations["person"] + default = default_order_deductions(person, year) + law_tax = law.calculate("income_tax", year).astype(float)[0::2] + margin = 0.01 + 1e-6 * np.array(rows, dtype=float).sum(axis=1) + + # Each recipient's shares forced through the model's own variables: a grid, + # then the old order. + c = _context(person, year, parameters_of(law)) + fractions = np.linspace(0, 1, COUPLE_GRID) + to_savings = np.minimum(c.savings, c.used)[0::2, :] * fractions[None, :] + to_dividends = np.minimum(c.dividends, c.used)[0::2, :] * fractions[None, :] + grid_savings = to_savings.repeat(COUPLE_GRID, axis=1) + grid_dividends = np.tile(to_dividends, COUPLE_GRID) + to_first = c.used[0::2] - grid_savings - grid_dividends + at_least = (c.used - c.shared)[0::2] if c.other_first else 0 + feasible = (to_first >= at_least - 1e-6) & (to_first <= c.non_savings[0::2] + 1e-6) + size = COUPLE_GRID**2 + 1 + forced_savings = np.column_stack([grid_savings, default.savings[0::2]]) + forced_dividends = np.column_stack([grid_dividends, default.dividends[0::2]]) + forced = run_couples( + couple_sims( + year, [region for region in regions for _ in range(size)], "forced" + ), + year, + [row for row in rows for _ in range(size)], + (forced_savings.ravel(), forced_dividends.ravel()), + ) + forced_tax = ( + forced.calculate("income_tax", year).astype(float)[0::2].reshape(count, -1) + ) + # Invariant 8: never more income tax than the old order. + old_tax = forced_tax[:, -1] + assert np.all(law_tax <= old_tax + margin), (rows, law_tax, old_tax) + # Invariant 9: no forced share gives less income tax. + least = np.where(feasible, forced_tax[:, :-1], np.inf).min(axis=1) + assert np.all(law_tax <= least + margin), (rows, law_tax, least) + + # Invariant 10: the model meets the test where the search counts on it. + x, s, v = _least_tax_share(c) + counted = _meets_rate_test(c, x, s, v)[:, 0] + moved = ( + law.calculate("received_allowances_savings_income", year) != default.savings + ) | ( + law.calculate("received_allowances_dividend_income", year) != default.dividends + ) + meets = law.calculate("meets_marriage_allowance_income_conditions", year) + assert meets[moved & counted].all() + + +@pytest.mark.parametrize("year", sorted({year for year, _, _ in RECIPIENTS})) +def test_recipient_share_gives_the_least_income_tax(couple_sims, year): + regions = [region for y, region, _ in RECIPIENTS if y == year] + rows = [row for y, _, row in RECIPIENTS if y == year] + check_recipients(couple_sims, year, regions, rows) + + +recipient = st.tuples(amount, amount, amount, st.just(0.0) | amount) +recipients = st.lists(recipient, min_size=4, max_size=4) + + +@settings( + SETTINGS, + suppress_health_check=[ + HealthCheck.too_slow, + HealthCheck.data_too_large, + HealthCheck.function_scoped_fixture, + ], +) +@given(year, recipients) +def test_drawn_recipient_share_gives_the_least_income_tax(couple_sims, year, rows): + check_recipients(couple_sims, year, REGIONS, rows) + + +def test_rate_test_survives_float32_rounding(couple_sims): + """Invariant 10 on many recipients whose income ends near the limits, in + amounts that float32 rounds. Dividends and savings at the point the test + turns on, and the income at the basic rate limit, are where rounding could + deny the search's share its reduction.""" + year = 2026 + count = 3_000 + rng = np.random.default_rng(2106) + after_allowance = 37_700 + rng.uniform(-3, 900, count) + savings = np.where( + rng.random(count) < 0.5, + rng.uniform(0, 1_200, count), + rng.uniform(0, 12_570, count), + ) + dividends = np.where(rng.random(count) < 0.5, 0, rng.uniform(0, 5_000, count)) + pay = np.maximum(0, after_allowance + 12_570 - savings - dividends) + rows = list(zip(pay, savings, dividends, np.zeros(count))) + law = run_couples(couple_sims(year, ["LONDON"] * count), year, rows) + person = law.populations["person"] + c = _context(person, year, parameters_of(law)) + default = default_order_deductions(person, year) + x, s, v = _least_tax_share(c) + counted = _meets_rate_test(c, x, s, v)[:, 0] + moved = ( + law.calculate("received_allowances_savings_income", year) != default.savings + ) | ( + law.calculate("received_allowances_dividend_income", year) != default.dividends + ) + meets = law.calculate("meets_marriage_allowance_income_conditions", year) + assert (moved & counted).sum() > 100 + assert meets[moved & counted].all() + + +def with_marriage_allowance(income_tax, year, inputs, default, seed): + """``inputs`` with a reduction at stake for about half the people, the rate + test's limits for them, and the old order's answer to the test, in float32 + as the model computes it.""" + rng = np.random.default_rng(seed) + count = len(inputs["savings"]) + f32 = np.float32 + arrays = dict( + gift_aid_grossed_up=inputs["gift"].astype(f32), + pays_scottish_income_tax=inputs["scottish"], + savings_allowance=inputs["savings_allowance"].astype(f32), + ) + limits = marriage_allowance_rate_limits( + lambda name, period: arrays[name], periods.period(year), income_tax + ) + savings = inputs["savings"].astype(f32) + dividends = inputs["dividends"].astype(f32) + non_savings_left = np.maximum( + 0, + inputs["net_income"].astype(f32) + - savings + - dividends + - inputs["allowances"].astype(f32), + ) + meets = meets_marriage_allowance_rate_test( + non_savings_left, + np.maximum(0, savings - default.savings), + np.maximum(0, dividends - default.dividends), + limits, + ) + return inputs | dict( + marriage_allowance_reduction=rng.choice([0.0, 2.0, 100.0, 252.0], count), + rate_test_non_savings_limit=np.asarray(limits.non_savings, dtype=np.float64), + rate_test_savings_allowance=np.broadcast_to( + np.asarray(limits.savings_allowance, dtype=np.float64), savings.shape + ), + old_order_meets_rate_test=meets, + ) + + +@settings( + max_examples=30, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) +@given(st.sampled_from(SYNTHETIC_YEARS), st.integers(0, 2**32 - 1)) +@example(2015, 0) +@example(2026, 0) +@example(2027, 0) +def test_fast_path_equals_full_search_with_marriage_allowance(system, year, seed): + # Invariant 12. + income_tax = system.parameters(str(year)).gov.hmrc.income_tax + limit = income_tax.rates.uk.thresholds[1] + for mix in ("free", "near-limit"): + inputs, default = synthetic_people(seed, mix=mix, basic_rate_limit=limit) + inputs = with_marriage_allowance(income_tax, year, inputs, default, seed) + assert_fast_path_equals_search(income_tax, inputs, default) + + +@pytest.mark.parametrize("year", [2018, 2026, 2027]) +def test_vertex_search_weighs_the_marriage_allowance(system, year): + """Invariant 11. The share found is feasible and no point on a 101 x 101 + grid, or on the thin sets where the rate test can hold, beats it.""" + income_tax = system.parameters(str(year)).gov.hmrc.income_tax + limit = income_tax.rates.uk.thresholds[1] + wins = 0 + for seed, mix in [(0, "free"), (1, "near-limit"), (2, "near-limit")]: + inputs, default = synthetic_people( + seed, count=300, mix=mix, basic_rate_limit=limit + ) + inputs = with_marriage_allowance(income_tax, year, inputs, default, seed) + c = _build(inputs, income_tax) + x, s, v = _least_tax_share(c) + found = tax_after_marriage_allowance(c, x, s, v)[:, 0] + total = c.step_3_income + low = np.maximum(0, total - c.savings - c.dividends) + high = np.minimum(c.first, total) + assert np.all(x >= low - 1e-6) and np.all(x <= high + 1e-6) + assert np.all(s >= -1e-6) and np.all(s <= c.savings + 1e-6) + assert np.all(v >= -1e-6) and np.all(v <= c.dividends + 1e-6) + fractions = np.linspace(0, 1, 101)[None, :] + line_x = low + (high - low) * fractions + grid_x = line_x.repeat(101, axis=1) + grid_s = np.tile(c.savings * fractions, 101) + all_x = np.concatenate([grid_x, line_x, line_x], axis=1) + all_w = np.concatenate( + [ + grid_x + grid_s, + np.broadcast_to(total, line_x.shape), + line_x + c.rate_test_savings_allowance, + ], + axis=1, + ) + all_x, all_s, all_v = _shares(c, all_x, all_w) + least = tax_after_marriage_allowance(c, all_x, all_s, all_v).min(axis=1) + worst = np.argmax(found - least) + assert np.all(found <= least + 1e-5), { + name: values[worst] for name, values in inputs.items() + } | {"found": found[worst], "least": least[worst]} + # The reduction changes the answer for someone, or this test proves + # nothing about it. + step_5_alone = _build( + inputs | dict(marriage_allowance_reduction=np.zeros(len(x))), income_tax + ) + x_alone, s_alone, v_alone = _least_tax_share(step_5_alone) + wins += np.sum( + np.abs(s[:, 0] - s_alone[:, 0]) + np.abs(v[:, 0] - v_alone[:, 0]) > 1 + ) + assert wins > 0 + + +def test_search_keeps_a_recipient_the_test_passes_exactly_at_the_limit(system): + """Pay of £38,570 and savings of £11,700 in 2026-27. The old order leaves + £26,000 of pay and £37,700 in all, exactly the basic rate limit, which the + rate test allows (s. 10(2)): 7,340 less the £252 reduction, 7,088. The + search's room below the limit doesn't count that share as meeting the test, + though a share that stops savings at the £1,000 savings allowance does, for + the same 7,088. So the old order must answer for itself, or the search would + move the recipient for no saving.""" + income_tax = system.parameters("2026").gov.hmrc.income_tax + inputs, default = one_person(savings=11_700, net_income=50_270) + inputs = with_marriage_allowance(income_tax, 2026, inputs, default, 0) + inputs["marriage_allowance_reduction"] = np.array([252.0]) + assert inputs["old_order_meets_rate_test"][0] + to_savings, to_dividends = least_tax_deductions( + inputs, income_tax, default.savings, default.dividends, search_all=True + ) + assert to_savings[0] == 0 and to_dividends[0] == 0 diff --git a/policyengine_uk/tests/test_marriage_allowance_cps_switch.py b/policyengine_uk/tests/test_marriage_allowance_cps_switch.py index 6c305411ee..817d10e8c5 100644 --- a/policyengine_uk/tests/test_marriage_allowance_cps_switch.py +++ b/policyengine_uk/tests/test_marriage_allowance_cps_switch.py @@ -31,6 +31,9 @@ 4. The allowance never falls when the statutory rate rises. 5. Switched on by its own parameters, the reform gives the allowance to the higher-rate earner too, worth the basic rate of the allowance. +6. A couple who meet the reform's expansion conditions have the allowance at + any share of reliefs and allowances, so the share follows the Step 5 tax + alone, and a couple who don't still have the reduction at stake. """ import functools @@ -451,3 +454,54 @@ def test_the_switched_on_reform_is_the_expanded_reform(simulation_for, drawn): explicit = run(simulation_for(reform=expanded_ma_reform), drawn) for variable in ("allowance", "meets_income_conditions", "income_tax"): np.testing.assert_array_equal(switched[variable], explicit[variable]) + + +# Invariant 6, by hand in 2025-26 (dividend rates 8.75% and 33.75%). Pay of +# £40,000, savings of £9,000 and dividends of £1,570. The whole allowance +# against pay gives the least Step 5 tax: 27,430 x 20% = 5,486, 8,500 of savings +# above the £500 savings allowance x 20% = 1,700, and 770 x 8.75% + 300 x 33.75% +# = 168.625, or 7,354.625. Without the reform that share fails the rate test, +# and a share leaving £37,500 of pay and £500 of savings passes it: 7,500 less +# the £252 reduction, 7,248. With the income condition removed, the couple have +# the £252 at any share and keep the lower Step 5 tax: 7,102.625. +SHARE_COUPLE = { + "people": { + "recipient": { + "age": {YEAR: 40}, + "employment_income": {YEAR: 40_000}, + "savings_interest_income": {YEAR: 9_000}, + "dividend_income": {YEAR: 1_570}, + }, + "partner": {"age": {YEAR: 40}}, + }, + "benunits": { + "benunit": {"members": ["recipient", "partner"], "is_married": {YEAR: True}} + }, + "households": {"household": {"members": ["recipient", "partner"]}}, +} + + +@pytest.mark.parametrize( + "reform, at_stake, to_savings, income_tax", + [ + (None, 252, 8_500, 7_248), + (change((REMOVE_INCOME_CONDITION, True)), 0, 0, 7_102.625), + ], + ids=["current-law", "income-condition-removed"], +) +def test_the_share_weighs_the_allowance_only_where_the_share_decides_it( + reform, at_stake, to_savings, income_tax +): + simulation = Simulation(situation=SHARE_COUPLE, reform=reform) + assert ( + simulation.calculate("marriage_allowance_reduction_at_stake", YEAR)[0] + == at_stake + ) + assert simulation.calculate("marriage_allowance", YEAR)[0] == 1_260 + assert ( + simulation.calculate("received_allowances_savings_income", YEAR)[0] + == to_savings + ) + assert simulation.calculate("income_tax", YEAR)[0] == pytest.approx( + income_tax, abs=0.01 + ) diff --git a/policyengine_uk/tests/test_uc_income_tax_properties.py b/policyengine_uk/tests/test_uc_income_tax_properties.py index 97f4b05284..77a465aa8c 100644 --- a/policyengine_uk/tests/test_uc_income_tax_properties.py +++ b/policyengine_uk/tests/test_uc_income_tax_properties.py @@ -10,9 +10,11 @@ earnings: - adding savings or dividend income never raises either slice, for every - person whose allowances it leaves unchanged (more income can taper the - Personal Allowance, which raises the tax on earnings for a reason of its - own). + person whose allowances and Step 6 tax reductions it leaves unchanged + (more income can taper the Personal Allowance, or fail a Marriage + Allowance recipient on the rate test, and either raises the tax on + earnings for a reason of its own). Everyone here lives alone, so no one + has the Marriage Allowance. """ import numpy as np diff --git a/policyengine_uk/utils/income_tax_allocation.py b/policyengine_uk/utils/income_tax_allocation.py index e30a754c71..538dfd94e8 100644 --- a/policyengine_uk/utils/income_tax_allocation.py +++ b/policyengine_uk/utils/income_tax_allocation.py @@ -84,15 +84,39 @@ people with that threshold at £2,000, a 101 x 101 grid found less tax than the search for 32 in 2026-27 (by up to £181.70) and 38 in 2027-28 (by up to £214.75). The fast path sends everyone to the search under such a reform. -The search keeps the order non-savings, savings, dividends unless another -share gives less tax, so it never charges more than that order. -The search ignores the Marriage Allowance. Parliament gives it as a tax -reduction (s. 55B(1)), which the taxpayer deducts at Step 6, not as a -deduction at Step 3, and whether a person qualifies for it (s. 55B(2)(b)) -depends on the share. The model deducts it at Step 6 too -(``marriage_allowance_tax_reduction``), so the allowance never enters the -Step 5 tax the search compares. +The Marriage Allowance adds one term. Parliament gives it as a tax reduction +at Step 6 (ss. 23 and 55B(1)), and only to a person liable at the rates it +lists (s. 55B(2)(b)), with dividends tested as if the dividend nil rate were +omitted (s. 55B(2)(ba)). The share decides those rates, so the share can win +or lose the reduction. Section 25(2) asks for the greatest reduction in +liability, and s. 23 fixes liability at Step 7, so the search compares ``f``: +the Step 5 tax less the reduction ``M`` where the share meets the rate test. +No share moves the other Step 6 reductions ``R``, and all the reductions +together leave ``max(0, f - R)`` of Step 5 tax, so the share with the least +``f`` leaves the least liability. Where ``R`` takes the tax to nil under more +than one share, the search can still move the share for a saving no one +receives. + +Where all the income left after the deductions exceeds the basic rate limit, +the rate test allows no dividends left, so the share lies on the line where +``w`` equals the Step 3 income, and the savings nil rate must cover any +savings above the limit. Under current rates that holds only with savings +left within the savings allowance (``w - x`` at most ``A``); a reform that +sets the basic rate limit below the starting rate limit plus the allowance +can make it hold all along that line. Otherwise only the limit on +non-savings income binds. So ``_candidates`` adds one vertical line, at that +limit, and the test's other edges already lie among the lines listed. On +each side of those edges ``f`` differs from the Step 5 tax by a constant, so +the least ``f`` again lies where two lines cross. + +The model stores its variables in float32, whose rounding can carry income at +a limit over it. So the search counts a share as meeting the rate test only +with room below each limit, under 4p on £38,000, and sets savings at the +savings allowance, or dividends at nil, exactly where the test reads them. + +The search keeps the order non-savings, savings, dividends unless another +share lowers ``f``, so it never leaves more liability than that order. Who cannot gain --------------- @@ -129,15 +153,23 @@ rate on the first component. Where any test holds, the search would keep the old order too, since it -changes the share only for a saving above ``TOLERANCE``. So both paths give -the same result, and ``test_income_tax_allocation.py`` checks it. +changes the share only for a saving above ``TOLERANCE``. A Marriage Allowance +recipient the old order denies the reduction still goes to the search, unless +even the least non-savings income any share leaves exceeds the limit in the +rate test. So both paths give the same result, and +``test_income_tax_allocation.py`` checks it. """ from types import SimpleNamespace import numpy as np +from policyengine_core import periods from policyengine_uk.utils.income_tax import first_extended_scottish_threshold +from policyengine_uk.utils.marriage_allowance import ( + marriage_allowance_rate_limits, + meets_marriage_allowance_rate_test, +) # A share replaces the order non-savings, savings, dividends only where it # saves more than this (in pounds), so rounding never moves a deduction. @@ -147,6 +179,11 @@ TIE = 1e-6 # Bound on people times candidates evaluated at once, to bound memory. CHUNK = 2_000_000 +# The search counts a share as meeting the Marriage Allowance rate test only +# with this much room below each limit: pounds, plus a fraction of the amounts +# involved, several times the float32 rounding of the variables the test reads. +RATE_TEST_ROOM = 1e-3 +RATE_TEST_ROOM_FRACTION = 1e-6 # The variables on earned_taxable_income_exclusions that the allocation sets # rather than reads. ALLOCATED_EXCLUSIONS = ( @@ -156,10 +193,9 @@ "received_allowances_earned_income", ) # The search leaves these exclusions out. Parliament gives the Marriage -# Allowance as a tax reduction (s. 55B(1)), not a deduction at Step 3, and -# whether a person qualifies (s. 55B(2)(b)) depends on the share, so the search -# shares a recipient's deductions as if they had none. The baseline list -# leaves the allowance out too, so this matters only under a reform that +# Allowance as a tax reduction at Step 6 (s. 55B(1)), not a deduction at Step 3, +# so the search weighs the reduction and takes no exclusion for it. The baseline +# list leaves the allowance out too, so this matters only under a reform that # lists it. IGNORED_EXCLUSIONS = ("marriage_allowance",) @@ -194,6 +230,7 @@ def values(variable): if variable not in ALLOCATED_EXCLUSIONS + IGNORED_EXCLUSIONS ] savings = values("taxable_savings_interest_income") + limits = marriage_allowance_rate_limits(person, period, income_tax) return dict( savings=savings, dividends=values("taxable_dividend_income"), @@ -208,13 +245,19 @@ def values(variable): gift=values("gift_aid_grossed_up"), scottish=np.asarray(person("pays_scottish_income_tax", period)), savings_allowance=values("savings_allowance"), + marriage_allowance_reduction=values("marriage_allowance_reduction_at_stake"), + rate_test_non_savings_limit=np.asarray(limits.non_savings, dtype=np.float64), + rate_test_savings_allowance=np.broadcast_to( + np.asarray(limits.savings_allowance, dtype=np.float64), savings.shape + ), + old_order_meets_rate_test=_old_order_meets_rate_test(person, period, limits), ) def _context(person, period, parameters): """Everything the tax on a share depends on, as float64 columns.""" income_tax = parameters(period).gov.hmrc.income_tax - return _build(_inputs(person, period, income_tax), income_tax) + return _build(_inputs(person, periods.period(period), income_tax), income_tax) def _build(inputs, income_tax): @@ -292,6 +335,27 @@ def _build(inputs, income_tax): c.shared = used c.offset = np.zeros_like(savings) c.step_3_income = c.first + savings + dividends - c.shared + # The Marriage Allowance rate test. These columns stay nil for a caller + # that passes no recipients. + zero = np.zeros_like(savings) + c.marriage_allowance_reduction = columns.get("marriage_allowance_reduction", zero) + c.old_order_meets_rate_test = columns.get("old_order_meets_rate_test", zero > 0) + c.rate_test_savings_allowance = columns.get("rate_test_savings_allowance", zero) + non_savings_limit = columns.get("rate_test_non_savings_limit", zero) + basic_rate_limit = c.bands[1] + # The rate test reads all the non-savings income, so it adds what s. 25(3A) + # leaves below the first component. + c.non_savings_below_first = c.other_left if c.other_first else zero + room = RATE_TEST_ROOM + RATE_TEST_ROOM_FRACTION * np.maximum( + np.maximum(np.abs(non_savings_limit), np.abs(basic_rate_limit)), + np.abs(c.non_savings_below_first + c.step_3_income), + ) + c.rate_test_limits = SimpleNamespace( + non_savings=non_savings_limit - room, + basic_rate=basic_rate_limit - room, + starting=c.starting_limit - room, + savings_allowance=c.rate_test_savings_allowance + TIE, + ) return c @@ -379,6 +443,27 @@ def step_5_tax(c, x, s, v): return earned_tax + property_tax + savings_tax + dividend_tax +def _meets_rate_test(c, x, s, v): + """Whether the share meets the Marriage Allowance rate test with room + below each limit. ``TIE`` covers float64 rounding of a share that leaves + savings at the savings allowance or no dividends.""" + return meets_marriage_allowance_rate_test( + x + c.non_savings_below_first, + s, + np.maximum(0, v - TIE), + c.rate_test_limits, + ) + + +def tax_after_marriage_allowance(c, x, s, v): + """The Step 5 tax less the Marriage Allowance reduction the share wins: + ``f`` in the module docstring.""" + tax = step_5_tax(c, x, s, v) + if not np.any(c.marriage_allowance_reduction > 0): + return tax + return tax - c.marriage_allowance_reduction * _meets_rate_test(c, x, s, v) + + def _candidates(c): """The points where two of the lines the module docstring lists cross.""" zero = np.zeros_like(c.savings) @@ -432,6 +517,20 @@ def _candidates(c): horizontal += [ limit - dividend_allowance - offset + zero for limit in c.dividend_scale[0] ] + if np.any(c.marriage_allowance_reduction > 0): + # The Marriage Allowance rate test's limit on non-savings income. Its + # other edges, no dividends left (w = total) and savings left at the + # savings allowance (w - x = allowance), already have lines. Everyone + # else repeats the line x = 0, so the search finds them the same share + # whoever else it searches. + recipient = c.marriage_allowance_reduction > 0 + vertical += [ + np.where( + recipient, + c.rate_test_limits.non_savings - c.non_savings_below_first, + zero, + ) + ] xs = np.concatenate([np.broadcast_to(v, zero.shape) for v in vertical], axis=1) ws = np.concatenate([np.broadcast_to(h, zero.shape) for h in horizontal], axis=1) ds = np.concatenate([np.broadcast_to(d, zero.shape) for d in diagonal], axis=1) @@ -480,10 +579,10 @@ def _shares(c, x, w): def _least_tax_share(c): - """The candidate share with the least Step 5 tax, per person. Of shares - within TIE of it, the one setting most against the first component, then - most against savings: the nearest the least tax allows to the order - non-savings, savings, dividends.""" + """The candidate share with the least ``f``, per person. Of shares within + TIE of it, the one setting most against the first component, then most + against savings: the nearest the least tax allows to the order non-savings, + savings, dividends.""" x, w = _candidates(c) n, count = x.shape tax = np.empty((n, count)) @@ -491,7 +590,7 @@ def _least_tax_share(c): for start in range(0, count, width): part = slice(start, start + width) x[:, part], w[:, part], _ = _shares(c, x[:, part], w[:, part]) - tax[:, part] = step_5_tax( + tax[:, part] = tax_after_marriage_allowance( c, x[:, part], w[:, part], c.step_3_income - x[:, part] - w[:, part] ) near = tax <= tax.min(axis=1, keepdims=True) + TIE @@ -621,11 +720,37 @@ def _old_order_is_least(c, to_savings, to_dividends): return nothing_to_share | (in_first & nil_rated) | (in_reach & rates_hold) +def _marriage_allowance_in_reach(c): + """Recipients the old order denies the Marriage Allowance reduction, where + the least non-savings income any share leaves falls within the rate test's + limit.""" + least_first = np.maximum(0, c.step_3_income - c.savings - c.dividends) + return ( + (c.marriage_allowance_reduction > 0) + & ~c.old_order_meets_rate_test + & (least_first + c.non_savings_below_first <= c.rate_test_limits.non_savings) + ) + + +def _exact_where_the_rate_test_reads(c, s, v): + """Savings and dividends left, with a recipient's savings at the savings + allowance, or dividends at nil, set exactly, so that float32 rounding keeps + them there.""" + recipient = c.marriage_allowance_reduction > 0 + allowance = c.rate_test_savings_allowance + s = np.where( + recipient & (np.abs(s - allowance) <= TIE), + np.minimum(c.savings, allowance), + s, + ) + return s, np.where(recipient & (v <= TIE), 0, v) + + def allocate_deductions(person, period, parameters): """Deductions against savings and dividend income in the share that gives - the least Income Tax (ITA 2007 s. 25(2) and (3A)). Where no share gives less - tax than the order non-savings, savings, dividends, the function returns - that order's deductions exactly.""" + the least Income Tax after the Marriage Allowance (ITA 2007 s. 25(2) and + (3A)). Where no share gives less tax than the order non-savings, savings, + dividends, the function returns that order's deductions exactly.""" default = default_order_deductions(person, period) income_tax = parameters(period).gov.hmrc.income_tax savings, dividends = least_tax_deductions( @@ -643,14 +768,18 @@ def least_tax_deductions( """The deductions from savings and dividends: the old order's (``to_savings`` and ``to_dividends``) unless another share saves more than TOLERANCE. The search runs only on people the tests in - ``_old_order_is_least`` leave, or on everyone with ``search_all``.""" + ``_old_order_is_least`` leave and on Marriage Allowance recipients whom + another share could win the reduction, or on everyone with + ``search_all``.""" to_savings = np.asarray(to_savings, dtype=np.float64) to_dividends = np.asarray(to_dividends, dtype=np.float64) if search_all: search = np.ones(len(to_savings), dtype=bool) else: - search = ~_old_order_is_least( - _build(inputs, income_tax), to_savings[:, None], to_dividends[:, None] + everyone = _build(inputs, income_tax) + search = ( + ~_old_order_is_least(everyone, to_savings[:, None], to_dividends[:, None]) + | _marriage_allowance_in_reach(everyone) )[:, 0] if not search.any(): return to_savings, to_dividends @@ -659,10 +788,13 @@ def least_tax_deductions( default_s = c.savings - to_savings[search][:, None] default_v = c.dividends - to_dividends[search][:, None] default_x = c.step_3_income - default_s - default_v - lower = ( - step_5_tax(c, x, s, v) - < step_5_tax(c, default_x, default_s, default_v) - TOLERANCE - )[:, 0] + # The old order takes the rate test's own answer, not the search's with + # room, so a person it already qualifies never moves for the reduction. + old_order_tax = step_5_tax(c, default_x, default_s, default_v) - ( + c.marriage_allowance_reduction * c.old_order_meets_rate_test + ) + lower = (tax_after_marriage_allowance(c, x, s, v) < old_order_tax - TOLERANCE)[:, 0] + s, v = _exact_where_the_rate_test_reads(c, s, v) moved = np.flatnonzero(search)[lower] to_savings, to_dividends = to_savings.copy(), to_dividends.copy() to_savings[moved] = (c.savings - s)[lower, 0] @@ -670,6 +802,35 @@ def least_tax_deductions( return to_savings, to_dividends +def _old_order_non_savings_left(person, period): + """Non-savings income left under the old order: net income less savings, + dividends and allowances, as the model has always computed it.""" + savings = np.maximum(0, person("taxable_savings_interest_income", period)) + dividends = np.maximum(0, person("taxable_dividend_income", period)) + return np.maximum( + 0, + person("net_income", period) + - savings + - dividends + - person("allowances", period), + ) + + +def _old_order_meets_rate_test(person, period, limits): + """Whether the old order meets the Marriage Allowance rate test, in the + arithmetic ``meets_marriage_allowance_income_conditions`` applies where the + share keeps that order.""" + default = default_order_deductions(person, period) + return meets_marriage_allowance_rate_test( + _old_order_non_savings_left(person, period), + np.maximum( + 0, person("taxable_savings_interest_income", period) - default.savings + ), + np.maximum(0, person("taxable_dividend_income", period) - default.dividends), + limits, + ) + + def non_savings_deductions(person, period): """Allowances against non-savings income beyond trade loss relief, the relief set against savings and dividends, and the non-savings income left @@ -698,9 +859,7 @@ def non_savings_deductions(person, period): non_savings = np.maximum(0, net_income + relief - savings - dividends) used = np.minimum(relief + allowances, non_savings + savings + dividends) to_non_savings = used - to_savings - to_dividends - # The old order: net income less savings, dividends and allowances, as the - # model has always computed it. - default_left = np.maximum(0, net_income - savings - dividends - allowances) + default_left = _old_order_non_savings_left(person, period) return SimpleNamespace( earned=np.where(moved, np.maximum(0, to_non_savings - relief), default.earned), relief_against_savings_and_dividends=np.where( diff --git a/policyengine_uk/utils/marriage_allowance.py b/policyengine_uk/utils/marriage_allowance.py new file mode 100644 index 0000000000..99b51f586b --- /dev/null +++ b/policyengine_uk/utils/marriage_allowance.py @@ -0,0 +1,98 @@ +"""The rate test for the Marriage Allowance (ITA 2007 s. 55B(2)(b) and (ba)). + +The test reads the income the reliefs and allowances leave in each component, +so it depends on how s. 25(2) shares them. +``meets_marriage_allowance_income_conditions`` applies it to the share the +model settles on, and ``income_tax_allocation`` to each share it compares. +""" + +from types import SimpleNamespace + +import numpy as np + + +def marriage_allowance_rate_limits(person, period, income_tax): + """The limits the rate test applies for the tax year. + + Parliament extends the rate limits by the grossed-up Gift Aid gift + (s. 414(2)(b)); the gift does not reduce taxable income. Before 2017-18 + this test uses the basic rate limit for Scottish taxpayers, even though + the model's historical Scottish liability currently reads the 2017-18 + schedule. + """ + rates = income_tax.rates + gross_gift = person("gift_aid_grossed_up", period) + # The Scottish higher rate follows the starter, basic and intermediate + # rates; in 2017-18 the Scottish scale had only the basic rate below it. + # Before 2017-18 the test holds a Scottish taxpayer to the basic rate + # limit. The Scottish rates of s. 11A start in 2016-17 (FA 2014 Sch. 38 + # para. 12, SI 2015/2000 art. 3). That year Parliament charged the Scottish + # basic rate on income that would otherwise bear the basic rate + # (s. 11A(1)). The model reads its Scottish scale's 2017-18 values in + # earlier years too, so the test sets the limit by the year. + scottish = rates.scotland.rates + basic_rate_limit = rates.uk.thresholds[1] + gross_gift + if period.start.year < 2017 or len(scottish.rates) < 3: + scottish_limit = basic_rate_limit + elif len(scottish.rates) == 3: + scottish_limit = scottish.thresholds[1] + gross_gift + else: + scottish_limit = scottish.thresholds[3] + gross_gift + non_savings_limit = np.where( + person("pays_scottish_income_tax", period), + scottish_limit, + basic_rate_limit, + ) + # Parliament gave the savings nil rate effect from 2016-17 (FA 2016 + # s. 4(17)). + if period.start.year < 2016: + savings_allowance = 0 + else: + savings_allowance = person("savings_allowance", period) + return SimpleNamespace( + non_savings=non_savings_limit, + basic_rate=basic_rate_limit, + starting=rates.savings_starter_rate.allowance, + savings_allowance=savings_allowance, + ) + + +def meets_marriage_allowance_rate_test(non_savings, savings, dividends, limits): + """Whether non-savings income, savings and dividends left after the + deductions bear only the rates Parliament permits (s. 55B(2)(b) and (ba)), + against ``marriage_allowance_rate_limits``.""" + # Savings sit above non-savings income (s. 16) and take the rest-of-UK + # limits (s. 11D(6)). Up to the basic rate limit they bear a basic rate or + # the starting rate for savings, both permitted: Parliament charges the + # starting rate only on savings that would otherwise bear a basic rate + # (s. 12(1), and s. 12(1)(b) from 2017-18). Above the limit it permits only + # the savings nil rate (s. 55B(2)(b)), which it charges on the lowest + # savings above the starting rate limit for savings, up to the savings + # allowance (s. 12A(1) to (4)); dividends sit above savings (s. 16) and + # don't move this slice. The person fails the test if any savings above the + # basic rate limit lie outside the slice. + below_dividends = non_savings + savings + nil_rate_start = np.maximum(non_savings, limits.starting) + nil_rate_top = np.minimum( + below_dividends, nil_rate_start + limits.savings_allowance + ) + savings_above_basic = np.maximum( + 0, below_dividends - np.maximum(non_savings, limits.basic_rate) + ) + nil_rate_above_basic = np.maximum( + 0, nil_rate_top - np.maximum(nil_rate_start, limits.basic_rate) + ) + savings_at_forbidden_rates = savings_above_basic - nil_rate_above_basic > 0 + # Dividends form the top slice, above all non-savings income and savings + # (s. 16); count them before the dividend allowance. Parliament charges the + # dividend upper rate on dividends that would otherwise bear the higher + # rate (s. 13(2)), and the higher rate starts at the basic rate limit plus + # the gross gift (ss. 10(3) and 414(2)(b)). + dividends_above_basic = (dividends > 0) & ( + below_dividends + dividends > limits.basic_rate + ) + return ( + (non_savings <= limits.non_savings) + & ~savings_at_forbidden_rates + & ~dividends_above_basic + ) diff --git a/policyengine_uk/utils/uc_income_tax.py b/policyengine_uk/utils/uc_income_tax.py index 39aa99efdf..6ef2acc4d5 100644 --- a/policyengine_uk/utils/uc_income_tax.py +++ b/policyengine_uk/utils/uc_income_tax.py @@ -86,8 +86,9 @@ def uc_income_tax_on_earnings_slice(person, period, parameters, include_trade=Tr # the model follows PAYE coding, which sets the allowances against the # job; moving them onto savings or dividends to lower the year's tax # would otherwise raise the tax on earnings. So the earnings slice reads - # the old order's deductions, and more savings or dividends never raise - # it. + # the old order's deductions, and more savings or dividends raise it + # only by taking away a Step 6 tax reduction, as when they fail a + # Marriage Allowance recipient on the rate test. default = default_order_deductions(person, period) # The income left in earned taxable income before allowances, under # that order. The part of it above earnings is the person's other diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance.py index 75ea9edc77..77507c1c3e 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance.py @@ -31,28 +31,6 @@ class marriage_allowance(Variable): unit = GBP def formula(person, period, parameters): - marital = person("marital_status", period) - married = marital == marital.possible_values.MARRIED - # The election is between spouses or civil partners (ITA 2007 - # s.55C(1)(a)): the claimant and partner of a married benefit unit, - # never their children. - spouse_or_civil_partner = person("is_claimant_or_partner", period) - eligible = ( - married - & spouse_or_civil_partner - & person("meets_marriage_allowance_income_conditions", period) + return person("meets_marriage_allowance_income_conditions", period) * ( + person("marriage_allowance_if_income_conditions_met", period) ) - transferable_amount = person("partners_unused_personal_allowance", period) - allowances = parameters(period).gov.hmrc.income_tax.allowances - capped_percentage = allowances.marriage_allowance.max - max_amount = allowances.personal_allowance.amount * capped_percentage - amount_if_eligible_pre_rounding = max_(min_(transferable_amount, max_amount), 0) - # Round up. - rounding_increment = allowances.marriage_allowance.rounding_increment - amount_if_eligible = ( - np.ceil(amount_if_eligible_pre_rounding / rounding_increment) - * rounding_increment - ) - # Use pre-generated take-up decision from dataset - would_claim = person("would_claim_marriage_allowance", period) - return eligible * amount_if_eligible * would_claim diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_if_income_conditions_met.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_if_income_conditions_met.py new file mode 100644 index 0000000000..e9364957af --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_if_income_conditions_met.py @@ -0,0 +1,38 @@ +from policyengine_uk.model_api import * + + +class marriage_allowance_if_income_conditions_met(Variable): + value_type = float + entity = Person + label = "Marriage Allowance if the income conditions are met" + documentation = ( + "The amount of personal allowance transferred to this person if they " + "meet the income conditions (ITA 2007 s. 55B(2)(b) and (ba)). It " + "reads neither those conditions nor the share of reliefs and " + "allowances, so the search for that share can read it." + ) + definition_period = YEAR + reference = "https://www.legislation.gov.uk/ukpga/2007/3/part/3/chapter/3A" + unit = GBP + + def formula(person, period, parameters): + marital = person("marital_status", period) + married = marital == marital.possible_values.MARRIED + # The election is between spouses or civil partners (ITA 2007 + # s.55C(1)(a)): the claimant and partner of a married benefit unit, + # never their children. + spouse_or_civil_partner = person("is_claimant_or_partner", period) + transferable_amount = person("partners_unused_personal_allowance", period) + allowances = parameters(period).gov.hmrc.income_tax.allowances + capped_percentage = allowances.marriage_allowance.max + max_amount = allowances.personal_allowance.amount * capped_percentage + amount_if_eligible_pre_rounding = max_(min_(transferable_amount, max_amount), 0) + # Round up. + rounding_increment = allowances.marriage_allowance.rounding_increment + amount_if_eligible = ( + np.ceil(amount_if_eligible_pre_rounding / rounding_increment) + * rounding_increment + ) + # Use pre-generated take-up decision from dataset + would_claim = person("would_claim_marriage_allowance", period) + return married * spouse_or_civil_partner * amount_if_eligible * would_claim diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_reduction_at_stake.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_reduction_at_stake.py new file mode 100644 index 0000000000..64451b0310 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_reduction_at_stake.py @@ -0,0 +1,33 @@ +from policyengine_uk.model_api import * + + +class marriage_allowance_reduction_at_stake(Variable): + value_type = float + entity = Person + label = "Marriage Allowance tax reduction at stake in the share of deductions" + documentation = ( + "The Marriage Allowance tax reduction this person receives only if " + "the share of reliefs and allowances meets the income conditions " + "(ITA 2007 s. 55B(2)(b) and (ba)): the appropriate percentage of the " + "allowance they would then receive (s. 55B(1) and (3)), before the " + "limit of the Step 5 tax left. The search for that share (s. 25(2)) " + "weighs it, and a reform that gives the allowance at any share sets " + "it to nil." + ) + definition_period = YEAR + reference = [ + dict( + title="Income Tax Act 2007 s. 55B", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/55B", + ), + 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): + return person("marriage_allowance_appropriate_percentage", period) * person( + "marriage_allowance_if_income_conditions_met", period + ) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py index e868a10b7b..c2114c7381 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py @@ -1,4 +1,8 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.marriage_allowance import ( + marriage_allowance_rate_limits, + meets_marriage_allowance_rate_test, +) def liable_only_at_marriage_allowance_rates(person, period, parameters): @@ -28,80 +32,25 @@ def liable_only_at_marriage_allowance_rates(person, period, parameters): the test reads income after allowances and the savings, dividend and Gift Aid amounts directly. """ - rates = parameters(period).gov.hmrc.income_tax.rates - gross_gift = person("gift_aid_grossed_up", period) # Earnings, pensions, property and other non-savings income after the # allowances set against it (s. 25(2)): the income below savings and - # dividends (s. 16). - non_savings = person("non_savings_income_after_allowances", period) - # The Scottish higher rate follows the starter, basic and intermediate - # rates; in 2017-18 the Scottish scale had only the basic rate below it. - # Before 2017-18 the test holds a Scottish taxpayer to the basic rate - # limit. The Scottish rates of s. 11A start in 2016-17 (FA 2014 Sch. 38 - # para. 12, SI 2015/2000 art. 3). That year Parliament charged the Scottish - # basic rate on income that would otherwise bear the basic rate - # (s. 11A(1)). The model reads its Scottish scale's 2017-18 values in - # earlier years too, so the test sets the limit by the year. - scottish = rates.scotland.rates - basic_rate_limit = rates.uk.thresholds[1] + gross_gift - if period.start.year < 2017 or len(scottish.rates) < 3: - scottish_limit = basic_rate_limit - elif len(scottish.rates) == 3: - scottish_limit = scottish.thresholds[1] + gross_gift - else: - scottish_limit = scottish.thresholds[3] + gross_gift - non_savings_limit = where( - person("pays_scottish_income_tax", period), - scottish_limit, - basic_rate_limit, - ) - # Savings sit above non-savings income (s. 16) and take the rest-of-UK - # limits (s. 11D(6)). Up to the basic rate limit they bear a basic rate or - # the starting rate for savings, both permitted: Parliament charges the - # starting rate only on savings that would otherwise bear a basic rate - # (s. 12(1), and s. 12(1)(b) from 2017-18). Above the limit it permits only - # the savings nil rate (s. 55B(2)(b)), which it charges on the lowest - # savings above the starting rate limit for savings, up to the savings - # allowance (s. 12A(1) to (4)); dividends sit above savings (s. 16) and - # don't move this slice. The person fails the test if any savings above the - # basic rate limit lie outside the slice. - savings = max_( - 0, - person("taxable_savings_interest_income", period) - - person("received_allowances_savings_income", period), - ) - # Parliament gave the savings nil rate effect from 2016-17 (FA 2016 - # s. 4(17)). - if period.start.year < 2016: - savings_allowance = 0 - else: - savings_allowance = person("savings_allowance", period) - below_dividends = non_savings + savings - nil_rate_start = max_(non_savings, rates.savings_starter_rate.allowance) - nil_rate_top = min_(below_dividends, nil_rate_start + savings_allowance) - savings_above_basic = max_(0, below_dividends - max_(non_savings, basic_rate_limit)) - nil_rate_above_basic = max_( - 0, nil_rate_top - max_(nil_rate_start, basic_rate_limit) - ) - savings_at_forbidden_rates = savings_above_basic - nil_rate_above_basic > 0 - # Dividends form the top slice, above all non-savings income and savings - # (s. 16); count them before the dividend allowance. Parliament charges the - # dividend upper rate on dividends that would otherwise bear the higher - # rate (s. 13(2)), and the higher rate starts at the basic rate limit plus - # the gross gift (ss. 10(3) and 414(2)(b)). - dividends = max_( - 0, - person("taxable_dividend_income", period) - - person("received_allowances_dividend_income", period), - ) - dividend_upper_limit = basic_rate_limit - dividends_above_basic = (dividends > 0) & ( - below_dividends + dividends > dividend_upper_limit - ) - return ( - (non_savings <= non_savings_limit) - & ~savings_at_forbidden_rates - & ~dividends_above_basic + # dividends (s. 16). Savings and dividends left after the allowances the + # share sets against them. + return meets_marriage_allowance_rate_test( + person("non_savings_income_after_allowances", period), + max_( + 0, + person("taxable_savings_interest_income", period) + - person("received_allowances_savings_income", period), + ), + max_( + 0, + person("taxable_dividend_income", period) + - person("received_allowances_dividend_income", period), + ), + marriage_allowance_rate_limits( + person, period, parameters(period).gov.hmrc.income_tax + ), )