diff --git a/changelog.d/1947.fixed.md b/changelog.d/1947.fixed.md new file mode 100644 index 0000000000..59d59411f5 --- /dev/null +++ b/changelog.d/1947.fixed.md @@ -0,0 +1 @@ +- Model Marriage Allowance as ITA 2007 ss. 55A-55E provide: the electing spouse gives up the fixed transferable amount from their own personal allowance and the other spouse gets a Step 6 tax reduction of the basic (or Scottish basic) rate times that amount, instead of the recipient's taxable income falling by the transferor's unused allowance. diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/eligible_bands.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/eligible_bands.yaml deleted file mode 100644 index 526252c4ed..0000000000 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/eligible_bands.yaml +++ /dev/null @@ -1,14 +0,0 @@ -description: Tax bands whose members may transfer personal allowance under the Marriage Allowance election (ITA 2007 s.55B). A transferor is eligible if they are not liable at the higher rate or above; the Scottish intermediate rate is treated as within the basic-rate envelope for this purpose. -values: - 0000-01-01: - - NONE - - STARTER - - BASIC - - INTERMEDIATE -metadata: - unit: list - label: Marriage Allowance eligible tax bands - period: year - reference: - - title: "Income Tax Act 2007, s. 55B" - href: https://www.legislation.gov.uk/ukpga/2007/3/section/55B diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/max.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/max.yaml index bbde24b1b1..21e32ebf9f 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/max.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/max.yaml @@ -1,9 +1,9 @@ -description: Maximum Marriage Allowance taxable income reduction, as a percentage of the full Personal Allowance. +description: Marriage Allowance transferable amount, as a share of the personal allowance in ITA 2007 s. 35(1). For 2015-16 the Act fixes the amount at £1,060, which is 10% of that year's £10,600 allowance. values: - 2016-04-01: 0.1 + 2015-04-06: 0.1 metadata: unit: /1 - label: Marriage Allowance maximum percentage + label: Marriage Allowance transferable share of the personal allowance reference: - - title: Income Tax Act 2007 s. 55B(4)(b) - href: https://www.legislation.gov.uk/ukpga/2007/3/part/3/chapter/3A + - title: Income Tax Act 2007 s. 55B(4) + href: https://www.legislation.gov.uk/ukpga/2007/3/section/55B diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/rounding_increment.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/rounding_increment.yaml index af43fa56bd..c396ce4e47 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/rounding_increment.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/rounding_increment.yaml @@ -1,4 +1,4 @@ -description: The Marriage Allowance is rounded up by this increment. +description: The Marriage Allowance transferable amount is rounded up to a multiple of this amount. values: 2016-04-01: 10 metadata: @@ -6,4 +6,4 @@ metadata: label: Marriage Allowance rounding increment reference: - title: Income Tax Act 2007 s. 55B(5) - href: https://www.legislation.gov.uk/ukpga/2007/3/part/3/chapter/3A + href: https://www.legislation.gov.uk/ukpga/2007/3/section/55B diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/earned_taxable_income_exclusions.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/earned_taxable_income_exclusions.yaml index d40254f49b..f4e83a3d2c 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/earned_taxable_income_exclusions.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/earned_taxable_income_exclusions.yaml @@ -5,7 +5,6 @@ values: - taxable_dividend_income - taxable_property_income - received_allowances_earned_income - - marriage_allowance metadata: unit: list diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/income_tax_subtractions.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/income_tax_subtractions.yaml index e6bc103f75..45f66d9ad8 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/income_tax_subtractions.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/income_tax_subtractions.yaml @@ -12,3 +12,4 @@ values: - capped_mcad - other_tax_credits - property_finance_cost_relief + - marriage_allowance_tax_reduction diff --git a/policyengine_uk/reforms/cps/marriage_tax_reforms.py b/policyengine_uk/reforms/cps/marriage_tax_reforms.py index 813cfc4570..c0eaca3ab1 100644 --- a/policyengine_uk/reforms/cps/marriage_tax_reforms.py +++ b/policyengine_uk/reforms/cps/marriage_tax_reforms.py @@ -1,10 +1,14 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.hmrc.income_tax.allowances.meets_marriage_allowance_income_conditions import ( + liable_only_at_marriage_allowance_rates, +) from typing import Union, Optional def create_expanded_ma_reform( max_child_age: Optional[int] = None, child_education_levels: Optional[List[str]] = None, + remove_income_condition: bool = True, ) -> Reform: class meets_expanded_ma_conditions(Variable): label = "Qualifies for an expanded Marriage Allowance" @@ -32,70 +36,54 @@ def formula(person, period): class meets_marriage_allowance_income_conditions(Variable): label = "Meets Marriage Allowance income conditions" - documentation = "Whether this person (and their partner) meets the conditions for this person to be eligible for the Marriage Allowance, as set out in the Income Tax Act 2007 sections 55B and 55C" + documentation = ( + "Whether this person pays income tax only at the rates that allow " + "a Marriage Allowance election, or meets the reform's expansion " + "conditions, which lift that restriction." + ) entity = Person definition_period = YEAR value_type = bool reference = "https://www.legislation.gov.uk/ukpga/2007/3/section/55B" def formula(person, period, parameters): - band = person("tax_band", period) - eligible_bands = parameters( - period - ).gov.hmrc.income_tax.allowances.marriage_allowance.eligible_bands - base_eligible = np.isin(band.decode_to_str(), eligible_bands) - # Expand to higher bands if the reform's expansion conditions are met. - bands = band.possible_values - expansion = person("meets_expanded_ma_conditions", period) & ( - (band == bands.HIGHER) | (band == bands.ADDITIONAL) + conditions = liable_only_at_marriage_allowance_rates( + person, period, parameters ) - return base_eligible | expansion + if not remove_income_condition: + return conditions + return conditions | person("meets_expanded_ma_conditions", period) - class marriage_allowance(Variable): + class marriage_allowance_transferable_amount(Variable): value_type = float entity = Person - label = "Marriage Allowance" + label = "Marriage Allowance transferable amount" + documentation = ( + "The share of the personal allowance a Marriage Allowance election " + "transfers, at the reform's rate for couples meeting its expansion " + "conditions, rounded up to the rounding increment." + ) definition_period = YEAR - reference = "https://www.legislation.gov.uk/ukpga/2007/3/part/3/chapter/3A" + reference = "https://www.legislation.gov.uk/ukpga/2007/3/section/55B" unit = GBP def formula(person, period, parameters): - marital = person("marital_status", period) - married = marital == marital.possible_values.MARRIED - eligible = ( - married - & person("is_claimant_or_partner", period) - & person("meets_marriage_allowance_income_conditions", period) - ) - transferable_amount = person("partners_unused_personal_allowance", period) - allowances = parameters(period).gov.hmrc.income_tax.allowances - capped_percentage = allowances.marriage_allowance.max - expanded_ma_cap = parameters( - period - ).gov.contrib.cps.marriage_tax_reforms.expanded_ma.ma_rate - capped_percentage = where( + p = parameters(period) + allowances = p.gov.hmrc.income_tax.allowances + share = where( person("meets_expanded_ma_conditions", period), - expanded_ma_cap, - capped_percentage, - ) - 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 + p.gov.contrib.cps.marriage_tax_reforms.expanded_ma.ma_rate, + allowances.marriage_allowance.max, ) - takes_up = person("would_claim_marriage_allowance", period) - return eligible * amount_if_eligible * takes_up + amount = allowances.personal_allowance.amount * share + increment = allowances.marriage_allowance.rounding_increment + return np.ceil(amount / increment) * increment 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_transferable_amount) return reform @@ -220,6 +208,7 @@ def create_marriage_tax_reform(parameters, period): if remove_income_condition or rate != original_rate: ma_reform = create_expanded_ma_reform( max_child_age=ma_max_child_age if ma_max_child_age > 0 else None, + remove_income_condition=bool(remove_income_condition), ) else: ma_reform = None @@ -246,4 +235,5 @@ def create_marriage_tax_reform(parameters, period): # These build the reform classes with no child-age / education conditions, so the reform # applies to every married couple regardless of child presence. expanded_ma_reform = create_expanded_ma_reform() +expanded_ma_reform_rate_only = create_expanded_ma_reform(remove_income_condition=False) marriage_neutral_it_reform = create_marriage_neutral_income_tax_reform() diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml index 99132fcbd3..9f02d59516 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml @@ -119,8 +119,8 @@ output: # Pensioner: (16,000 - 12,570) x 20% = 686, on State Pension. # Partner: (13,000 - 12,570) x 20% = 86 and (13,000 - 12,569.96) x 8% - # = 34.40. Both are above the personal allowance, so there is no - # Marriage Allowance. + # = 34.40. Either spouse electing for Marriage Allowance would cost them + # 252 and save the other 252, so the couple does not elect. income_tax: [686, 86] uc_income_tax_on_earnings: [0, 86] uc_national_insurance_on_earnings: [0, 34.40] @@ -201,11 +201,10 @@ household: members: [recipient, transferor] output: - # The transferor's unused allowance of 2,570 exceeds the 1,257 cap, which - # rounds up to 1,260. Tax on the recipient's earnings: - # (15,000 - 12,570 - 1,260) x 20% = 234, the same as 486 less the - # 20% x 1,260 = 252 reduction. Dividends: (5,000 - 500) x 10.75% = 483.75, - # not deducted. + # The transferor (10,000, below the cut allowance of 11,310) elects, and + # the recipient's tax falls by 20% x 1,260 = 252. Tax on the recipient's + # earnings: (15,000 - 12,570) x 20% = 486, less the 252 reduction = 234. + # Dividends: (5,000 - 500) x 10.75% = 483.75, not deducted. marriage_allowance: [1_260, 0] income_tax: [717.75, 0] uc_income_tax_on_earnings: [234, 0] diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml index 673614ccf2..f496be2837 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml @@ -63,14 +63,556 @@ marriage_allowance: [0, 0, 0, 0] income_tax: [9_432, 4_486, 0, 0] +# ITA 2007 ss. 55A-55E. 2026-27: personal allowance 12,570 (s. 35(1)), so the +# transferable amount is 10% x 12,570 = 1,257, rounded up to 1,260 +# (s. 55B(4)(b), (5)). The transferor's allowance falls to 11,310 (s. 55B(6)); +# the recipient's tax falls by 20% x 1,260 = 252 at Step 6 (s. 55A(2), +# s. 55B(1), (3)), capped at their Step 5 tax (s. 29(2)). The couple elects +# only when that lowers their combined tax. + +- name: Issue example - transferor between the cut and the full allowance + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 30_000 + transferor: + age: 38 + employment_income: 12_000 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + # Transferor: (12,000 - 11,310) x 20% = 138. Recipient: + # (30,000 - 12,570) x 20% - 252 = 3,486 - 252 = 3,234. The couple pays + # 3,372 against 3,486 without the election. + makes_marriage_allowance_election: [false, true] + marriage_allowance_relinquished: [0, 1_260] + marriage_allowance: [1_260, 0] + personal_allowance: [12_570, 11_310] + marriage_allowance_tax_reduction: [252, 0] + income_tax: [3_234, 138] + +- name: Transferor with no income + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 30_000 + transferor: + age: 38 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + makes_marriage_allowance_election: [false, true] + marriage_allowance: [1_260, 0] + income_tax: [3_234, 0] + +- name: Transferor just under the allowance still saves the couple tax + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 30_000 + transferor: + age: 38 + employment_income: 12_500 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + # Transferor: (12,500 - 11,310) x 20% = 238, less than the 252 saved. + makes_marriage_allowance_election: [false, true] + income_tax: [3_234, 238] + +- name: Transferor using the whole allowance gains nothing, so the couple does not elect + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 30_000 + transferor: + age: 38 + employment_income: 12_570 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + # Electing would cost the transferor 1,260 x 20% = 252, exactly the + # recipient's reduction. + makes_marriage_allowance_election: [false, false] + marriage_allowance: [0, 0] + personal_allowance: [12_570, 12_570] + income_tax: [3_486, 0] + +- name: Issue departure 1 - transferor with 15,000 of interest stays at the starting rate for savings + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 30_000 + transferor: + age: 38 + savings_interest_income: 15_000 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + # Transferor: 15,000 - 11,310 = 3,690 of interest, all inside the 5,000 + # starting rate for savings at 0% (s. 12), so they can elect + # (s. 55C(1)(c)) and pay nothing more. Recipient: 3,486 - 252 = 3,234. + makes_marriage_allowance_election: [false, true] + personal_allowance: [12_570, 11_310] + income_tax: [3_234, 0] + +- name: Issue departure 1 - transferor with 12,000 of interest + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 30_000 + transferor: + age: 38 + savings_interest_income: 12_000 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + makes_marriage_allowance_election: [false, true] + income_tax: [3_234, 0] + +- name: Issue departure 2 - recipient taxed mostly on dividends keeps the reduction + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 12_570 + dividend_income: 5_000 + transferor: + age: 38 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + # Earnings use the whole allowance. Dividends: 500 at the dividend nil + # rate, 4,500 x 10.75% = 483.75 at Step 5, less 252 = 231.75. + marriage_allowance: [1_260, 0] + marriage_allowance_tax_reduction: [252, 0] + income_tax: [231.75, 0] + +- name: Issue departure 3 - Scottish intermediate-rate recipient saves the Scottish basic rate + period: 2026 + absolute_error_margin: 0.01 + input: + people: + recipient: + age: 40 + employment_income: 30_000 + transferor: + age: 38 + employment_income: 5_000 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + households: + household: + members: [recipient, transferor] + region: SCOTLAND + output: + # Recipient: 17,430 taxable = 3,967 x 19% + 12,989 x 20% + 474 x 21% + # = 753.73 + 2,597.80 + 99.54 = 3,451.07, less 20% x 1,260 = 252, not + # the 21% marginal rate. + marriage_allowance_appropriate_percentage: [0.2, 0.2] + marriage_allowance_tax_reduction: [252, 0] + income_tax: [3_199.07, 0] + +- name: Scottish starter-rate transferor + period: 2026 + absolute_error_margin: 0.01 + input: + people: + recipient: + age: 40 + employment_income: 30_000 + transferor: + age: 38 + employment_income: 12_000 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + households: + household: + members: [recipient, transferor] + region: SCOTLAND + output: + # Transferor: 690 x 19% = 131.10, less than the 252 saved. + makes_marriage_allowance_election: [false, true] + income_tax: [3_199.07, 131.10] + +- name: Issue departure 5 - both partners below the allowance, no election and no double counting + period: 2026 + input: + people: + p1: + age: 40 + employment_income: 5_000 + p2: + age: 38 + employment_income: 6_000 + benunits: + benunit: + members: [p1, p2] + is_married: true + output: + # Neither has tax to reduce. + makes_marriage_allowance_election: [false, false] + marriage_allowance: [0, 0] + income_tax: [0, 0] + +- name: Both partners basic-rate taxpayers, no election + period: 2026 + input: + people: + p1: + age: 40 + employment_income: 30_000 + p2: + age: 38 + employment_income: 20_000 + benunits: + benunit: + members: [p1, p2] + is_married: true + output: + makes_marriage_allowance_election: [false, false] + income_tax: [3_486, 1_486] + +- name: Higher-rate recipient cannot gain (s. 55B(2)(b)) + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 60_000 + transferor: + age: 38 + employment_income: 5_000 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + # 37,700 x 20% + 9,730 x 40% = 11,432. + makes_marriage_allowance_election: [false, false] + marriage_allowance: [0, 0] + income_tax: [11_432, 0] + +- name: Issue departure 6 - no election when married couple's allowance is claimed (s. 55B(2)(d)) + period: 2026 + input: + people: + # Married couple's allowance needs a spouse born before 6 April 1935. + recipient: + age: 92 + employment_income: 30_000 + married_couples_allowance: 11_270 + transferor: + age: 90 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + # 3,486 less 10% x 11,270 = 1,127. + makes_marriage_allowance_election: [false, false] + marriage_allowance: [0, 0] + income_tax: [2_359, 0] + +- name: The couple's take-up follows the spouse who would gain + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 30_000 + would_claim_marriage_allowance: false + transferor: + age: 38 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + makes_marriage_allowance_election: [false, false] + income_tax: [3_486, 0] + +- name: The electing spouse's take-up value does not stop the election + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 30_000 + transferor: + age: 38 + would_claim_marriage_allowance: false + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + makes_marriage_allowance_election: [false, true] + income_tax: [3_234, 0] + +- name: The lower earner elects whichever spouse is older + period: 2026 + input: + people: + transferor: + age: 40 + employment_income: 8_000 + recipient: + age: 38 + employment_income: 30_000 + benunits: + benunit: + members: [transferor, recipient] + is_married: true + output: + makes_marriage_allowance_election: [true, false] + marriage_allowance: [0, 1_260] + personal_allowance: [11_310, 12_570] + income_tax: [0, 3_234] + +- name: Reduction capped at the recipient's Step 5 tax (s. 29(2)) + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 13_000 + transferor: + age: 38 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + # Step 5 tax (13,000 - 12,570) x 20% = 86, so the reduction is 86. + makes_marriage_allowance_election: [false, true] + marriage_allowance: [1_260, 0] + marriage_allowance_tax_reduction: [86, 0] + income_tax: [0, 0] + +- name: Other tax reductions leave less tax for the reduction (s. 27(2), s. 29(2)-(3)) + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 15_000 + other_tax_credits: 300 + transferor: + age: 38 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + # Step 5 tax 2,430 x 20% = 486, less 300 leaves 186 for the reduction. + marriage_allowance_tax_reduction_limit: [186, 0] + marriage_allowance_tax_reduction: [186, 0] + income_tax: [0, 0] + +- name: When either spouse's election saves the same, the elder elects + period: 2026 + input: + people: + p1: + age: 40 + savings_interest_income: 15_000 + dividend_income: 10_000 + p2: + age: 38 + savings_interest_income: 15_000 + dividend_income: 10_000 + benunits: + benunit: + members: [p1, p2] + is_married: true + output: + # The spouses are identical, so either election saves the same and the + # elder elects. Income tax is not asserted: the model sets allowances + # against savings before dividends, where s. 25(2) requires the order + # that most reduces tax. + makes_marriage_allowance_election: [true, false] + personal_allowance: [11_310, 12_570] + +- name: Children in the benefit unit are not spouses + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 30_000 + transferor: + age: 38 + child: + age: 5 + benunits: + benunit: + members: [recipient, transferor, child] + is_married: true + output: + is_marriage_allowance_spouse: [true, true, false] + makes_marriage_allowance_election: [false, true, false] + marriage_allowance: [1_260, 0, 0] + income_tax: [3_234, 0, 0] + +- name: s. 55B(2)(ba) - dividends in the nil rate above the basic rate limit bar the recipient + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 50_070 + dividend_income: 500 + transferor: + age: 38 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + # Taxable 37,500 + 500: without the dividend nil rate 300 of dividends + # would be at the dividend upper rate. + makes_marriage_allowance_election: [false, false] + income_tax: [7_500, 0] + +- name: Savings in the savings nil rate above the basic rate limit do not bar the recipient + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 50_070 + savings_interest_income: 500 + transferor: + age: 38 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + # The interest is at the savings nil rate, which s. 55B(2)(b) allows: + # 37,500 x 20% - 252 = 7,248. + makes_marriage_allowance_election: [false, true] + income_tax: [7_248, 0] + +- name: Scottish intermediate-rate recipient with savings banded on UK rates can gain + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 40_000 + savings_interest_income: 5_000 + transferor: + age: 38 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + households: + household: + members: [recipient, transferor] + region: SCOTLAND + output: + makes_marriage_allowance_election: [false, true] + marriage_allowance_tax_reduction: [252, 0] + +- name: Pensioner transferor with State Pension, as in the UC State Pension test + period: 2026 + input: + people: + pensioner: + age: 70 + state_pension: 12_000 + partner: + age: 45 + employment_income: 20_000 + benunits: + benunit: + members: [pensioner, partner] + is_married: true + output: + # Pensioner: 690 x 20% = 138. Partner: 7,430 x 20% - 252 = 1,234. + makes_marriage_allowance_election: [true, false] + personal_allowance: [11_310, 12_570] + income_tax: [138, 1_234] + +- name: 2015-16 transferable amount is 1,060 (s. 55B(4)(a)) + period: 2015 + input: + people: + recipient: + age: 40 + employment_income: 20_000 + transferor: + age: 38 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + # (20,000 - 10,600) x 20% = 1,880, less 20% x 1,060 = 212. + marriage_allowance_transferable_amount: [1_060, 1_060] + personal_allowance: [10_600, 9_540] + income_tax: [1_668, 0] + +- name: 2019-20 transferable amount is 10% of 12,500 + period: 2019 + input: + people: + recipient: + age: 40 + employment_income: 20_000 + transferor: + age: 38 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + # 7,500 x 20% = 1,500, less 20% x 1,250 = 250. + marriage_allowance_transferable_amount: [1_250, 1_250] + income_tax: [1_250, 0] # Marriage Allowance is an election between spouses or civil partners (ITA 2007 # s.55B, s.55C(1)(a)): the claimant and partner of a married benefit unit, never # a child in it, whatever the child's age. - name: An 18-year-old dependant's unused allowance does not transfer to a parent - # p1 is a basic-rate taxpayer with no unused allowance of their own spouse - # (p2 earns 60,000), so p1 gets no Marriage Allowance. Previously the - # 18-year-old's unused 12,570 counted as a "partner's" allowance and p1 got - # 1,260 (a 252 tax cut). + # p2 earns 60,000, so p2 cannot gain and cannot elect (higher rate even with + # the allowance cut, s. 55C(1)(c)); p1 pays basic rate, so electing would + # cost p1 as much as it could save. The 18-year-old is not a spouse. + # Before #1896 the 18-year-old's unused allowance counted as a "partner's" + # and p1 got 1,260 (a 252 tax cut). period: 2025 input: people: @@ -89,7 +631,7 @@ members: [p1, p2, student] is_married: true output: - partners_unused_personal_allowance: [0, 0, 0] + is_marriage_allowance_spouse: [true, true, false] marriage_allowance: [0, 0, 0] marital_status: [MARRIED, MARRIED, SINGLE] @@ -134,3 +676,138 @@ members: [parent_1, parent_2, student] output: marriage_allowance: [0, 0, 0] + +- name: A landlord whose finance-cost reduction clears their tax gains nothing, so the couple does not elect + # ITA 2007 s. 29(2)-(3): a reduction is deducted only from Step 5 tax left + # after the reductions before it. The finance-cost reduction (ITTOIA 2005 + # s. 274AA) is deducted first; s. 27(2) gives the same total either way. + period: 2026 + input: + people: + landlord: + age: 40 + property_rental_income: 20_000 + property_income: 20_000 + property_finance_costs: 15_000 + earner: + age: 38 + employment_income: 12_000 + benunits: + benunit: + members: [landlord, earner] + is_married: true + output: + # Landlord: Step 5 tax (20,000 - 12,570) x 20% = 1,486. Costs relieved + # min(15,000, 20,000, 7,430) = 7,430, so the reduction is 1,486 and no tax + # is left for a Marriage Allowance reduction. The earner electing would + # pay (12,000 - 11,310) x 20% = 138 and save the landlord nothing. + uses_property_allowance: [false, false] + property_finance_cost_relief: [1_486, 0] + marriage_allowance_tax_reduction_limit: [0, 0] + makes_marriage_allowance_election: [false, false] + marriage_allowance_tax_reduction: [0, 0] + income_tax: [0, 0] + +- name: A landlord's Marriage Allowance reduction is limited to the tax the finance-cost reduction leaves + period: 2026 + input: + people: + landlord: + age: 40 + property_rental_income: 20_000 + property_income: 20_000 + property_finance_costs: 6_700 + earner: + age: 38 + employment_income: 12_000 + benunits: + benunit: + members: [landlord, earner] + is_married: true + output: + # Landlord: Step 5 tax 1,486, finance-cost reduction 20% x 6,700 = 1,340, + # leaving 146. The earner electing pays (12,000 - 11,310) x 20% = 138 and + # saves the landlord 146, so the couple elects and pays 138 against 146. + property_finance_cost_relief: [1_340, 0] + marriage_allowance_tax_reduction_limit: [146, 138] + makes_marriage_allowance_election: [false, true] + marriage_allowance_tax_reduction: [146, 0] + income_tax: [0, 138] + +- name: Take-up follows the gaining spouse when the electing spouse is older + # The draw that decides the couple's take-up is the gaining spouse's, not + # the elder spouse's. + period: 2026 + input: + people: + recipient: + age: 38 + employment_income: 30_000 + would_claim_marriage_allowance: false + transferor: + age: 40 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + makes_marriage_allowance_election: [false, false] + income_tax: [3_486, 0] + +- name: An older electing spouse's take-up value does not stop the election + period: 2026 + input: + people: + recipient: + age: 38 + employment_income: 30_000 + transferor: + age: 40 + would_claim_marriage_allowance: false + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + makes_marriage_allowance_election: [false, true] + income_tax: [3_234, 0] + +- name: The election is weighed under the property allowance route the couple uses + # Each property allowance route is weighed with the election it would + # bring (ITTOIA 2005 s. 783BL); the election is then weighed under the route + # chosen. Under policyengine-core 3.33 the election's branches dropped the + # chosen route and picked their own, so the earner elected as if on full + # relief while taxed on actual expenses (CI, 11 Oct 2026). + period: 2027 + input: + people: + landlord: + age: 40 + employment_income: 20_000 + property_rental_income: 10_000 + property_income: 10_000 + property_finance_costs: 2_000 + earner: + age: 38 + employment_income: 12_000 + property_rental_income: 800 + property_income: 800 + property_finance_costs: 200 + benunits: + benunit: + members: [landlord, earner] + is_married: true + output: + # Earner, full relief: no tax, or 690 x 20% = 138 if electing, so the + # couple would elect. On actual expenses: employment uses 12,000 of the + # allowance, 230 of property at the 22% property basic rate = 50.60, less + # 22% x 200 of finance costs = 6.60; electing would raise that to + # 138 + 176 - 44 = 270, by 263.40, more than the 252 it saves, so no + # election. Actual expenses cost the earner less (6.60 against 138), so + # the earner uses them. + # Landlord: 7,430 x 20% + 10,000 x 22% - 22% x 2,000 = 3,246, against + # 3,466 with the property allowance. + uses_property_allowance: [false, false] + makes_marriage_allowance_election: [false, false] + property_finance_cost_relief: [440, 44] + income_tax: [3_246, 6.60] diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml index 064d9679fc..bc8c4b2cf2 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml @@ -1,41 +1,172 @@ -- name: Basic rate band is eligible for Marriage Allowance - period: 2021 +# ITA 2007 s. 55B(2)(b), (ba) and s. 55C(1)(c), (ca): only the basic rates, +# the Scottish starter and intermediate rates, the dividend ordinary rate and +# the nil and starting rates for savings. 2026-27: personal allowance 12,570, +# basic rate limit 37,700, Scottish higher rate from 31,092 of taxable income. + +- name: Basic-rate earner meets the conditions + period: 2026 input: - tax_band: BASIC + employment_income: 30_000 output: meets_marriage_allowance_income_conditions: true -- name: Higher rate band is ineligible for Marriage Allowance - period: 2021 +- name: No income meets the conditions + period: 2026 + input: + employment_income: 0 + output: + meets_marriage_allowance_income_conditions: true + +- name: Taxable income exactly at the basic rate limit meets the conditions + period: 2026 + input: + # 50,270 - 12,570 = 37,700, all at the basic rate. + employment_income: 50_270 + output: + meets_marriage_allowance_income_conditions: true + +- name: One pound into the higher rate fails the conditions + period: 2026 + input: + employment_income: 50_271 + output: + meets_marriage_allowance_income_conditions: false + +- name: Additional-rate earner fails the conditions + period: 2026 input: - tax_band: HIGHER + employment_income: 150_000 output: meets_marriage_allowance_income_conditions: false -- name: Additional rate band is ineligible for Marriage Allowance - period: 2021 +- name: Transferor test uses the cut allowance (s. 55C(1)(c)) + period: 2026 input: - tax_band: ADDITIONAL + # Allowance 12,570 - 1,260 = 11,310, so 50,000 leaves 38,690 taxable, + # 990 above the basic rate limit. + employment_income: 50_000 + marriage_allowance_relinquished: 1_260 output: + personal_allowance: 11_310 meets_marriage_allowance_income_conditions: false -- name: Below-PA earner (band NONE) is eligible for Marriage Allowance (regression for #395) - period: 2021 +- name: The same income meets the conditions with the full allowance + period: 2026 input: - tax_band: NONE + # 50,000 - 12,570 = 37,430, within the basic rate band. + employment_income: 50_000 output: meets_marriage_allowance_income_conditions: true -- name: Scottish starter rate band is eligible for Marriage Allowance - period: 2021 +- name: Scottish intermediate-rate earner meets the conditions + period: 2026 + input: + # 40,000 - 12,570 = 27,430, below the Scottish higher rate at 31,092. + employment_income: 40_000 + region: SCOTLAND + output: + meets_marriage_allowance_income_conditions: true + +- name: Scottish starter-rate earner meets the conditions + period: 2026 + input: + employment_income: 14_000 + region: SCOTLAND + output: + meets_marriage_allowance_income_conditions: true + +- name: Scottish higher-rate earner fails the conditions + period: 2026 + input: + # 44,000 - 12,570 = 31,430, above 31,092. + employment_income: 44_000 + region: SCOTLAND + output: + meets_marriage_allowance_income_conditions: false + +- name: Scottish savings are banded on UK rates, so an intermediate-rate earner with savings meets the conditions + period: 2026 input: - tax_band: STARTER + # Non-savings 27,430 is below the Scottish higher rate; savings stack on + # it to 32,430, below the UK basic rate limit of 37,700. + employment_income: 40_000 + savings_interest_income: 5_000 + region: SCOTLAND output: meets_marriage_allowance_income_conditions: true -- name: Scottish intermediate rate band is eligible for Marriage Allowance - period: 2021 +- name: Savings within the savings nil rate above the basic rate limit meet the conditions + period: 2026 input: - tax_band: INTERMEDIATE + # Non-savings 50,070 - 12,570 = 37,500. The 500 of interest is covered by + # the savings nil rate (s. 12A), which s. 55B(2)(b) allows, so none of it + # is charged at the savings higher rate. + employment_income: 50_070 + savings_interest_income: 500 output: meets_marriage_allowance_income_conditions: true + +- name: Dividends within the dividend nil rate above the basic rate limit fail the conditions + period: 2026 + input: + # The same 500 as dividends: s. 55B(2)(ba) ignores the dividend nil rate + # (s. 13A), and 37,500 + 500 = 38,000 would put 300 at the dividend upper + # rate. + employment_income: 50_070 + dividend_income: 500 + output: + meets_marriage_allowance_income_conditions: false + +- name: Savings charged at the savings higher rate fail the conditions + period: 2026 + input: + # Taxable income 37,500 + 2,000 = 39,500. A higher-rate taxpayer's + # savings nil rate covers 500, leaving savings at the higher rate. + employment_income: 50_070 + savings_interest_income: 2_000 + output: + meets_marriage_allowance_income_conditions: false + +- name: The dividend test uses the basic rate limit (2025-26, 37,700) + period: 2025 + input: + # Taxable income 49,970 - 12,570 = 37,400, plus 250 of dividends within + # the dividend allowance: 37,650 is inside the basic rate limit, so no + # dividends would be at the dividend upper rate even without s. 13A. + employment_income: 49_970 + dividend_income: 250 + output: + meets_marriage_allowance_income_conditions: true + +- name: Dividends above property income cross the basic rate limit and fail the conditions + period: 2026 + input: + # Property 47,000 less the 1,000 property allowance and the allowance of + # 12,570 leaves 33,430 of non-savings income. Dividends sit on top + # (s. 16), from 33,430 to 38,430, so 730 would be at the dividend upper + # rate (s. 55B(2)(ba)). + property_income: 47_000 + dividend_income: 5_000 + output: + meets_marriage_allowance_income_conditions: false + +- name: Dividends that reach the basic rate limit exactly meet the conditions (s. 55B(2)(ba)) + period: 2026 + input: + # 50,070 - 12,570 = 37,500 of earnings, then 200 of dividends to 37,700: + # even without the dividend nil rate (s. 13A) none would be at the + # dividend upper rate. Companion case B of TheAxiomFoundation/rulespec-uk#437. + employment_income: 50_070 + dividend_income: 200 + output: + meets_marriage_allowance_income_conditions: true + +- name: One pound of dividends above the basic rate limit fails the conditions (s. 55B(2)(ba)) + period: 2026 + input: + # 37,500 + 201 = 37,701: without s. 13A, 1 would be at the dividend upper + # rate. Companion case C of TheAxiomFoundation/rulespec-uk#437. + employment_income: 50_070 + dividend_income: 201 + output: + meets_marriage_allowance_income_conditions: false 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 7312c6d5bd..58c603f093 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 @@ -119,7 +119,7 @@ taxed_income: 47_430 personal_pension_contributions_tax: 4_000 -- name: Case 7, Marriage Allowance still comes off the band base (pinned; #1947). +- name: Case 7, Marriage Allowance leaves the band base alone (#1947). period: 2026 absolute_error_margin: 0.01 input: @@ -134,8 +134,9 @@ members: [person] country: ENGLAND output: - # earned_taxable_income subtracts Marriage Allowance, which the law gives - # as a tax reduction (ITA 2007 s. 55B), so the base is 30,000 - 12,570 - - # 1,260 = 16,170 rather than 17,430. Pinned here until #1947 is fixed. - non_savings_taxable_income: 16_170 + # Marriage Allowance received is a tax reduction at Step 6 (ITA 2007 + # s. 55A(2), s. 55B(1)), 20% x 1,260 = 252, not a cut to taxable income, + # so the base is 30,000 - 12,570 = 17,430. + non_savings_taxable_income: 17_430 non_savings_income_after_allowances: 17_430 + marriage_allowance_tax_reduction: 252 diff --git a/policyengine_uk/tests/policy/reforms/cps/marriage_tax_reforms.yaml b/policyengine_uk/tests/policy/reforms/cps/marriage_tax_reforms.yaml index c557150fb9..fb3f8692b4 100644 --- a/policyengine_uk/tests/policy/reforms/cps/marriage_tax_reforms.yaml +++ b/policyengine_uk/tests/policy/reforms/cps/marriage_tax_reforms.yaml @@ -51,9 +51,10 @@ household: members: [high_earner, low_earner] output: - # Low earner's unused PA (12,570 − 10,000 = 2,570) is capped at PA × ma_rate - # = 12,570 × 0.1 = 1,257, then rounded up to the nearest £10 → 1,260. - # Low earner's partner has no unused PA to transfer, so low_earner's MA is 0. + # The low earner elects: the transferable amount is PA x ma_rate = + # 12,570 x 0.1 = 1,257, rounded up to the nearest £10 → 1,260, and costs + # them nothing (10,000 is below 11,310). With the income condition lifted, + # the higher-rate earner gains it; the low earner receives nothing. marriage_allowance: [1260, 0] - name: Expanded MA — explicit Marriage Allowance take-up input drives the reform @@ -153,6 +154,32 @@ output: marriage_allowance: [0, 0] +- name: Expanded MA — rate-only expansion keeps the higher-rate bar + period: 2026 + absolute_error_margin: 1 + reforms: + - policyengine_uk.reforms.cps.marriage_tax_reforms.expanded_ma_reform_rate_only + input: + people: + high_earner: + age: 35 + employment_income: 60_000 + low_earner: + age: 33 + employment_income: 8_000 + benunits: + benunit: + members: [high_earner, low_earner] + is_married: true + households: + household: + members: [high_earner, low_earner] + output: + # Without remove_income_condition the higher-rate earner still cannot + # gain (ITA 2007 s. 55B(2)(b)): 37,700 x 20% + 9,730 x 40% = 11,432. + makes_marriage_allowance_election: [false, false] + marriage_allowance: [0, 0] + income_tax: [11_432, 0] - name: Marriage-neutral income tax — a dependant's income stays out of the couple's split # Only the spouses' incomes are pooled: (60,000 + 10,000) / 2 = 35,000 each, # which lowers their combined tax. The 19-year-old dependant at school keeps diff --git a/policyengine_uk/tests/test_marriage_allowance_election_branches.py b/policyengine_uk/tests/test_marriage_allowance_election_branches.py new file mode 100644 index 0000000000..26475cb2ac --- /dev/null +++ b/policyengine_uk/tests/test_marriage_allowance_election_branches.py @@ -0,0 +1,244 @@ +"""Engineering checks for the branches behind the Marriage Allowance election. + +makes_marriage_allowance_election weighs each couple's income tax in two +policyengine-core branches (ITA 2007 ss. 55B, 55C), and uses_property_allowance +weighs income tax in branches of its own (ITTOIA 2005 s. 783BL), so the two +nest whenever a spouse has property income with finance costs. For any +generated population of couples and singles: + +1. Determinism under query order: the election, the Marriage Allowance + reduction, personal allowances, income tax, the property allowance route, + the finance-cost reduction, Universal Credit and household net income are + bit-for-bit the same whichever of them is asked for first, with and without + a reform to the personal allowance dated to the year. +2. Baseline isolation: a reformed simulation's baseline gives bit-for-bit the + same election, reduction, allowances and income tax as a simulation with no + reform, whether the reform or the baseline is computed first, and the + reform's own results do not depend on that order either. +3. Branches are dropped: computing six years leaves no branch behind. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.utils.scenario import Scenario + +PROPERTY_SETTINGS = settings( + max_examples=3, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +YEARS = [2026, 2027] +ELECTION = "makes_marriage_allowance_election" +OUTPUTS = [ + ELECTION, + "marriage_allowance_tax_reduction", + "personal_allowance", + "income_tax", + "uses_property_allowance", + "property_finance_cost_relief", + "universal_credit", + "household_net_income", +] +# Each order asks for one output first; the rest follow in list order. +FIRST = [ + ELECTION, + "universal_credit", + "income_tax", + "uses_property_allowance", + "personal_allowance", +] +BASELINE_OUTPUTS = [ + ELECTION, + "marriage_allowance_transferable_amount", + "marriage_allowance_tax_reduction", + "personal_allowance", + "income_tax", +] +TENURES = ["RENT_FROM_COUNCIL", "RENT_PRIVATELY", "OWNED_OUTRIGHT"] +REGIONS = ["LONDON", "WALES", "SCOTLAND"] + + +def personal_allowance_reform(year): + # A 15,000 allowance for this tax year only (a bare year is the UK fiscal + # year), so the transferable amount is 1,500 (s. 55B(4)(b), (5)). + return Scenario( + parameter_changes={ + "gov.hmrc.income_tax.allowances.personal_allowance.amount": { + str(year): 15_000 + } + } + ) + + +def amounts(cap): + return st.one_of(st.just(0.0), st.floats(0, cap)) + + +@st.composite +def adults(draw): + adult = dict( + age=draw(st.integers(18, 80)), + employment_income=draw(amounts(50_000)), + savings_interest_income=draw(amounts(10_000)), + dividend_income=draw(amounts(5_000)), + property_income=draw(amounts(15_000)), + property_finance_costs=draw(amounts(10_000)), + ) + # Gross receipts are the profit plus expenses; with expenses under the + # 1,000 property allowance, and finance costs, the route is a choice + # weighed in branches (ITTOIA 2005 s. 783BH, s. 783BL). + adult["property_rental_income"] = adult["property_income"] + draw(amounts(1_500)) + if adult["age"] >= 67: + adult["state_pension"] = draw(amounts(15_000)) + return adult + + +@st.composite +def families(draw): + return dict( + adults=[draw(adults()) for _ in range(draw(st.sampled_from([1, 2, 2])))], + married=draw(st.sampled_from([True, True, False])), + children=[draw(st.integers(0, 15)) for _ in range(draw(st.integers(0, 2)))], + tenure=draw(st.sampled_from(TENURES)), + rent=draw(amounts(15_000)), + region=draw(st.sampled_from(REGIONS)), + ) + + +# Small populations: every adult may have property receipts and finance +# costs, so most simulations run both kinds of branch. +populations = st.lists(families(), min_size=1, max_size=3) + + +def situation(units, years): + """One simulation holding every family, with the same inputs each year.""" + years = [years] if isinstance(years, int) else years + + def each_year(value): + return {year: value for year in years} + + people, benunits, households = {}, {}, {} + for i, unit in enumerate(units): + names = [] + for j, adult in enumerate(unit["adults"]): + person = {variable: each_year(value) for variable, value in adult.items()} + person["is_claimant_or_partner"] = each_year(True) + people[f"p{i}_{j}"] = person + names.append(f"p{i}_{j}") + for k, age in enumerate(unit["children"]): + people[f"c{i}_{k}"] = { + "age": each_year(age), + "is_claimant_or_partner": each_year(False), + } + names.append(f"c{i}_{k}") + benunits[f"b{i}"] = { + "members": names, + "is_married": each_year(unit["married"] and len(unit["adults"]) == 2), + } + households[f"h{i}"] = { + "members": names, + "rent": each_year(unit["rent"]), + "tenure_type": each_year(unit["tenure"]), + "region": each_year(unit["region"]), + } + return {"people": people, "benunits": benunits, "households": households} + + +def calculate(simulation, year, variables, first=None): + order = [first] + [v for v in variables if v != first] if first else variables + for variable in order: + simulation.calculate(variable, year) + return {v: np.asarray(simulation.calculate(v, year)) for v in variables} + + +def assert_identical(a, b, message): + for variable in a: + assert a[variable].dtype == b[variable].dtype, (variable, message) + assert np.array_equal(a[variable], b[variable]), (variable, message) + + +@PROPERTY_SETTINGS +@given( + units=populations, + year=st.sampled_from(YEARS), + reformed=st.booleans(), +) +def test_results_do_not_depend_on_query_order(units, year, reformed): + scenario = personal_allowance_reform(year) if reformed else None + results = [ + calculate( + Simulation(situation=situation(units, year), scenario=scenario), + year, + OUTPUTS, + first=first, + ) + for first in FIRST + ] + for first, result in zip(FIRST[1:], results[1:]): + assert_identical(results[0], result, f"{first} first: {units}") + + +@PROPERTY_SETTINGS +@given(units=populations, year=st.sampled_from(YEARS)) +def test_reform_leaves_the_baseline_unchanged(units, year): + plain = calculate( + Simulation(situation=situation(units, year)), year, BASELINE_OUTPUTS + ) + # Reform first, then its baseline. + reformed = Simulation( + situation=situation(units, year), scenario=personal_allowance_reform(year) + ) + reform_first = calculate(reformed, year, BASELINE_OUTPUTS) + baseline_second = calculate(reformed.baseline, year, BASELINE_OUTPUTS) + # Baseline first, then the reform. + reformed = Simulation( + situation=situation(units, year), scenario=personal_allowance_reform(year) + ) + baseline_first = calculate(reformed.baseline, year, BASELINE_OUTPUTS) + reform_second = calculate(reformed, year, BASELINE_OUTPUTS) + assert_identical(plain, baseline_second, f"baseline after reform: {units}") + assert_identical(plain, baseline_first, f"baseline before reform: {units}") + assert_identical(reform_first, reform_second, f"reform: {units}") + # The reform reached the simulation: the transferable amount is 10% of + # 15,000 (s. 55B(4)(b)), and 1,260 in the baseline. + assert np.all(reform_first["marriage_allowance_transferable_amount"] == 1_500) + assert np.all(plain["marriage_allowance_transferable_amount"] == 1_260) + + +def test_no_branch_is_left_after_any_year(): + # A couple who elect every year: 30,000 and 9,000 of earnings, so electing + # costs the lower earner nothing and saves the other 252 (s. 55B). The + # lower earner's property receipts are within the property allowance and + # carry finance costs, so the property route is weighed in branches too. + units = [ + dict( + adults=[ + dict(age=40, employment_income=30_000.0), + dict( + age=38, + employment_income=9_000.0, + property_rental_income=800.0, + property_income=800.0, + property_finance_costs=200.0, + ), + ], + married=True, + children=[4], + tenure="RENT_PRIVATELY", + rent=9_000.0, + region="LONDON", + ) + ] + years = list(range(2025, 2031)) + simulation = Simulation(situation=situation(units, years)) + before = set(simulation.branches) + elected = [] + for year in years: + simulation.calculate("household_net_income", year) + elected.append(bool(simulation.calculate(ELECTION, year).any())) + assert set(simulation.branches) == before, (year, list(simulation.branches)) + assert all(elected), elected diff --git a/policyengine_uk/tests/test_marriage_allowance_properties.py b/policyengine_uk/tests/test_marriage_allowance_properties.py new file mode 100644 index 0000000000..a70f683211 --- /dev/null +++ b/policyengine_uk/tests/test_marriage_allowance_properties.py @@ -0,0 +1,385 @@ +"""Property-based tests for the Marriage Allowance (ITA 2007 ss. 55A-55E). + +An election by one spouse or civil partner cuts their own personal allowance +by the transferable amount (s. 55B(6)) and gives the other a Step 6 tax +reduction of the appropriate percentage of it, capped at their Step 5 tax +(s. 55A(2), s. 55B(1), (3), s. 29(2)). The couple elects only when the +conditions allow it and it lowers their combined income tax. + +Invariants, for any generated population of couples with every kind of +taxable income, some with residential finance costs, in England, Wales or +Scotland: + +1. The couple's income tax is never higher than with no election. +2. Differential against brute force: the model's election gives the lowest + couple income tax of no election, the first spouse electing and the second + spouse electing, among those the conditions allow, each run with the + election fixed. +3. Once a spouse has elected, the gaining partner's reduction, allowance and + income tax do not depend on the electing spouse's income, and the electing + spouse's allowance falls by exactly the transferable amount. +4. Accounting: in each couple what one spouse gives up the other receives, at + most one spouse elects, the gaining partner's tax falls by exactly the + reduction, and the reduction is at most the appropriate percentage of the + transferable amount and the tax left to reduce. +5. The couple's income tax, with the election the model makes, never falls + when either spouse has more income. +6. No one outside a married couple gives up or receives anything. +""" + +import numpy as np +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +PROPERTY_SETTINGS = settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +# 2019 has a 1,250 transferable amount, 2026 current rates and the 10.75% +# dividend ordinary rate, 2027 the property and savings rates (Finance Act +# 2026). +YEARS = [2019, 2026, 2027] +REGIONS = ["LONDON", "NORTH_EAST", "WALES", "SCOTLAND"] +INCOMES = { + "employment_income": 60_000, + "self_employment_income": 20_000, + "private_pension_income": 20_000, + "state_pension": 15_000, + "savings_interest_income": 20_000, + "dividend_income": 15_000, + "property_income": 10_000, +} +# Residential finance costs give the landlord a Step 6 reduction of their own +# (ITTOIA 2005 s. 274AA), deducted before the Marriage Allowance reduction. +# They also make the property allowance a choice that income tax weighs in +# branches of its own, which is slow, so about a quarter of the generated +# populations have them, and LANDLORD_COUPLE is always tried. +FINANCE_COSTS = 10_000 +# A landlord whose finance-cost reduction clears their tax, married to a +# low earner who would pay 138 to elect: electing saves the landlord nothing. +LANDLORD_COUPLE = [ + dict( + adults=[ + dict(age=40, property_income=20_000.0, property_finance_costs=15_000.0), + dict(age=38, employment_income=12_000.0), + ], + married=True, + children=[], + region="LONDON", + married_couples_allowance=0.0, + ) +] +ELECTION = "makes_marriage_allowance_election" +VARIABLES = [ + ELECTION, + "is_marriage_allowance_spouse", + "marriage_allowance_transferable_amount", + "marriage_allowance_relinquished", + "marriage_allowance", + "marriage_allowance_appropriate_percentage", + "marriage_allowance_tax_reduction", + "marriage_allowance_tax_reduction_limit", + "meets_marriage_allowance_income_conditions", + "personal_allowance", + "income_tax", +] +TOLERANCE = 0.01 + + +def amounts(cap): + # Many incomes are zero, so the allowances and bands are often unused. + return st.one_of(st.just(0.0), st.floats(0, cap)) + + +@st.composite +def adults(draw, finance_costs=False): + age = draw(st.integers(18, 100)) + adult = dict(age=age) + for variable, cap in INCOMES.items(): + if variable == "state_pension" and age < 67: + continue + adult[variable] = draw(amounts(cap)) + if finance_costs: + adult["property_finance_costs"] = draw(amounts(FINANCE_COSTS)) + return adult + + +@st.composite +def families(draw, finance_costs=False): + return dict( + adults=[ + draw(adults(finance_costs)) + for _ in range(draw(st.sampled_from([1, 2, 2, 2]))) + ], + married=draw(st.sampled_from([True, True, True, False])), + children=[draw(st.integers(0, 15)) for _ in range(draw(st.integers(0, 2)))], + region=draw(st.sampled_from(REGIONS)), + # s. 55B(2)(d): now and then the first adult claims married couple's + # allowance. + married_couples_allowance=draw(st.sampled_from([0.0] * 9 + [11_270.0])), + ) + + +def situation(units, year, election=None, bump=None): + """One simulation holding every family. + + ``election``, one value per person in order, fixes who elects. + ``bump`` is (family index, adult index, variable, amount) to add. + """ + people, benunits, households = {}, {}, {} + for i, unit in enumerate(units): + names = [] + for j, adult in enumerate(unit["adults"]): + person = {"state_pension": {year: 0.0}} + for variable, value in adult.items(): + person[variable] = {year: value} + if j == 0: + person["married_couples_allowance"] = { + year: unit["married_couples_allowance"] + } + if bump is not None and bump[:2] == (i, j): + person[bump[2]] = {year: adult.get(bump[2], 0.0) + bump[3]} + people[f"p{i}_{j}"] = person + names.append(f"p{i}_{j}") + for k, age in enumerate(unit["children"]): + people[f"c{i}_{k}"] = {"age": {year: age}} + names.append(f"c{i}_{k}") + benunits[f"b{i}"] = { + "members": names, + "is_married": {year: unit["married"] and len(unit["adults"]) == 2}, + } + households[f"h{i}"] = {"members": names, "region": {year: unit["region"]}} + if election is not None: + for person, elects in zip(people.values(), election): + person[ELECTION] = {year: bool(elects)} + return {"people": people, "benunits": benunits, "households": households} + + +def calculate(units, year, **kwargs): + sim = Simulation(situation=situation(units, year, **kwargs)) + values = {v: np.asarray(sim.calculate(v, year)) for v in VARIABLES} + values["benunit"] = np.asarray(sim.populations["benunit"].members_entity_id) + return values + + +def per_person(units, adult_value, child_value=0.0): + """One value per person in input order: adults, then children.""" + return np.array( + [ + value + for unit in units + for value in [adult_value(unit, j) for j in range(len(unit["adults"]))] + + [child_value] * len(unit["children"]) + ] + ) + + +def couple_total(values, variable): + """Each person's couple total of ``variable``.""" + total = np.bincount(values["benunit"], weights=values[variable]) + return total[values["benunit"]] + + +def spouses_by_order(values): + """Masks for the first and second spouse of each couple, in input order.""" + spouse = values["is_marriage_allowance_spouse"] + first = np.zeros_like(spouse) + second = np.zeros_like(spouse) + seen = set() + for i, (is_spouse, unit) in enumerate(zip(spouse, values["benunit"])): + if not is_spouse: + continue + if unit in seen: + second[i] = True + else: + first[i] = True + seen.add(unit) + return first, second + + +@st.composite +def populations(draw): + finance_costs = draw(st.sampled_from([False, False, False, True])) + return draw(st.lists(families(finance_costs), min_size=1, max_size=8)) + + +couple_populations = populations() + + +@PROPERTY_SETTINGS +@given(units=couple_populations, year=st.sampled_from(YEARS)) +@example(units=LANDLORD_COUPLE, year=2026) +def test_election_never_raises_the_couples_tax(units, year): + model = calculate(units, year) + none = calculate(units, year, election=np.zeros_like(model[ELECTION])) + model_tax = couple_total(model, "income_tax") + none_tax = couple_total(none, "income_tax") + assert np.all(model_tax <= none_tax + TOLERANCE), units + # A couple that elects saves at least a penny. + elects = couple_total(model, ELECTION) > 0 + assert np.all(model_tax[elects] <= none_tax[elects] - TOLERANCE + 1e-6), units + + +@PROPERTY_SETTINGS +@given(units=couple_populations, year=st.sampled_from(YEARS)) +@example(units=LANDLORD_COUPLE, year=2026) +def test_election_is_the_best_allowed_choice(units, year): + model = calculate(units, year) + none = calculate(units, year, election=np.zeros_like(model[ELECTION])) + first, second = spouses_by_order(model) + options = [none] + allowed = [np.ones_like(first)] + claims_mca = per_person( + units, lambda unit, j: float(j == 0 and unit["married_couples_allowance"] > 0) + ) + no_mca = couple_total(dict(benunit=model["benunit"], mca=claims_mca), "mca") == 0 + for elector in [first, second]: + run = calculate(units, year, election=elector) + gainer = model["is_marriage_allowance_spouse"] & ~elector + # s. 55C(1)(b), (c): the elector has an allowance and, with it cut, + # pays only basic rates; s. 55B(2)(b): so does the gaining partner; + # s. 55B(2)(d): no married couple's allowance. + conditions = ( + elector + & (none["personal_allowance"] > 0) + & run["meets_marriage_allowance_income_conditions"] + ) | (gainer & none["meets_marriage_allowance_income_conditions"]) + couple_ok = ( + couple_total(dict(benunit=model["benunit"], ok=conditions), "ok") == 2 + ) & no_mca + options.append(run) + allowed.append(couple_ok) + best = couple_total(none, "income_tax") + for run, ok in zip(options[1:], allowed[1:]): + best = np.where(ok, np.minimum(best, couple_total(run, "income_tax")), best) + np.testing.assert_allclose( + couple_total(model, "income_tax"), best, atol=TOLERANCE, err_msg=str(units) + ) + + +@st.composite +def bumped(draw): + units = draw(populations()) + i = draw(st.integers(0, len(units) - 1)) + j = draw(st.integers(0, len(units[i]["adults"]) - 1)) + variables = [ + v for v in INCOMES if v != "state_pension" or units[i]["adults"][j]["age"] >= 67 + ] + return units, (i, j, draw(st.sampled_from(variables)), draw(st.floats(1, 30_000))) + + +@PROPERTY_SETTINGS +@given(case=bumped(), year=st.sampled_from(YEARS)) +def test_gaining_partner_is_independent_of_the_electors_income(case, year): + units, (i, j, variable, amount) = case + base = calculate(units, year) + # The bumped adult elects wherever they are a spouse. + person_index = ( + sum(len(unit["adults"]) + len(unit["children"]) for unit in units[:i]) + j + ) + election = np.zeros_like(base[ELECTION]) + election[person_index] = base["is_marriage_allowance_spouse"][person_index] + low = calculate(units, year, election=election) + high = calculate(units, year, election=election, bump=(i, j, variable, amount)) + others = np.arange(len(election)) != person_index + for variable_name in [ + "marriage_allowance", + "marriage_allowance_tax_reduction", + "personal_allowance", + "income_tax", + ]: + np.testing.assert_allclose( + high[variable_name][others], + low[variable_name][others], + atol=TOLERANCE, + err_msg=f"{variable_name} {case}", + ) + # s. 55B(6): the elector's allowance falls by the transferable amount. + none = calculate(units, year, election=np.zeros_like(election)) + for run in [low]: + np.testing.assert_allclose( + run["personal_allowance"], + np.maximum( + 0, + none["personal_allowance"] + - election * run["marriage_allowance_transferable_amount"], + ), + atol=TOLERANCE, + err_msg=str(case), + ) + + +@PROPERTY_SETTINGS +@given(units=couple_populations, year=st.sampled_from(YEARS)) +def test_marriage_allowance_accounting(units, year): + model = calculate(units, year) + none = calculate(units, year, election=np.zeros_like(model[ELECTION])) + spouse = model["is_marriage_allowance_spouse"] + elects = model[ELECTION] + # Only spouses elect, at most one per couple. + assert not np.any(elects & ~spouse), units + assert np.all(couple_total(model, ELECTION) <= 1), units + # What one spouse gives up the other receives. + np.testing.assert_allclose( + couple_total(model, "marriage_allowance"), + couple_total(model, "marriage_allowance_relinquished"), + atol=TOLERANCE, + ) + transferable = model["marriage_allowance_transferable_amount"] + received = model["marriage_allowance"] + assert np.all(np.isclose(received, 0) | np.isclose(received, transferable)), units + # The reduction is bounded by s. 55B(1), (3) and s. 29(2). + reduction = model["marriage_allowance_tax_reduction"] + rate = model["marriage_allowance_appropriate_percentage"] + assert np.all(reduction <= rate * received + TOLERANCE), units + assert np.all( + reduction <= model["marriage_allowance_tax_reduction_limit"] + TOLERANCE + ), units + # The gaining partner's tax falls by exactly the reduction. + gainer = received > 0 + np.testing.assert_allclose( + model["income_tax"][gainer], + none["income_tax"][gainer] - reduction[gainer], + atol=TOLERANCE, + err_msg=str(units), + ) + # The elector pays at least as much as without the election. + assert np.all( + model["income_tax"][elects] >= none["income_tax"][elects] - TOLERANCE + ), units + + +@PROPERTY_SETTINGS +@given(case=bumped(), year=st.sampled_from(YEARS)) +def test_couples_tax_never_falls_with_more_income(case, year): + units, bump = case + low = calculate(units, year) + high = calculate(units, year, bump=bump) + assert np.all( + couple_total(high, "income_tax") >= couple_total(low, "income_tax") - TOLERANCE + ), (bump, units) + + +@PROPERTY_SETTINGS +@given(units=couple_populations, year=st.sampled_from(YEARS)) +def test_only_married_couples_transfer(units, year): + model = calculate(units, year) + outside = ~model["is_marriage_allowance_spouse"] + for variable in [ + ELECTION, + "marriage_allowance", + "marriage_allowance_relinquished", + "marriage_allowance_tax_reduction", + ]: + assert np.all(model[variable][outside] == 0), (variable, units) + married_pairs = { + i + for i, unit in enumerate(units) + if unit["married"] and len(unit["adults"]) == 2 + } + spouse_units = set(model["benunit"][model["is_marriage_allowance_spouse"]]) + assert spouse_units == married_pairs, units diff --git a/policyengine_uk/tests/test_uc_earnings_deductions_properties.py b/policyengine_uk/tests/test_uc_earnings_deductions_properties.py index bc961fe791..a8adaeca9b 100644 --- a/policyengine_uk/tests/test_uc_earnings_deductions_properties.py +++ b/policyengine_uk/tests/test_uc_earnings_deductions_properties.py @@ -26,14 +26,17 @@ Invariants 1-3 hold only while unearned income leaves the person's allowances alone, so the generated incomes keep adjusted net income below -the personal allowance taper (100,000) and no one claims Marriage Allowance -(see test_uc_state_pension_properties.py for the Marriage Allowance -deviation). Tax reductions are covered: adults born before 6 April 1935 can -have a married couple's allowance, which comes off the tax on earnings -first. Invariant 4 runs with Marriage Allowance claimed, and with everyone in -a start-up period: the minimum income floor now compares net earned income +the personal allowance taper (100,000). Tax reductions are covered: adults +born before 6 April 1935 can have a married couple's allowance, and couples +claim Marriage Allowance; both come off the tax on earnings first. Each +couple's Marriage Allowance election is held at its choice in the first run: +the tax on the gaining partner's earnings then does not depend on anyone's +unearned income, but the couple's choice to elect responds to it (see +test_uc_state_pension_properties.py). Invariant 4 runs with everyone in a +start-up period: the minimum income floor now compares net earned income with a net threshold (test_uc_minimum_income_floor_properties.py), which -moves earned income either way, so it is held off here. +moves earned income either way, so it is held off here. Its two runs elect +alike, since the formula it compares against changes no one's tax. """ import numpy as np @@ -92,7 +95,9 @@ "universal_credit_pre_benefit_cap", "uc_earned_income", ] +ELECTION = "makes_marriage_allowance_election" PERSON_VARIABLES = [ + ELECTION, "uc_individual_earned_income", "uc_income_tax_on_earnings", "uc_national_insurance_on_earnings", @@ -137,11 +142,12 @@ def families(draw): ) -def situation(units, year, bump=None, earnings_only=False, marriage_allowance=False): +def situation(units, year, bump=None, earnings_only=False, election=None): """One simulation holding every family. ``bump`` is (family index, adult index, variable, amount) to add. - ``earnings_only`` drops every kind of unearned income. + ``earnings_only`` drops every kind of unearned income. ``election``, one + value per person in order, fixes who makes a Marriage Allowance election. """ people, benunits, households = {}, {}, {} for i, unit in enumerate(units): @@ -166,21 +172,27 @@ def situation(units, year, bump=None, earnings_only=False, marriage_allowance=Fa if bump is not None and bump[:2] == (i, j) and not earnings_only: variable, amount = bump[2], bump[3] person[variable] = {year: adult.get(variable, 0.0) + amount} - if not marriage_allowance: - person["would_claim_marriage_allowance"] = {year: False} people[name] = person names.append(name) for k, age in enumerate(unit["children"]): name = f"c{i}_{k}" people[name] = {"age": {year: age}, "is_claimant_or_partner": {year: False}} names.append(name) - benunits[f"b{i}"] = {"members": names} + benunits[f"b{i}"] = { + "members": names, + # Marriage Allowance needs a marriage or civil partnership + # (ITA 2007 s. 55C(1)(a)). + "is_married": {year: len(unit["adults"]) == 2}, + } households[f"h{i}"] = { "members": names, "rent": {year: unit["rent"]}, "tenure_type": {year: unit["tenure"]}, "region": {year: unit["region"]}, } + if election is not None: + for person, elects in zip(people.values(), election): + person[ELECTION] = {year: bool(elects)} return {"people": people, "benunits": benunits, "households": households} @@ -233,7 +245,7 @@ def bumped(draw): def test_unearned_income_never_changes_earned_income(case, year): units, bump = case low = calculate(units, year) - high = calculate(units, year, bump=bump) + high = calculate(units, year, bump=bump, election=low[ELECTION]) for variable in ["uc_individual_earned_income", "uc_earned_income"]: np.testing.assert_allclose( high[variable], low[variable], atol=0.01, err_msg=f"{bump} {units}" @@ -245,7 +257,7 @@ def test_unearned_income_never_changes_earned_income(case, year): def test_uc_is_non_increasing_in_unearned_income(case, year): units, bump = case low = calculate(units, year) - high = calculate(units, year, bump=bump) + high = calculate(units, year, bump=bump, election=low[ELECTION]) for variable in ["universal_credit", "universal_credit_pre_benefit_cap"]: assert np.all(high[variable] <= low[variable] + 0.01), (variable, bump, units) @@ -257,7 +269,7 @@ def test_uc_is_non_increasing_in_unearned_income(case, year): ) def test_deductions_equal_tax_and_ni_on_earnings_alone(units, year): full = calculate(units, year) - alone = calculate(units, year, earnings_only=True) + alone = calculate(units, year, earnings_only=True, election=full[ELECTION]) adult = full["is_adult_person"] np.testing.assert_allclose( full["uc_income_tax_on_earnings"][adult], @@ -318,8 +330,8 @@ def test_fix_only_removes_deductions(units, year): ) for unit in units ] - after = calculate(units, year, marriage_allowance=True) - before = calculate(units, year, scenario=BEFORE_1942, marriage_allowance=True) + after = calculate(units, year) + before = calculate(units, year, scenario=BEFORE_1942) assert np.all(after["uc_earned_income"] >= before["uc_earned_income"] - 0.01), units for variable in ["universal_credit", "universal_credit_pre_benefit_cap"]: assert np.all(after[variable] <= before[variable] + 0.01), (variable, units) diff --git a/policyengine_uk/tests/test_uc_income_from_capital_properties.py b/policyengine_uk/tests/test_uc_income_from_capital_properties.py index 4a07d757fd..69b4227414 100644 --- a/policyengine_uk/tests/test_uc_income_from_capital_properties.py +++ b/policyengine_uk/tests/test_uc_income_from_capital_properties.py @@ -24,14 +24,14 @@ the award, before or after the benefit cap, and never lowers tariff income. -Invariant 2's award clause runs with Marriage Allowance switched off. The -model books it on the recipient as the transferor's unused personal allowance -(PolicyEngine/policyengine-uk#1947), so capital income that uses up one -partner's allowance raises the other partner's tax on earnings. The clause -also compares only families whose personal allowances the income leaves -unchanged: above 100,000 of adjusted net income the allowance tapers (ITA 2007 -s. 35), which raises the tax a person pays on their earnings and so changes -the deduction from them (see test_uc_earnings_deductions_properties.py). +Invariant 2's award clause holds each couple's Marriage Allowance election +at its choice in the base run: capital income can change whether an election +is allowed or lowers the couple's tax, and so the gaining partner's tax on +earnings. The clause also compares only families whose personal +allowances the income leaves unchanged: above 100,000 of adjusted net income +the allowance tapers (ITA 2007 s. 35), which raises the tax a person pays on +their earnings and so changes the deduction from them (see +test_uc_earnings_deductions_properties.py). The award after the benefit cap is compared only where the cap exemption is also unchanged: the model's earnings exception to the cap still nets tax on all of a person's income off their earnings @@ -89,6 +89,7 @@ "is_uc_eligible", "is_benefit_cap_exempt", ] +ELECTION = "makes_marriage_allowance_election" @st.composite @@ -112,15 +113,13 @@ def families(draw): ) -def situation( - units, year, income_scale=1.0, capital_bump=None, marriage_allowance=True -): +def situation(units, year, income_scale=1.0, capital_bump=None, election=None): """Build one simulation holding every family. ``income_scale`` multiplies each family's interest, dividends and rent; ``capital_bump`` is an optional (source, amount) added to every family's - capital. With ``marriage_allowance=False`` no one claims Marriage - Allowance. + capital. ``election``, one value per person in order, fixes who makes a + Marriage Allowance election. """ people, benunits, households = {}, {}, {} for i, unit in enumerate(units): @@ -131,8 +130,6 @@ def situation( # claimant-or-partner presumption (a member under 20 and much # younger is the head's child) does not apply. Children get False. person = {"age": {year: age}, "is_claimant_or_partner": {year: True}} - if not marriage_allowance: - person["would_claim_marriage_allowance"] = {year: False} if j == 0: person["employment_income"] = {year: unit["earnings"]} person["private_pension_income"] = { @@ -164,6 +161,9 @@ def situation( "main_residence_value": {year: unit["main_residence_value"]}, **{source: {year: value} for source, value in stocks.items()}, } + if election is not None: + for person, elects in zip(people.values(), election): + person[ELECTION] = {year: bool(elects)} return {"people": people, "benunits": benunits, "households": households} @@ -177,6 +177,7 @@ def listed_sources(sim, year): def calculate(units, year, **kwargs): sim = Simulation(situation=situation(units, year, **kwargs)) values = {v: np.asarray(sim.calculate(v, year)) for v in UC_VARIABLES} + values[ELECTION] = np.asarray(sim.calculate(ELECTION, year)) values["personal_allowances"] = np.asarray( sim.calculate("personal_allowance", year, map_to="benunit") ) @@ -246,8 +247,8 @@ def test_interest_dividends_and_rent_do_not_enter_the_means_test(units, scale, y year=st.sampled_from(YEARS), ) def test_interest_dividends_and_rent_leave_the_award_unchanged(units, scale, year): - base = calculate(units, year, marriage_allowance=False) - scaled = calculate(units, year, income_scale=scale, marriage_allowance=False) + base = calculate(units, year) + scaled = calculate(units, year, income_scale=scale, election=base[ELECTION]) # Only one adult receives the capital income, so the unit's summed # personal allowances are unchanged exactly when that adult's is. unchanged = np.isclose( @@ -355,8 +356,8 @@ def test_tax_on_dividends_does_not_remove_the_benefit_cap_earnings_exception(): }, recipient=0, ) - with_dividends = calculate([unit], 2026, marriage_allowance=False) - without = calculate([unit], 2026, income_scale=0.0, marriage_allowance=False) + with_dividends = calculate([unit], 2026) + without = calculate([unit], 2026, income_scale=0.0) assert with_dividends["uc_earned_income"][0] == pytest.approx( without["uc_earned_income"][0], abs=0.01 ) diff --git a/policyengine_uk/tests/test_uc_state_pension_properties.py b/policyengine_uk/tests/test_uc_state_pension_properties.py index 0afad1e6ff..80212b11f0 100644 --- a/policyengine_uk/tests/test_uc_state_pension_properties.py +++ b/policyengine_uk/tests/test_uc_state_pension_properties.py @@ -25,12 +25,15 @@ the pre-cap award by min(x, award), whatever the partner earns. Any tariff income from capital remains the same in all three cases. -Marriage Allowance is switched off for invariants 2, 3 and 4. The model gives -the recipient min(partner's unused personal allowance, 10% of the personal -allowance) instead of the fixed transferable amount in ITA 2007 s. 55B(4)-(6), -so State Pension or property income that uses up the pensioner's unused -allowance raises the earning partner's tax on earnings. The strict xfail at -the end pins that deviation (PolicyEngine/policyengine-uk#1947). +Marriage Allowance is claimed throughout. Once a couple has elected, the +gaining partner's reduction is fixed (ITA 2007 s. 55B(1), (4)-(6)), so the +pensioner's income cannot move the earning partner's tax on earnings. The +couple's election itself responds to the pensioner's income: the couple +stops electing once it no longer lowers their tax (from 12,570 of State +Pension in 2026-27), which raises the earner's tax and so the award. So the +invariants that compare runs hold each couple's election at its choice in +the first run; test_couple_stops_electing_when_it_no_longer_saves_tax pins +the change in election (PolicyEngine/policyengine-uk#1947). """ import numpy as np @@ -73,6 +76,7 @@ "uc_income_reduction", "uc_maximum_amount", ] +ELECTION = "makes_marriage_allowance_election" @st.composite @@ -98,7 +102,7 @@ def situation( year, income_variable="state_pension", pension_bump=0.0, - marriage_allowance=True, + election=None, ): """Build one simulation holding every family. @@ -106,8 +110,8 @@ def situation( ``pension_bump``) under ``income_variable``; when that is not ``state_pension``, their State Pension is set to zero so the same amount arrives as the other income instead. A working-age partner receives the - family's earnings. With ``marriage_allowance=False`` no one claims - Marriage Allowance. + family's earnings. ``election``, one value per person in order, fixes + who makes a Marriage Allowance election. """ people, benunits, households = {}, {}, {} for i, unit in enumerate(units): @@ -123,8 +127,6 @@ def situation( "state_pension": {year: 0.0}, "is_claimant_or_partner": {year: True}, } - if not marriage_allowance: - person["would_claim_marriage_allowance"] = {year: False} if j == 0: amount = unit["state_pension"] + pension_bump person[income_variable] = {year: amount} @@ -136,19 +138,27 @@ def situation( name = f"c{i}_{k}" people[name] = {"age": {year: age}, "is_claimant_or_partner": {year: False}} names.append(name) - benunits[f"b{i}"] = {"members": names} + benunits[f"b{i}"] = { + "members": names, + # Marriage Allowance needs a marriage or civil partnership + # (ITA 2007 s. 55C(1)(a)). + "is_married": {year: len(unit["ages"]) == 2}, + } households[f"h{i}"] = { "members": names, "rent": {year: unit["rent"]}, "tenure_type": {year: unit["tenure"]}, "savings": {year: unit["savings"]}, } + if election is not None: + for person, elects in zip(people.values(), election): + person[ELECTION] = {year: bool(elects)} return {"people": people, "benunits": benunits, "households": households} def calculate(units, year, **kwargs): sim = Simulation(situation=situation(units, year, **kwargs)) - return {v: np.asarray(sim.calculate(v, year)) for v in UC_VARIABLES} + return {v: np.asarray(sim.calculate(v, year)) for v in UC_VARIABLES + [ELECTION]} def assert_same(a, b, message): @@ -166,7 +176,7 @@ def assert_same(a, b, message): ) def test_uc_is_non_increasing_in_state_pension(units, bump, year): low = calculate(units, year) - high = calculate(units, year, pension_bump=bump) + high = calculate(units, year, pension_bump=bump, election=low[ELECTION]) for variable in ["universal_credit", "universal_credit_pre_benefit_cap"]: assert np.all(high[variable] <= low[variable] + 0.01), (variable, units) # State Pension changes nothing in the maximum amount, and all of it is @@ -212,9 +222,10 @@ def test_uc_is_non_increasing_in_state_pension(units, bump, year): ) def test_uc_falls_pound_for_pound_in_state_pension(units, bump, year): # Earnings in the family change nothing: tax on State Pension is never - # deducted from anyone's earnings (reg. 55(5)(b), reg. 57(2) step 3). - low = calculate(units, year, marriage_allowance=False) - high = calculate(units, year, pension_bump=bump, marriage_allowance=False) + # deducted from anyone's earnings (reg. 55(5)(b), reg. 57(2) step 3), and + # an election already made fixes the earner's Marriage Allowance. + low = calculate(units, year) + high = calculate(units, year, pension_bump=bump, election=low[ELECTION]) award = low["universal_credit_pre_benefit_cap"] np.testing.assert_allclose( high["universal_credit_pre_benefit_cap"], @@ -258,9 +269,11 @@ def test_state_pension_counts_like_private_pension(units, year): ) def test_property_income_is_excluded_while_state_pension_counts(units, year): # Neither income's tax comes off the partner's earnings (reg. 55(5)(b)), - # so this holds with earnings in the family too. - kwargs = dict(marriage_allowance=False) - pension = calculate(units, year, **kwargs) + # so this holds with earnings in the family too. The pensioner's income + # can change whether the couple elects, so hold the election at the + # State Pension run's choice. + pension = calculate(units, year) + kwargs = dict(election=pension[ELECTION]) property_income = calculate( units, year, income_variable="property_income", **kwargs ) @@ -286,8 +299,9 @@ def test_property_income_is_excluded_while_state_pension_counts(units, year): def test_tax_on_state_pension_does_not_reduce_partners_earned_income(): # 2026: pensioner aged 70 with State Pension 16,000 pays income tax of # (16,000 - 12,570) x 20% = 686. The partner aged 45 earns 13,000 and pays - # income tax of 86 and NI of 34.40 (above the personal allowance, so no - # marriage allowance). Council rent 20,000. + # income tax of 86 and NI of 34.40. Either spouse electing for Marriage + # Allowance would cost them 252 and save the other 252, so the couple + # does not elect. Council rent 20,000. # Earned income = 13,000 - 86 - 34.40 = 12,879.60 (reg. 55(5)(b)). # Maximum amount = 12 x 666.97 + 20,000 = 28,003.64. # UC = 28,003.64 - (0.55 x 12,879.60 + 16,000) = 4,919.86. @@ -305,14 +319,6 @@ def test_tax_on_state_pension_does_not_reduce_partners_earned_income(): assert values["universal_credit"][0] == pytest.approx(4_919.86, abs=0.01) -@pytest.mark.xfail( - strict=True, - reason=( - "PolicyEngine/policyengine-uk#1947: Marriage Allowance is booked on " - "the recipient as the transferor's unused personal allowance, so the " - "transferor's State Pension raises the recipient's tax on earnings" - ), -) def test_state_pension_does_not_change_partners_marriage_allowance(): # 2026: pensioner aged 70, partner aged 45 earning 20,000, council rent # 20,000. Once the pensioner elects, ITA 2007 s. 55B gives the partner a @@ -321,7 +327,8 @@ def test_state_pension_does_not_change_partners_marriage_allowance(): # keeps them within the basic rate, so they can elect (s. 55C(1)(c)). # Partner's tax on earnings = (20,000 - 12,570) x 20% - 252 = 1,234 and # NI (20,000 - 12,569.96) x 8% = 594.40, so earned income = 18,171.60 - # at any State Pension up to the pensioner's election limit. + # at any State Pension while the election saves the couple tax (below + # 12,570). # UC = 8,003.64 + 20,000 - (0.55 x 18,171.60 + 12,000) = 6,009.26. unit = dict( ages=[70, 45], @@ -335,3 +342,34 @@ def test_state_pension_does_not_change_partners_marriage_allowance(): values = calculate([unit], 2026) assert values["uc_earned_income"][0] == pytest.approx(18_171.60, abs=0.01) assert values["universal_credit"][0] == pytest.approx(6_009.26, abs=0.01) + + +def test_couple_stops_electing_when_it_no_longer_saves_tax(): + # 2026, the couple above with State Pension of 12,500 and 12,600. At + # 12,500 electing costs the pensioner (12,500 - 11,310) x 20% = 238 and + # saves the partner 252, so the couple elects and the partner's earned + # income is 18,171.60 as above: + # UC = 28,003.64 - (0.55 x 18,171.60 + 12,500) = 5,509.26. + # At 12,600 it would cost 252, the same as it saves, so the couple does not + # elect. The partner's tax on earnings is 1,486, earned income + # 20,000 - 1,486 - 594.40 = 17,919.60, and + # UC = 28,003.64 - (0.55 x 17,919.60 + 12,600) = 5,547.86. + # The election weighs only the couple's income tax, not their UC, so 100 + # more State Pension raises UC by 38.60 here. + unit = dict( + ages=[70, 45], + children=[], + tenure="RENT_FROM_COUNCIL", + rent=20_000.0, + savings=0.0, + earnings=20_000.0, + state_pension=12_500.0, + ) + low = calculate([unit], 2026) + high = calculate([unit], 2026, pension_bump=100.0) + assert low[ELECTION].tolist() == [True, False] + assert high[ELECTION].tolist() == [False, False] + assert low["uc_earned_income"][0] == pytest.approx(18_171.60, abs=0.01) + assert high["uc_earned_income"][0] == pytest.approx(17_919.60, abs=0.01) + assert low["universal_credit"][0] == pytest.approx(5_509.26, abs=0.01) + assert high["universal_credit"][0] == pytest.approx(5_547.86, abs=0.01) diff --git a/policyengine_uk/tests/test_uc_unearned_benefits_properties.py b/policyengine_uk/tests/test_uc_unearned_benefits_properties.py index 78a59e33a0..ba67888d58 100644 --- a/policyengine_uk/tests/test_uc_unearned_benefits_properties.py +++ b/policyengine_uk/tests/test_uc_unearned_benefits_properties.py @@ -26,13 +26,17 @@ For carer support payment both families keep the carer's caring hours, so both get the carer element. -Marriage Allowance is switched off for invariants 2 and 3, and for invariant -1 for carer support payment. The model books it on the recipient as the -transferor's unused personal allowance (PolicyEngine/policyengine-uk#1947), -so a taxable benefit that uses up the first adult's allowance raises the -earning partner's tax on earnings. Invariants 2 and 3 compare the award before the benefit cap because -contributory ESA and industrial injuries benefit trigger benefit cap -exemptions and ESA counts towards the cap, which private pension does not. +Marriage Allowance is claimed throughout. Once a couple has elected, the +gaining partner's reduction is fixed (ITA 2007 s. 55B(1), (4)-(6)), so the +first adult's benefit cannot move the earning partner's tax on earnings. The +couple's election itself can respond to the benefit, since a taxable benefit +changes whether electing lowers the couple's tax (see +test_uc_state_pension_properties.py). So every invariant that compares runs +holds each couple's election at its choice in the first run +(PolicyEngine/policyengine-uk#1947). Invariants 2 and 3 compare the award +before the benefit cap because contributory ESA and industrial injuries +benefit trigger benefit cap exemptions and ESA counts towards the cap, which +private pension does not. """ import numpy as np @@ -84,6 +88,7 @@ "uc_income_reduction", "uc_maximum_amount", ] +ELECTION = "makes_marriage_allowance_election" @st.composite @@ -100,17 +105,16 @@ def families(draw): ) -def situation( - units, year, income_variable, bump=0.0, carer=False, marriage_allowance=True -): +def situation(units, year, income_variable, bump=0.0, carer=False, election=None): """Build one simulation holding every family. The first adult receives the family's ``amount`` (plus ``bump``) under ``income_variable``; a partner receives the family's earnings. With ``carer``, the first adult cares for 40 hours a week in Scotland and has carer support payment set explicitly (zero unless it is the income - variable), so the carer element applies whatever the income. With - ``marriage_allowance=False`` no one claims Marriage Allowance. + variable), so the carer element applies whatever the income. + ``election``, one value per person in order, fixes who makes a Marriage + Allowance election. """ people, benunits, households = {}, {}, {} for i, unit in enumerate(units): @@ -122,8 +126,6 @@ def situation( # head's child) does not apply. Setting it for anyone makes it an # input for everyone, so children get False below. person = {"age": {year: age}, "is_claimant_or_partner": {year: True}} - if not marriage_allowance: - person["would_claim_marriage_allowance"] = {year: False} if j == 0: if carer: person["care_hours"] = {year: 40} @@ -145,12 +147,16 @@ def situation( "savings": {year: unit["savings"]}, "country": {year: "SCOTLAND" if carer else "ENGLAND"}, } + if election is not None: + for person, elects in zip(people.values(), election): + person[ELECTION] = {year: bool(elects)} return {"people": people, "benunits": benunits, "households": households} def calculate(units, year, income_variable, **kwargs): sim = Simulation(situation=situation(units, year, income_variable, **kwargs)) values = {v: np.asarray(sim.calculate(v, year)) for v in UC_VARIABLES} + values[ELECTION] = np.asarray(sim.calculate(ELECTION, year)) counted = np.asarray(sim.calculate("uc_unearned_carer_support_payment", year)) values["counted_csp"] = np.asarray( sim.map_result(counted, "person", "benunit", how="sum") @@ -203,7 +209,7 @@ def assert_monotone(low, high, increase, units): ) def test_uc_is_non_increasing_in_each_benefit(units, benefit, bump, year): low = calculate(units, year, benefit) - high = calculate(units, year, benefit, bump=bump) + high = calculate(units, year, benefit, bump=bump, election=low[ELECTION]) assert_monotone(low, high, bump, units) @@ -214,9 +220,15 @@ def test_uc_is_non_increasing_in_each_benefit(units, benefit, bump, year): year=st.sampled_from(CSP_YEARS), ) def test_uc_is_non_increasing_in_carer_support_payment(units, bump, year): - kwargs = dict(carer=True, marriage_allowance=False) - low = calculate(units, year, "carer_support_payment", **kwargs) - high = calculate(units, year, "carer_support_payment", bump=bump, **kwargs) + low = calculate(units, year, "carer_support_payment", carer=True) + high = calculate( + units, + year, + "carer_support_payment", + bump=bump, + carer=True, + election=low[ELECTION], + ) increase = high["counted_csp"] - low["counted_csp"] assert np.all(increase >= -0.01), units assert_monotone(low, high, increase, units) @@ -230,8 +242,8 @@ def test_uc_is_non_increasing_in_carer_support_payment(units, bump, year): year=st.sampled_from(YEARS), ) def test_uc_falls_pound_for_pound_in_each_benefit(units, benefit, bump, year): - low = calculate(units, year, benefit, marriage_allowance=False) - high = calculate(units, year, benefit, bump=bump, marriage_allowance=False) + low = calculate(units, year, benefit) + high = calculate(units, year, benefit, bump=bump, election=low[ELECTION]) award = low["universal_credit_pre_benefit_cap"] np.testing.assert_allclose( high["universal_credit_pre_benefit_cap"], @@ -248,9 +260,15 @@ def test_uc_falls_pound_for_pound_in_each_benefit(units, benefit, bump, year): year=st.sampled_from(CSP_YEARS), ) def test_uc_falls_pound_for_pound_in_counted_carer_support_payment(units, bump, year): - kwargs = dict(carer=True, marriage_allowance=False) - low = calculate(units, year, "carer_support_payment", **kwargs) - high = calculate(units, year, "carer_support_payment", bump=bump, **kwargs) + low = calculate(units, year, "carer_support_payment", carer=True) + high = calculate( + units, + year, + "carer_support_payment", + bump=bump, + carer=True, + election=low[ELECTION], + ) award = low["universal_credit_pre_benefit_cap"] increase = high["counted_csp"] - low["counted_csp"] np.testing.assert_allclose( @@ -268,9 +286,13 @@ def test_uc_falls_pound_for_pound_in_counted_carer_support_payment(units, bump, year=st.sampled_from(YEARS), ) def test_each_benefit_counts_like_private_pension(units, benefit, year): + with_benefit = calculate(units, year, benefit) + with_pension = calculate( + units, year, "private_pension_income", election=with_benefit[ELECTION] + ) assert_same( - calculate(units, year, benefit, marriage_allowance=False), - calculate(units, year, "private_pension_income", marriage_allowance=False), + with_benefit, + with_pension, [v for v in UC_VARIABLES if v != "universal_credit"], str(units), ) @@ -282,12 +304,17 @@ def test_each_benefit_counts_like_private_pension(units, benefit, year): year=st.sampled_from(CSP_YEARS), ) def test_carer_support_payment_counts_like_capped_private_pension(units, year): - kwargs = dict(carer=True, marriage_allowance=False) - with_csp = calculate(units, year, "carer_support_payment", **kwargs) + with_csp = calculate(units, year, "carer_support_payment", carer=True) # The same family with the counted amount of Carer Support Payment # received as private pension instead. counted = [dict(unit, amount=c) for unit, c in zip(units, with_csp["counted_csp"])] - with_pension = calculate(counted, year, "private_pension_income", **kwargs) + with_pension = calculate( + counted, + year, + "private_pension_income", + carer=True, + election=with_csp[ELECTION], + ) assert_same( with_csp, with_pension, diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/is_marriage_allowance_spouse.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/is_marriage_allowance_spouse.py new file mode 100644 index 0000000000..f4e09d52eb --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/is_marriage_allowance_spouse.py @@ -0,0 +1,27 @@ +from policyengine_uk.model_api import * + + +class is_marriage_allowance_spouse(Variable): + value_type = bool + entity = Person + label = "Spouse or civil partner for Marriage Allowance" + documentation = ( + "Whether this person is one of the married couple or civil partners " + "in their benefit unit, who can make or gain from a Marriage " + "Allowance election: the claimant and partner of a married benefit " + "unit, never a child in it. The Act needs only a marriage or civil " + "partnership, not living together, but a spouse outside the benefit " + "unit is not modelled." + ) + definition_period = YEAR + reference = dict( + title="Income Tax Act 2007 s. 55C(1)(a)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/55C", + ) + + def formula(person, period, parameters): + status = person("marital_status", period) + spouse = (status == status.possible_values.MARRIED) & person( + "is_claimant_or_partner", period + ) + return spouse & (person.benunit.sum(spouse) == 2) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/makes_marriage_allowance_election.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/makes_marriage_allowance_election.py new file mode 100644 index 0000000000..de1543e4ef --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/makes_marriage_allowance_election.py @@ -0,0 +1,183 @@ +from policyengine_uk.model_api import * + +BRANCH_OUTPUTS = [ + "income_tax", + "personal_allowance", + "marriage_allowance_tax_reduction_limit", + "meets_marriage_allowance_income_conditions", +] +# Inputs to income tax that do not depend on the election. They are resolved +# before branching and held at the simulation's values in both branches, so +# the result does not depend on which variable a caller asks for first, and a +# choice made in branches upstream of them (a reform's split of adjusted net +# income, or a labour supply response in employment income) is not made again +# for the counterfactual. +INDEPENDENT_OF_ELECTION = [ + "age", + "employment_income", + "adjusted_net_income", + "gift_aid_grossed_up", + "gift_aid", + "blind_persons_allowance", + "covenanted_payments", + "charitable_investment_gifts", + "other_deductions", + "pension_contributions_relief", + "taxable_savings_interest_income", + "taxable_dividend_income", + "taxable_property_income", + "pays_scottish_income_tax", + "CB_HITC", + "other_tax_credits", + "married_couples_allowance", +] + + +def tax_position(simulation, name, period, relinquished, held): + """Each person's tax with no election in force and the given allowance cut. + + The branch holds the election off, so no one gains a reduction, and cuts + each person's personal allowance by ``relinquished`` (ITA 2007 s. 55B(6)). + It also holds each variable in ``held`` at the given values, the + simulation's own: the inputs to income tax the election does not change, + and the property allowance route. From policyengine-core 3.33, an input + set on a branch drops what the branch holds that may have been calculated + from the value it replaces. A choice the simulation made in branches of + its own before this election (the property allowance route, say) was + weighed with an election in force there, so without these inputs the + branch would make that choice again for the counterfactual it represents. + The branch shares the simulation's cached arrays until it writes, and is + dropped afterwards so a later period starts from a fresh branch. + """ + while name in simulation.branches or name == simulation.branch_name: + name += "_" + branch = simulation.get_branch(name) + try: + branch.set_input( + "makes_marriage_allowance_election", + period, + np.zeros(len(relinquished), dtype=bool), + ) + branch.set_input("marriage_allowance_relinquished", period, relinquished) + for variable, values in held.items(): + branch.set_input(variable, period, values) + population = branch.populations["person"] + return {variable: population(variable, period) for variable in BRANCH_OUTPUTS} + finally: + del simulation.branches[name] + + +class makes_marriage_allowance_election(Variable): + value_type = bool + entity = Person + label = "Makes a Marriage Allowance election" + documentation = ( + "Whether this person elects to give up part of their personal " + "allowance so that their spouse or civil partner gets the Marriage " + "Allowance tax reduction. The couple elects only when the election " + "is allowed and lowers their combined income tax, in whichever " + "direction lowers it more, and only if the spouse who would gain has " + "would_claim_marriage_allowance true. Set as an input, an election " + "is taken to be one the other spouse is entitled to gain from: the " + "conditions are checked only when the model makes the choice." + ) + 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. 55C", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/55C", + ), + dict( + title="Income Tax Act 2007 s. 55E", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/55E", + ), + ] + + def formula(person, period, parameters): + held = { + variable: person(variable, period) for variable in INDEPENDENT_OF_ELECTION + } + spouse = person("is_marriage_allowance_spouse", period) + transferable = person("marriage_allowance_transferable_amount", period) + # The property allowance route (ITTOIA 2005 s. 783BL) is settled + # first, each route weighed with the election it would bring. The + # election is then weighed under that route. + held["uses_property_allowance"] = person("uses_property_allowance", period) + simulation = person.simulation + without = tax_position( + simulation, + "marriage_allowance_without_election", + period, + np.zeros_like(transferable), + held, + ) + relinquishing = tax_position( + simulation, + "marriage_allowance_relinquishing", + period, + spouse * transferable, + held, + ) + + def partner(values): + # The other spouse's value, for each spouse. + return person.benunit.sum(spouse * values) - spouse * values + + # If this person elects, the other spouse's tax falls by the + # appropriate percentage of this person's transferable amount, capped + # at the tax they have left (s. 55B(1), (3); s. 29(2)), and this + # person pays tax on the allowance they give up. + rate = person("marriage_allowance_appropriate_percentage", period) + partner_reduction = min_( + partner(rate) * transferable, + partner(without["marriage_allowance_tax_reduction_limit"]), + ) + extra_tax = relinquishing["income_tax"] - without["income_tax"] + # Tax is computed to the penny; smaller differences are rounding. + saving = np.round(partner_reduction - extra_tax, 2) + married_couples_allowance_claimed = person.benunit.any( + spouse & (person("married_couples_allowance", period) > 0) + ) + allowed = ( + spouse + # s. 55B(2)(d): neither spouse claims married couple's allowance. + & ~married_couples_allowance_claimed + # s. 55C(1)(b): the electing spouse is entitled to an allowance. + & (without["personal_allowance"] > 0) + # s. 55C(1)(c), (ca): only basic rates once the allowance is cut. + & relinquishing["meets_marriage_allowance_income_conditions"] + # s. 55B(2)(b), (ba): the gaining party pays only basic rates. + & (partner(without["meets_marriage_allowance_income_conditions"]) > 0) + ) + # Elect only to lower the couple's tax. Only one spouse elects. s. 55E + # allows each of them one election and one reduction and does not + # bar elections both ways, which would lower some couples' tax + # further (where giving up allowance costs less than the basic rate, + # as on dividends or at the Scottish starter rate), but HMRC's + # guidance describes a single transfer from the lower earner. + saving_if_allowed = where(allowed & (saving > 0), saving, 0) + other_saving = partner(saving_if_allowed) + # On a tie the spouse with the lower adjusted net income elects, then + # the elder, then the first listed. + income = person("adjusted_net_income", period) + other_income = partner(income) + elder = ( + person.get_rank(person.benunit, -person("age", period), condition=spouse) + == 0 + ) + first_on_tie = (income < other_income) | ((income == other_income) & elder) + chooses = (saving_if_allowed > 0) & ( + (saving_if_allowed > other_saving) + | ((saving_if_allowed == other_saving) & first_on_tie) + ) + # The couple's take-up draw is the gaining spouse's, the same draw + # that decided take-up when the transfer was modelled on the + # recipient alone. + partner_would_claim = ( + partner(person("would_claim_marriage_allowance", period)) > 0 + ) + return chooses & partner_would_claim 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 d54f76158a..ec3ee2b6df 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 @@ -4,8 +4,11 @@ class would_claim_marriage_allowance(Variable): label = "Would claim Marriage Allowance" documentation = ( - "Whether this person would claim Marriage Allowance if eligible. " - "Generated stochastically in the dataset using take-up rates." + "Whether this person would claim Marriage Allowance from their spouse " + "or civil partner's election when it lowers the couple's income tax. " + "Generated stochastically in the dataset using take-up rates. The " + "couple's election follows the value on the spouse who would gain " + "(makes_marriage_allowance_election)." ) entity = Person definition_period = YEAR @@ -19,34 +22,31 @@ class would_claim_marriage_allowance(Variable): class marriage_allowance(Variable): value_type = float entity = Person - label = "Marriage Allowance" + label = "Marriage Allowance received" + documentation = ( + "The transferable amount this person's spouse or civil partner gives " + "up under a Marriage Allowance election. This person's tax falls by " + "the appropriate percentage of it (marriage_allowance_tax_reduction); " + "the allowance itself does not come off their taxable income." + ) definition_period = YEAR - reference = "https://www.legislation.gov.uk/ukpga/2007/3/part/3/chapter/3A" + 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. 55C", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/55C", + ), + ] 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) - ) - 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 + # The gaining party is the other spouse or civil partner of the person + # who elects (s. 55C(1)(a)); at most one of the couple elects. + spouse = person("is_marriage_allowance_spouse", period) + given_up = person("makes_marriage_allowance_election", period) * person( + "marriage_allowance_transferable_amount", period ) - # Use pre-generated take-up decision from dataset - would_claim = person("would_claim_marriage_allowance", period) - return eligible * amount_if_eligible * would_claim + return spouse * (person.benunit.sum(spouse * given_up) - given_up) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_appropriate_percentage.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_appropriate_percentage.py new file mode 100644 index 0000000000..1554e2652d --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_appropriate_percentage.py @@ -0,0 +1,31 @@ +from policyengine_uk.model_api import * + + +class marriage_allowance_appropriate_percentage(Variable): + value_type = float + entity = Person + label = "Marriage Allowance appropriate percentage" + documentation = ( + "The rate at which the Marriage Allowance reduces the gaining party's " + "tax: the basic rate, or the Scottish basic rate for a Scottish " + "taxpayer, whatever rate their income is actually taxed at. The Welsh " + "basic rate equals the basic rate in this model." + ) + definition_period = YEAR + reference = dict( + title="Income Tax Act 2007 s. 55B(3)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/55B", + ) + unit = "/1" + + def formula(person, period, parameters): + rates = parameters(period).gov.hmrc.income_tax.rates + # The Scottish basic rate follows the starter rate; before 2018-19 the + # Scottish scale had no starter rate. + scottish = rates.scotland.rates.rates + scottish_basic = scottish[1 if len(scottish) > 3 else 0] + return where( + person("pays_scottish_income_tax", period), + scottish_basic, + rates.uk.rates[0], + ) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_relinquished.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_relinquished.py new file mode 100644 index 0000000000..48fb598dfe --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_relinquished.py @@ -0,0 +1,24 @@ +from policyengine_uk.model_api import * + + +class marriage_allowance_relinquished(Variable): + value_type = float + entity = Person + label = "Personal allowance given up under a Marriage Allowance election" + documentation = ( + "The transferable amount by which a Marriage Allowance election cuts " + "the electing person's own personal allowance." + ) + definition_period = YEAR + reference = dict( + title="Income Tax Act 2007 s. 55B(6)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/55B", + ) + unit = GBP + + def formula(person, period, parameters): + # Only a spouse or civil partner can give allowance up. + elects = person("makes_marriage_allowance_election", period) & person( + "is_marriage_allowance_spouse", period + ) + return elects * person("marriage_allowance_transferable_amount", period) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_tax_reduction.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_tax_reduction.py new file mode 100644 index 0000000000..826fcfc7a4 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_tax_reduction.py @@ -0,0 +1,34 @@ +from policyengine_uk.model_api import * + + +class marriage_allowance_tax_reduction(Variable): + value_type = float + entity = Person + label = "Marriage Allowance tax reduction" + documentation = ( + "The gaining party's tax reduction at Step 6 of the income tax " + "calculation: the appropriate percentage of the transferable amount, " + "capped at the tax left to reduce." + ) + definition_period = YEAR + reference = [ + dict( + title="Income Tax Act 2007 s. 55A(2)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/55A", + ), + dict( + title="Income Tax Act 2007 s. 55B(1) and (3)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/55B", + ), + dict( + title="Income Tax Act 2007 s. 29(2)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/29", + ), + ] + unit = GBP + + def formula(person, period, parameters): + reduction = person("marriage_allowance_appropriate_percentage", period) * ( + person("marriage_allowance", period) + ) + return min_(reduction, person("marriage_allowance_tax_reduction_limit", period)) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_tax_reduction_limit.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_tax_reduction_limit.py new file mode 100644 index 0000000000..82caa9078f --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_tax_reduction_limit.py @@ -0,0 +1,40 @@ +from policyengine_uk.model_api import * + + +class marriage_allowance_tax_reduction_limit(Variable): + value_type = float + entity = Person + label = "Tax available for the Marriage Allowance reduction" + documentation = ( + "Tax at Step 5 of the income tax calculation left after the person's " + "other tax reductions. The Marriage Allowance tax reduction cannot " + "exceed it. Each reduction is limited to the tax left after those " + "before it, so the order changes how the total is split between " + "them but not the total, which is the greatest reduction in " + "liability. Deducting this one last makes it exactly the tax an " + "election saves the gaining spouse. Double taxation relief should " + "come after the Marriage Allowance (s. 27(6)), but other_tax_credits " + "does not separate it out." + ) + definition_period = YEAR + reference = [ + dict( + title="Income Tax Act 2007 s. 29(2) and (3)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/29", + ), + dict( + title="Income Tax Act 2007 s. 27(2)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/27", + ), + ] + unit = GBP + + def formula(person, period, parameters): + income_tax = parameters(period).gov.hmrc.income_tax + other_reductions = [ + variable + for variable in income_tax.income_tax_subtractions + if variable != "marriage_allowance_tax_reduction" + ] + step_5_tax = person("income_tax_pre_charges", period) + return max_(0, step_5_tax - add(person, period, other_reductions)) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_transferable_amount.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_transferable_amount.py new file mode 100644 index 0000000000..a26dbec283 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_transferable_amount.py @@ -0,0 +1,26 @@ +from policyengine_uk.model_api import * + + +class marriage_allowance_transferable_amount(Variable): + value_type = float + entity = Person + label = "Marriage Allowance transferable amount" + documentation = ( + "The fixed amount of personal allowance a Marriage Allowance election " + "transfers: 10% of the personal allowance in ITA 2007 s. 35(1), " + "rounded up to a multiple of £10. It does not depend on how much of " + "the transferor's allowance is unused." + ) + definition_period = YEAR + reference = dict( + title="Income Tax Act 2007 s. 55B(4) and (5)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/55B", + ) + unit = GBP + + def formula(person, period, parameters): + allowances = parameters(period).gov.hmrc.income_tax.allowances + ma = allowances.marriage_allowance + amount = allowances.personal_allowance.amount * ma.max + increment = ma.rounding_increment + return np.ceil(amount / increment) * increment 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 e91f14cbd3..00a89c6c59 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,18 +1,84 @@ from policyengine_uk.model_api import * -import numpy as np + + +def liable_only_at_marriage_allowance_rates(person, period, parameters): + """Whether the person pays no income tax above the basic rates. + + ITA 2007 s. 55B(2)(b) and s. 55C(1)(c) allow only the basic rate, the + property, default, savings, Scottish and Welsh basic rates, the Scottish + starter and intermediate rates, the dividend ordinary rate and the nil and + starting rates for savings. So no non-savings income may reach the higher + rate (the Scottish higher rate for a Scottish taxpayer), and no savings the + savings higher rate. Dividends covered by the dividend nil rate still + count: s. 55B(2)(ba) and s. 55C(1)(ca) test the dividend upper rate as if + s. 13A were omitted. + """ + rates = parameters(period).gov.hmrc.income_tax.rates + savings = person("taxable_savings_interest_income", period) + dividends = person("taxable_dividend_income", period) + # Earnings, pensions, property and other non-savings income after + # allowances, as in property_income_tax. + non_savings = max_( + 0, + person("adjusted_net_income", period) + - savings + - dividends + - person("allowances", period), + ) + # The Scottish higher rate follows the starter, basic and intermediate + # rates; before 2018-19 the Scottish scale had only the basic rate below it. + scottish = rates.scotland.rates + scottish_higher = scottish.thresholds[3 if len(scottish.rates) > 3 else 1] + non_savings_limit = where( + person("pays_scottish_income_tax", period), + scottish_higher, + rates.uk.thresholds[1], + ) + savings_above_basic = add( + person, period, ["higher_rate_savings_income", "add_rate_savings_income"] + ) + # Dividends are the top slice, above all non-savings income (property + # included) and savings (s. 16), counted before the dividend allowance; + # the dividend upper rate starts at the basic rate limit (s. 13). The + # savings test above uses the model's own savings bands. + below_dividends = non_savings + max_( + 0, savings - person("received_allowances_savings_income", period) + ) + dividends_after_allowances = max_( + 0, dividends - person("received_allowances_dividend_income", period) + ) + dividends_above_basic = (dividends_after_allowances > 0) & ( + below_dividends + dividends_after_allowances > rates.uk.thresholds[1] + ) + return ( + (non_savings <= non_savings_limit) + & (savings_above_basic <= 0) + & ~dividends_above_basic + ) class meets_marriage_allowance_income_conditions(Variable): label = "Meets Marriage Allowance income conditions" - documentation = "Whether this person (and their partner) meets the conditions for this person to be eligible for the Marriage Allowance, as set out in the Income Tax Act 2007 sections 55B and 55C" + documentation = ( + "Whether this person pays income tax only at the rates that allow a " + "Marriage Allowance election, given their actual allowances. The " + "gaining party must meet this (ITA 2007 s. 55B(2)(b), (ba)); the " + "electing spouse must meet it with their allowance already cut by " + "the transferable amount (s. 55C(1)(c), (ca))." + ) entity = Person definition_period = YEAR value_type = bool - reference = "https://www.legislation.gov.uk/ukpga/2007/3/section/55B" + reference = [ + dict( + title="Income Tax Act 2007 s. 55B(2)(b) and (ba)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/55B", + ), + dict( + title="Income Tax Act 2007 s. 55C(1)(c) and (ca)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/55C", + ), + ] def formula(person, period, parameters): - band = person("tax_band", period) - eligible_bands = parameters( - period - ).gov.hmrc.income_tax.allowances.marriage_allowance.eligible_bands - return np.isin(band.decode_to_str(), eligible_bands) + return liable_only_at_marriage_allowance_rates(person, period, parameters) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/partners_unused_personal_allowance.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/partners_unused_personal_allowance.py deleted file mode 100644 index 9f18828b9f..0000000000 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/partners_unused_personal_allowance.py +++ /dev/null @@ -1,23 +0,0 @@ -from policyengine_uk.model_api import * -from numpy import ceil - - -class partners_unused_personal_allowance(Variable): - label = "Partner's unused personal allowance" - reference = "https://www.legislation.gov.uk/ukpga/2007/3/section/55B" - documentation = ( - "The personal tax allowance not used by this person's partner, if they exist" - ) - entity = Person - definition_period = YEAR - value_type = float - unit = GBP - - def formula(person, period, parameters): - # Only a spouse or civil partner can transfer allowance (ITA 2007 - # s.55B(5A), s.55C(1)(a)): the other claimant or partner of the - # benefit unit, never a child. People outside the couple get zero, so - # they cannot produce a negative transferable amount. - couple_member = person("is_claimant_or_partner", period) - pa = person("unused_personal_allowance", period) - return person.benunit.sum(couple_member * pa) - couple_member * pa diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/personal_allowance.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/personal_allowance.py index 52efd2fab7..19b60ca948 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/personal_allowance.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/personal_allowance.py @@ -7,10 +7,16 @@ class personal_allowance(Variable): label = "Personal Allowance for the year" unit = GBP definition_period = YEAR - reference = dict( - title="Income Tax Act 2007 s. 35, s. 58", - href="https://www.legislation.gov.uk/ukpga/2007/3/section/35", - ) + reference = [ + dict( + title="Income Tax Act 2007 s. 35, s. 58", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/35", + ), + dict( + title="Income Tax Act 2007 s. 55B(6)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/55B", + ), + ] def formula(person, period, parameters): params = parameters(period) @@ -23,4 +29,8 @@ def formula(person, period, parameters): ANI_for_taper = ANI - gift_aid_grossed_up excess = max_(0, ANI_for_taper - PA.maximum_ANI) reduction = excess * PA.reduction_rate - return max_(0, np.ceil(personal_allowance - reduction)) + allowance = max_(0, np.ceil(personal_allowance - reduction)) + # A Marriage Allowance election cuts the electing spouse's allowance + # by the transferable amount (s. 55B(6)). + relinquished = person("marriage_allowance_relinquished", period) + return max_(0, allowance - relinquished) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/property_finance_cost_relief.py b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/property_finance_cost_relief.py index b23956b772..7c0f2113fa 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/property_finance_cost_relief.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/property_finance_cost_relief.py @@ -1,5 +1,7 @@ from policyengine_uk.model_api import * +DEDUCTED_AFTER = ["property_finance_cost_relief", "marriage_allowance_tax_reduction"] + class property_finance_cost_relief(Variable): value_type = float @@ -42,12 +44,14 @@ def formula(person, period, parameters): ) # The order of Step 6 reductions does not change their total, so the # person's other reductions come first; this one is limited to the - # Step 5 tax they leave. Step 7 charges (ITA 2007 s. 30), such as the - # High Income Child Benefit Charge, are not reduced. + # Step 5 tax they leave. The Marriage Allowance reduction comes after + # this one, so it is exactly the tax an election saves the gaining + # spouse. Step 7 charges (ITA 2007 s. 30), such as the High Income + # Child Benefit Charge, are not reduced. other_reductions = [ variable for variable in income_tax.income_tax_subtractions - if variable != "property_finance_cost_relief" + if variable not in DEDUCTED_AFTER ] tax_left = max_( 0,