diff --git a/changelog.d/2106.fixed.md b/changelog.d/2106.fixed.md new file mode 100644 index 0000000000..0950505e2d --- /dev/null +++ b/changelog.d/2106.fixed.md @@ -0,0 +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. 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/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..91ffd405cf --- /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, the taxpayer deducts them under s. 25(2) alone. + 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/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/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 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 new file mode 100644 index 0000000000..0d4bc2a9b1 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml @@ -0,0 +1,554 @@ +# 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, 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 +# 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 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%. + received_allowances_savings_income: 0 + received_allowances_dividend_income: 12_570 + savings_income_tax: 0 + income_tax: 744.975 + +- 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: + 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 the taxpayer sets a trading loss 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 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 + # 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 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 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. + 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 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 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. + 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 + +- 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: + # 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 + 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 + +- name: Case 13, a Marriage Allowance recipient the old order already qualifies keeps it + 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 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)). 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 + # 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] + +- 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. 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 + 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 + - 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 + +- 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/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..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 @@ -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 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 all bears 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 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 + 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..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 @@ -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 bear 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..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 @@ -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 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 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 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 e6384e97ee..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 @@ -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 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] + 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/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 40f4a75fde..221c5b036a 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 + # 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 - name: Case 3, pay and property income together form the band base. period: 2026 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..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 @@ -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 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: 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 remains: (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..eb2c734fde 100644 --- a/policyengine_uk/tests/policy/integration/income.yaml +++ b/policyengine_uk/tests/policy/integration/income.yaml @@ -20,12 +20,22 @@ 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 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%, 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 - 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 +65,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 +73,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/shard_durations.toml b/policyengine_uk/tests/shard_durations.toml index 5e04dd9d37..50febb8d86 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 @@ -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_budget_2026_savings_property_rates_properties.py b/policyengine_uk/tests/test_budget_2026_savings_property_rates_properties.py index 09c62063dd..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 @@ -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,16 @@ 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). 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 @@ -56,10 +67,14 @@ 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 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) def situation(): @@ -88,7 +103,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", @@ -101,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): @@ -116,9 +142,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 +168,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 +178,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 +190,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 +200,31 @@ 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_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) +@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 +233,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_gift_aid.py b/policyengine_uk/tests/test_gift_aid.py index 7beb9f00b4..91b4d18244 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 + # 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] + + [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 new file mode 100644 index 0000000000..cd34ba7a18 --- /dev/null +++ b/policyengine_uk/tests/test_income_tax_allocation.py @@ -0,0 +1,1096 @@ +"""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 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 + 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. +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") + +YEARS = (2026, 2027) +PEOPLE = 8 +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=8, + 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 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 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 + 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] + # 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) + + +# 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, 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), + # 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), + # 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. + (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), + # 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. + (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), + # 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), +] + + +@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", 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; + "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): + 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) + 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( + 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) + 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, exclusion, gift = ( + np.concatenate([column, rows[:, i]]) + for i, column in enumerate( + [ + other, + property_income, + savings, + dividends, + relief, + extra, + excluded * 1_260.0, + gift, + ] + ) + ) + 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. + exclusion = 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 + pension).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, + pension=pension.astype(f32), + earned_exclusions=exclusion.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(2025, 0) +@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() + + +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: 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, + 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) + + +# 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_property_income_band_base_properties.py b/policyengine_uk/tests/test_property_income_band_base_properties.py index 98abf4cfd7..b489636a0d 100644 --- a/policyengine_uk/tests/test_property_income_band_base_properties.py +++ b/policyengine_uk/tests/test_property_income_band_base_properties.py @@ -10,21 +10,29 @@ amount leaves savings and dividend tax unchanged, in England and in Scotland. (The taxable amount, because the £1,000 property allowance can relieve small property income in full.) -2. Accounting: non_savings_taxable_income equals adjusted net income less - savings, dividends and allowances, floored at nil. The Marriage Allowance - does not enter it: the model gives the allowance as a tax reduction at - Step 6 (ITA 2007 s. 55B(1)). -3. Monotonic: more property income never lowers savings or dividend tax. +2. Accounting: 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. The Marriage Allowance does not enter it: the + model gives the allowance as a tax reduction at Step 6 (ITA 2007 s. 55B(1)). + Under s. 25(2) the model can set allowances against savings or 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 + 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.) """ 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) @@ -42,6 +50,9 @@ "taxable_savings_interest_income", "taxable_dividend_income", "allowances", + "received_allowances_savings_income", + "received_allowances_dividend_income", + "income_tax", ] @@ -101,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) @@ -110,19 +124,41 @@ 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. 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") - 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 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(): + """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) + 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) 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 807ee24603..8cd9c33b3c 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. @@ -429,17 +429,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 7bc8837dd7..58dd3dda12 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 @@ -317,8 +320,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"] @@ -385,7 +390,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 @@ -399,9 +404,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/tests/test_uc_income_tax_properties.py b/policyengine_uk/tests/test_uc_income_tax_properties.py new file mode 100644 index 0000000000..77a465aa8c --- /dev/null +++ b/policyengine_uk/tests/test_uc_income_tax_properties.py @@ -0,0 +1,144 @@ +"""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: 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 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 +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 new file mode 100644 index 0000000000..538dfd94e8 --- /dev/null +++ b/policyengine_uk/utils/income_tax_allocation.py @@ -0,0 +1,873 @@ +"""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), the taxpayer +deducts reliefs and allowances "in the way which will result in the greatest +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). 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, under which +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 +---------------------------------- +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 - 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`` 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 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 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. 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 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 +--------------- +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 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 +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 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``. 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. +TOLERANCE = 1e-3 +# 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 +# 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 = ( + "taxable_savings_interest_income", + "taxable_dividend_income", + "taxable_property_income", + "received_allowances_earned_income", +) +# The search leaves these exclusions out. Parliament gives the Marriage +# 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",) + + +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 _inputs(person, period, income_tax): + """The amounts the tax on a share depends on, one float64 value a person.""" + + def values(variable): + return np.asarray(person(variable, period), dtype=np.float64) + + exclusions = [ + variable + for variable in income_tax.earned_taxable_income_exclusions + 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"), + relief=values("trade_loss_relief_against_general_income"), + 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) + ), + 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, periods.period(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 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)); for relief at + # source Parliament raises the basic and higher rate limits instead + # (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)), + 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"] - pension + 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, + dividends=dividends, + non_savings=non_savings, + property=property_income, + used=used, + earned_exclusions=columns["earned_exclusions"], + gift=gift, + scottish=columns["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=savings_rates, + property_rates=property_rates, + starting_limit=rates.savings_starter_rate.allowance, + phase_out=rates.savings_starter_rate.phase_out, + savings_allowance=columns["savings_allowance"], + 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 - c.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 + # 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 + + +def _non_savings_tax(c, income): + return np.where(c.scottish, _tax(income, c.scotland), _tax(income, c.uk)) + + +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 + 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, + # 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. + top, start = _savings_band(c, earned + property_left, s) + low, basic_limit, higher_limit = c.bands + 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 _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) + 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 + 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) + ] + total = c.step_3_income + 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] + ] + 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) + 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 ``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)) + 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] = 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 + 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.""" + 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, net_income_before_relief - 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 _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 _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): + """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 + 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(step >= 0 for _, steps in scales for step in steps) + 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: + rho_n = np.where( + c.scottish, _rate(c.scotland, other + x), _rate(property_scale, other + x) + ) + in_reach = in_first + else: + # 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)) + # 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, + 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 _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 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( + _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 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: + 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 + c = _build({name: values[search] for name, values in inputs.items()}, income_tax) + x, s, v = _least_tax_share(c) + 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 + # 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] + to_dividends[moved] = (c.dividends - v)[lower, 0] + 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 + 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 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 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) + 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)) + relief = person("trade_loss_relief_against_general_income", period).astype( + np.float64 + ) + 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 + 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( + moved, + 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/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 38658d8956..6ef2acc4d5 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 earnings_tax_reductions(person, period, parameters): @@ -46,15 +47,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 @@ -79,13 +81,46 @@ 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. - income_in_base = person("net_income", period) - add( - person, period, excluded_incomes + # The slice follows the order non-savings, savings, dividends, not the + # least-tax share of the reliefs and allowances (ITA 2007 s. 25(2)): + # 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 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 + # 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) + + 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/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 3089892bd2..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 - # allowances, which go to it first: 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 + # 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). 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 + ), ) 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..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 @@ -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 it holds what 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..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 @@ -1,31 +1,26 @@ 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. 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 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..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 @@ -16,6 +24,17 @@ 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) + 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 67ad30d77d..c42df88337 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): @@ -6,31 +7,41 @@ class non_savings_income_after_allowances(Variable): entity = Person label = "Non-savings, non-dividend income for Income Tax after allowances" documentation = ( - "The model subtracts savings income, dividend income and allowances " - "from net income and floors the result at zero, so it sets allowances " - "against this income first. Parliament treats savings and dividends " - "as the highest part of income, above this income (ITA 2007 s. 16). " - "It raises the rate limits by the grossed-up Gift Aid gift " - "(s. 414(2)(b)), which does not reduce this taxable income. " - "property_income_tax, taxable_property_income_after_allowances, the " + "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 the reliefs and allowances set against non-savings " + "income, floored at nil: the income below savings and dividends " + "(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)). Parliament raises the rate limits by " + "the grossed-up Gift Aid gift (s. 414(2)(b)). 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, earned_taxable_income, the " "Marriage Allowance rate test and, for Scottish taxpayers, tax_band " - "read it." + "read it, and non_savings_taxable_income reads it through " + "taxable_property_income_after_allowances and earned_taxable_income." ) 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): - 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. - person("net_income", period) - - person("taxable_savings_interest_income", period) - - person("taxable_dividend_income", period) - - person("allowances", 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/bracketized_liability/tax_band.py b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_liability/tax_band.py index 320fd79c12..e44c34a8b1 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_liability/tax_band.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_liability/tax_band.py @@ -65,9 +65,9 @@ def scottish_tax_band(person, period, parameters): TaxBand.HIGHER, ] + [TaxBand.ADDITIONAL] * (len(scale.thresholds) - 4) labels = np.array([band.index for band in [TaxBand.NONE] + scottish_bands]) - # Allowances go to this income first, as in earned_income_tax. Parliament - # gives the Marriage Allowance as a tax reduction (s. 55B(1)), so it does - # not move the band. + # Income after the allowances set against it (s. 25(2)), as in + # earned_income_tax. Parliament gives the Marriage Allowance as a tax + # reduction (s. 55B(1)), so it does not move the band. non_savings = person("non_savings_income_after_allowances", period) # Parliament raises the Scottish basic rate's upper limit and every limit # above it by the grossed-up gift (s. 414(2)(b)). 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..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 @@ -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,16 @@ 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. 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 @@ -24,10 +26,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