From 10106ad72498f95a6c1d82a2e6499b379c097251 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 10:39:49 -0400 Subject: [PATCH 1/6] Fix Gift Aid income tax rate limits (#2253) Extend UK and eligible Scottish rate limits by gross Gift Aid, remove the net-donation allowance, and adjust ANI once without changing taxable income. Preserve CGT and zero-gift behavior, with policy and property tests plus statutory documentation. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2253.fixed.md | 1 + docs/book/validation/spi-validation.ipynb | 2 +- docs/engineering/gift-aid.md | 80 ++++ .../reforms/cps/marriage_tax_reforms.py | 16 +- .../capital_gains_tax/capital_gains_tax.yaml | 30 +- .../capital_gains_tax_components.yaml | 34 +- .../gov/hmrc/income_tax/allowances.yaml | 4 +- .../allowances/allowance_integration.yaml | 12 +- .../hmrc/income_tax/allowances/gift_aid.yaml | 48 +- .../allowances/is_allowance_eligible.yaml | 10 +- .../received_allowances_earned_income.yaml | 14 +- .../gov/hmrc/income_tax/gift_aid.yaml | 304 +++++++++++++ .../reliefs/property_finance_cost_relief.yaml | 7 +- .../test_capital_gains_tax_components.py | 6 +- policyengine_uk/tests/test_gift_aid.py | 409 ++++++++++++++++++ policyengine_uk/utils/income_tax.py | 29 ++ policyengine_uk/utils/supplied_inputs.py | 1 + ...intenance_loan_candidate_benunit_income.py | 2 +- .../maintenance_loan_household_income.py | 2 +- .../income/uc_income_tax_on_earnings.py | 19 +- .../capital_gains_tax/capital_gains_tax.py | 6 +- .../hmrc/income_tax/adjusted_net_income.py | 24 +- .../hmrc/income_tax/allowances/allowances.py | 3 +- .../hmrc/income_tax/allowances/gift_aid.py | 4 +- .../allowances/pension_annual_allowance.py | 2 +- .../allowances/personal_allowance.py | 7 +- .../received_allowances_earned_income.py | 2 +- .../allowances/unused_personal_allowance.py | 3 +- .../add_rate_earned_income.py | 6 +- .../basic_rate_earned_income.py | 4 +- .../earned_taxable_income.py | 2 +- .../higher_rate_earned_income.py | 6 +- .../bracketized_liability/tax_band.py | 43 +- .../add_rate_savings_income.py | 8 +- .../basic_rate_savings_income.py | 6 +- .../basic_rate_savings_income_pre_starter.py | 6 +- .../higher_rate_savings_income.py | 10 +- .../gov/hmrc/income_tax/earned_income_tax.py | 17 +- .../liability/dividend_income_tax.py | 10 +- .../liability/property_income_tax.py | 28 +- .../gov/hmrc/income_tax/net_income.py | 37 ++ .../property_finance_costs_relieved.py | 2 +- .../student_loans/plan_2_interest_rate.py | 2 +- .../student_loans/student_loan_repayment.py | 2 +- 44 files changed, 1099 insertions(+), 171 deletions(-) create mode 100644 changelog.d/2253.fixed.md create mode 100644 docs/engineering/gift-aid.md create mode 100644 policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/gift_aid.yaml create mode 100644 policyengine_uk/tests/test_gift_aid.py create mode 100644 policyengine_uk/utils/income_tax.py create mode 100644 policyengine_uk/variables/gov/hmrc/income_tax/net_income.py diff --git a/changelog.d/2253.fixed.md b/changelog.d/2253.fixed.md new file mode 100644 index 0000000000..cfccd9a421 --- /dev/null +++ b/changelog.d/2253.fixed.md @@ -0,0 +1 @@ +- Fixed Gift Aid income tax relief by extending rate limits instead of deducting donations from taxable income, and deducting grossed-up gifts from adjusted net income. diff --git a/docs/book/validation/spi-validation.ipynb b/docs/book/validation/spi-validation.ipynb index 6889042648..aac183e5c1 100644 --- a/docs/book/validation/spi-validation.ipynb +++ b/docs/book/validation/spi-validation.ipynb @@ -55,7 +55,7 @@ "1. Cases where an individual appears to transfer a portion of their Personal Allowance to their spouse as part of the Marriage Allowance. This is because the SPI removes the transferred amount from the individual's Personal Allowance, but does not indicate that the subsequent lower amount is driven by the Marriage Allowance. This may represent as much as 37,998 records (4.8% of total).\n", "1. Cases where an individual receives less than £100,000 in annual income and is non-resident for taxation purposes. In this situation, the individual receives £0 Personal Allowance, even though our model would project a full £12,500 allowance. The SPI uses a variable that is not publicly available to calculate this status. This represents 2,054 records (0.3% of total).\n", "1. Cases where both the SPI and PolicyEngine calculate the same amount of taxable savings income for an individual receiving any form of savings income classified by the SPI as \"Other Investment Income\" (e.g., interest on securities), but the SPI finds that they owe around £1,000 more in income tax than we do. This represents 1,276 records (0.2% of total).\n", - "1. Cases where an individual near the Personal Allowance taper value who claims Gift Aid receives a full Personal Allowance of £12,500 within the SPI model. The SPI model's Gift Aid variable signifies the amount an individual deducts due to Gift Aid donations, while PolicyEngine's represents an individual's total Gift Aid-eligible donations. We then calculate the individual's deduction amount from this value. This error represents 738 records (0.1% of total).\n", + "1. Cases where an individual near the Personal Allowance taper value who claims Gift Aid receives a full Personal Allowance of £12,500 within the SPI model. This historical validation identified 738 records (0.1% of total) with differences associated with the interpretation of Gift Aid inputs. PolicyEngine's `gift_aid` input is the net eligible donation. Under [ITA 2007 ss. 414–415](https://www.legislation.gov.uk/ukpga/2007/3/section/414), the grossed-up gift extends the income tax rate limits; it does not reduce taxable income. The grossed-up gift also reduces adjusted net income for the Personal Allowance taper under [s. 58](https://www.legislation.gov.uk/ukpga/2007/3/section/58). These figures describe the original validation and have not been recalculated after that treatment was corrected.\n", "\n", "## Conclusion\n", "PolicyEngine's UK model shows a high level of accordance with the SPI's 2020/21 data, with almost 93% of records with the same inputs returning the same total income tax calculation. While some discrepancies exist, they are limited in scope and often attributable to either testability limitations within the SPI's structure or to inputs that the SPI has not made publicly available. This validation exercise builds upon our previous validations, such as our [Autumn 2023 model calibration](https://policyengine.org/uk/research/uk-calibration-2023-q4). We welcome questions and comments on our findings or methodology - please feel free to [get in touch](mailto:contact@policyengine.org)." diff --git a/docs/engineering/gift-aid.md b/docs/engineering/gift-aid.md new file mode 100644 index 0000000000..b64ef562c3 --- /dev/null +++ b/docs/engineering/gift-aid.md @@ -0,0 +1,80 @@ +# Gift Aid and income tax + +`gift_aid` is the net eligible donation. [Income Tax Act 2007 +s. 414(2)(a)](https://www.legislation.gov.uk/ukpga/2007/3/section/414) +treats the gift as made after deduction of basic-rate income tax, and +[s. 415](https://www.legislation.gov.uk/ukpga/2007/3/section/415) defines its +grossed-up amount. The model calculates `gift_aid_grossed_up` as the net gift +divided by one minus the UK basic rate. + +Gift Aid does not belong in `allowances` and does not reduce taxable income. +Under s. 414(2)(b), the gross gift increases both UK rate limits. For Scottish +non-savings income, it also increases the Scottish basic-rate upper limit and +every limit above it; the starter-rate upper limit remains unchanged. Savings +and dividends use the extended UK limits for Scottish taxpayers too. + +The model extends limits where band amounts are calculated. For variables that +use a complete rate scale, `calc_income_tax_with_gift_aid` in +`policyengine_uk/utils/income_tax.py` applies an equivalent correction to the +scale's original calculation. At each extended threshold, it subtracts the +rate increase multiplied by the income between the old and extended limits. +For a stacked slice, subtracting the corrected tax at its bottom from the +corrected tax at its top gives the tax on that slice. This preserves the +original scale calculation when the gift is zero and handles gifts that cross +several thresholds. + +`net_income` holds the income base previously exposed as `adjusted_net_income`. +Taxable-income calculations and allowance allocation use this base. +When adjusted net income is supplied explicitly, its gross Gift Aid adjustment +is added back to obtain the taxable-income base. This preserves direct income +inputs when the gift is zero without recovering ordinary taxable income from a +rounded adjusted-net-income calculation. The marriage-neutral income-tax +reform splits `net_income` before each person's Gift Aid adjustment. +`adjusted_net_income` deducts the gross gift once, as [s. 58(1), Step +2](https://www.legislation.gov.uk/ukpga/2007/3/section/58) requires. The Personal +Allowance taper and High Income Child Benefit Charge therefore see the lower +adjusted net income. The Personal Allowance formula does not deduct Gift Aid a +second time. Capital gains tax uses `net_income`, retains its gross Gift Aid +band extension, and no longer removes the net gift from `allowances`. + +## Existing limitations + +Zero-gift calculations retain their existing behavior. Several inherited +limitations remain relevant when interpreting mixed-income results: + +- Savings and dividend stacking currently omits property income from the + income beneath those slices. Gift Aid extends the limits of those existing + stacks. [ITA 2007 s. 16](https://www.legislation.gov.uk/ukpga/2007/3/section/16) + requires savings and dividends above other income; repairing property + occupancy separately would also change zero-gift results. +- The Personal Savings Allowance follows the model's `tax_band` enum, including + its Scottish band classification. [s. 12B(8)](https://www.legislation.gov.uk/ukpga/2007/3/section/12B) + instead tests Scottish non-savings income against the counterfactual UK + higher and additional rates. Extending the existing band tests preserves + that limitation. +- The Scottish top-rate threshold parameter is £112,570 of income after + allowances for 2024 onwards. The [HMRC rates and allowances + table](https://www.gov.uk/government/publications/rates-and-allowances-income-tax/income-tax-rates-and-allowances-current-and-past) + gives £125,140 after allowances. The Gift Aid correction extends the model's + existing threshold and does not repair it. +- Adjusted net income still omits other adjustments in s. 58, including the + deduction for pension contributions relieved at source in Step 3. +- Gifts are assumed to qualify. The model does not charge the donor for a + shortfall in income tax and capital gains tax covering the tax attributed to + gifts under [ss. 423–424](https://www.legislation.gov.uk/ukpga/2007/3/section/424). + +## Relief bounds in tests + +For a fixed taxable-income stack and fixed zero-rate allowances, the maximum +band-extension relief is the gross gift multiplied by the sum of positive +rate increases at extended limits. For earned income, that cumulative rate +increase is the top rate minus the basic rate: 25 percentage points for the UK +schedule and 28 for the current Scottish schedule. A bound using only the +largest adjacent rate increase would fail even for the £150,000 UK example, +where both UK limits move. + +Restoring Personal Allowance through the adjusted-net-income taper can give +additional relief. Changing tax band can also increase the Personal Savings +Allowance discretely under s. 12B, so its relief cannot be bounded by a constant +times a very small gift. Tests of the continuous band-extension bound must +allow for that allowance change or hold the zero-rate allowance fixed. diff --git a/policyengine_uk/reforms/cps/marriage_tax_reforms.py b/policyengine_uk/reforms/cps/marriage_tax_reforms.py index 813cfc4570..73efa66e79 100644 --- a/policyengine_uk/reforms/cps/marriage_tax_reforms.py +++ b/policyengine_uk/reforms/cps/marriage_tax_reforms.py @@ -153,9 +153,11 @@ def formula(person, period, parameters): COMPONENTS.append("basic_income") return max_(0, add(person, period, COMPONENTS)) - class adjusted_net_income(Variable): - label = "Optimised adjusted net income" - documentation = "Adjusted net income, but split equally between partners if they are married" + class net_income(Variable): + label = "Optimised net income" + documentation = ( + "Income before Gift Aid adjustments, split equally between married partners" + ) entity = Person definition_period = YEAR value_type = float @@ -171,9 +173,7 @@ def formula(person, period, parameters): originally_split_income_branch = person.simulation.get_branch( "originally_split_income", clone_system=True ) - originally_split_income_branch.set_input( - "adjusted_net_income", period, income - ) + originally_split_income_branch.set_input("net_income", period, income) originally_split_income_tax = person.benunit.sum( originally_split_income_branch.calculate("income_tax", period) ) @@ -186,7 +186,7 @@ def formula(person, period, parameters): split_income_branch = person.simulation.get_branch( "split_income", clone_system=True ) - split_income_branch.set_input("adjusted_net_income", period, split_income) + split_income_branch.set_input("net_income", period, split_income) split_income_tax = person.benunit.sum( split_income_branch.calculate("income_tax", period) ) @@ -200,7 +200,7 @@ def formula(person, period, parameters): class reform(Reform): def apply(self): self.add_variable(meets_ma_neutral_tax_conditions) - self.update_variable(adjusted_net_income) + self.update_variable(net_income) self.add_variable(unadjusted_net_income) return reform diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/capital_gains_tax/capital_gains_tax.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/capital_gains_tax/capital_gains_tax.yaml index 635a946d7c..aa94221356 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/capital_gains_tax/capital_gains_tax.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/capital_gains_tax/capital_gains_tax.yaml @@ -6,7 +6,7 @@ - name: No capital gains period: 2025 input: - adjusted_net_income: 0 + net_income: 0 capital_gains: 0 output: capital_gains_tax: 0 @@ -14,7 +14,7 @@ - name: Capital gains below annual exempt amount period: 2025 input: - adjusted_net_income: 0 + net_income: 0 capital_gains: 2_000 output: capital_gains_tax: 0 @@ -22,7 +22,7 @@ - name: Capital gains just above annual exempt amount period: 2025 input: - adjusted_net_income: 0 + net_income: 0 capital_gains: 3_500 output: capital_gains_tax: 90 @@ -30,7 +30,7 @@ - name: Basic rate capital gains tax period: 2025 input: - adjusted_net_income: 20_000 + net_income: 20_000 capital_gains: 23_000 output: capital_gains_tax: 3_600 @@ -39,7 +39,7 @@ period: 2025 absolute_error_margin: 0.01 input: - adjusted_net_income: 50_000 + net_income: 50_000 capital_gains: 50_000 output: capital_gains_tax: 11_263.80 @@ -47,7 +47,7 @@ - name: Additional rate capital gains tax period: 2025 input: - adjusted_net_income: 150_000 + net_income: 150_000 capital_gains: 100_000 output: capital_gains_tax: 23_280 @@ -56,7 +56,7 @@ period: 2025 absolute_error_margin: 0.01 input: - adjusted_net_income: 35_000 + net_income: 35_000 capital_gains: 40_000 output: capital_gains_tax: 7_963.80 @@ -64,7 +64,7 @@ - name: High capital gains, low income period: 2025 input: - adjusted_net_income: 10_000 + net_income: 10_000 capital_gains: 200_000 output: capital_gains_tax: 45_018 @@ -73,7 +73,7 @@ period: 2025 absolute_error_margin: 0.1 input: - adjusted_net_income: 500_000 + net_income: 500_000 capital_gains: 1_000_000 output: capital_gains_tax: 239_280 @@ -81,7 +81,7 @@ - name: Edge case, capital gains exactly equal to annual exempt amount period: 2025 input: - adjusted_net_income: 0 + net_income: 0 capital_gains: 3_000 output: capital_gains_tax: 0 @@ -105,7 +105,7 @@ input: people: person1: - adjusted_net_income: 9_324 + net_income: 9_324 capital_gains: 228_890 person2: capital_gains: -124_972 @@ -117,7 +117,7 @@ - name: Case 13, Gift Aid extends the basic rate band for capital gains. period: 2018 input: - adjusted_net_income: 41_850 + net_income: 41_850 gift_aid: 40_000 capital_gains: 70_000 output: @@ -136,7 +136,7 @@ period: 2026 absolute_error_margin: 0.01 input: - adjusted_net_income: 11_336.06 + net_income: 11_336.06 capital_gains: 32_942.86 output: capital_gains_tax: 5_389.71 @@ -150,7 +150,7 @@ period: 2024 absolute_error_margin: 0.01 input: - adjusted_net_income: 20_000 + net_income: 20_000 capital_gains: 23_000 output: capital_gains_tax: 2_692.60 @@ -159,7 +159,7 @@ period: 2024 absolute_error_margin: 0.01 input: - adjusted_net_income: 150_000 + net_income: 150_000 capital_gains: 100_000 output: capital_gains_tax: 21_079.56 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/capital_gains_tax/capital_gains_tax_components.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/capital_gains_tax/capital_gains_tax_components.yaml index f7676af409..9f7b32c837 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/capital_gains_tax/capital_gains_tax_components.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/capital_gains_tax/capital_gains_tax_components.yaml @@ -11,13 +11,13 @@ # The residential property higher rate is 24% for the whole of 2024-25. - name: Residential property 2024, 18% on the unused basic rate band and 24% above, after one AEA. - # adjusted_net_income 42,570 less the 12,570 personal allowance leaves taxable + # net_income 42,570 less the 12,570 personal allowance leaves taxable # income 30,000 and 7,700 of the 37,700 basic rate band. 100,000 - 3,000 AEA = # 97,000: 7,700 x 0.18 = 1,386 plus 89,300 x 0.24 = 21,432. period: 2024 absolute_error_margin: 0.01 input: - adjusted_net_income: 42_570 + net_income: 42_570 capital_gains: 100_000 capital_gains_residential_property: 100_000 output: @@ -29,7 +29,7 @@ period: 2024 absolute_error_margin: 0.01 input: - adjusted_net_income: 42_570 + net_income: 42_570 capital_gains_before_response: 100_000 capital_gains_residential_property: 100_000 output: @@ -39,7 +39,7 @@ # 500,000 - 3,000 AEA = 497,000 x 0.10. period: 2024 input: - adjusted_net_income: 0 + net_income: 0 capital_gains: 500_000 capital_gains_badr: 500_000 output: @@ -49,7 +49,7 @@ # 497,000 x 0.14 (Finance Act 2025 s. 8, disposals from 6 April 2025). period: 2025 input: - adjusted_net_income: 0 + net_income: 0 capital_gains: 500_000 capital_gains_badr: 500_000 output: @@ -59,7 +59,7 @@ # 497,000 x 0.18 (Finance Act 2025 s. 8, disposals from 6 April 2026). period: 2026 input: - adjusted_net_income: 0 + net_income: 0 capital_gains: 500_000 capital_gains_badr: 500_000 output: @@ -73,7 +73,7 @@ period: 2025 absolute_error_margin: 0.1 input: - adjusted_net_income: 0 + net_income: 0 capital_gains: 1_500_000 capital_gains_badr: 1_500_000 output: @@ -85,7 +85,7 @@ period: 2024 absolute_error_margin: 0.1 input: - adjusted_net_income: 0 + net_income: 0 capital_gains: 1_010_000 capital_gains_badr: 1_010_000 output: @@ -97,7 +97,7 @@ period: 2024 absolute_error_margin: 0.1 input: - adjusted_net_income: 0 + net_income: 0 capital_gains: 1_000_000 capital_gains_carried_interest: 1_000_000 output: @@ -108,7 +108,7 @@ period: 2025 absolute_error_margin: 0.1 input: - adjusted_net_income: 0 + net_income: 0 capital_gains: 1_000_000 capital_gains_carried_interest: 1_000_000 output: @@ -122,7 +122,7 @@ # the residential gains would have paid 0.18 and the total would be 6,538. period: 2025 input: - adjusted_net_income: 0 + net_income: 0 capital_gains: 47_700 capital_gains_badr: 37_700 capital_gains_residential_property: 10_000 @@ -141,7 +141,7 @@ period: 2024 absolute_error_margin: 0.01 input: - adjusted_net_income: 20_000 + net_income: 20_000 capital_gains: 100_000 capital_gains_badr: 20_000 capital_gains_carried_interest: 10_000 @@ -155,7 +155,7 @@ # Residential: 37,700 x 0.18 = 6,786 plus 2,300 x 0.24 = 552. period: 2025 input: - adjusted_net_income: 0 + net_income: 0 capital_gains: 50_000 capital_gains_residential_property: 40_000 capital_gains_carried_interest: 30_000 @@ -167,7 +167,7 @@ # 34,500 x 0.18 = 6,210 plus 3,800 x 0.28 = 1,064. period: 2018 input: - adjusted_net_income: 0 + net_income: 0 capital_gains: 50_000 capital_gains_residential_property: 50_000 output: @@ -176,7 +176,7 @@ - name: A schedule input without capital_gains is ignored. period: 2025 input: - adjusted_net_income: 0 + net_income: 0 capital_gains: 0 capital_gains_residential_property: 50_000 output: @@ -185,7 +185,7 @@ - name: A net loss pays nothing whatever the schedule inputs say. period: 2025 input: - adjusted_net_income: 0 + net_income: 0 capital_gains: -20_000 capital_gains_residential_property: 50_000 output: @@ -196,7 +196,7 @@ period: 2025 absolute_error_margin: 0.01 input: - adjusted_net_income: 50_000 + net_income: 50_000 capital_gains: 50_000 capital_gains_badr: 0 capital_gains_residential_property: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances.yaml index 6f2f1f0f16..970f0d71f1 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances.yaml @@ -30,14 +30,14 @@ period: 2020 absolute_error_margin: 0 input: - adjusted_net_income: 10000 + net_income: 10000 output: pension_annual_allowance: 40000 - name: Annual Allowance reduced period: 2020 absolute_error_margin: 0 input: - adjusted_net_income: 320000 + net_income: 320000 output: pension_annual_allowance: 4000 - name: Trading allowance full deduction diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/allowance_integration.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/allowance_integration.yaml index 07cc84943b..f9fbe29a6a 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/allowance_integration.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/allowance_integration.yaml @@ -1,23 +1,23 @@ -- name: Income tax, based on adjusted net income +- name: Income tax, based on net income period: 2024 absolute_error_margin: 4 input: - adjusted_net_income: 40000 + net_income: 40000 output: income_tax: 5484.2 -- name: Income tax, increasing adjusted net income +- name: Income tax, increasing net income period: 2024 absolute_error_margin: 4 input: - adjusted_net_income: 50000 + net_income: 50000 output: income_tax: 7484.2 -- name: Income tax, continously increasing adjusted net income +- name: Income tax, continuously increasing net income period: 2024 absolute_error_margin: 4 input: - adjusted_net_income: 60000 + net_income: 60000 output: income_tax: 11428.4 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/gift_aid.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/gift_aid.yaml index 921f9fb2fa..233dc97b68 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/gift_aid.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/gift_aid.yaml @@ -1,29 +1,31 @@ -# Gift Aid tests -# ITA 2007 s.58 requires grossed-up Gift Aid to be deducted from ANI -# when calculating Personal Allowance taper +# ITA 2007 ss.414(2)(b), 415 extend the basic and higher rate limits by +# the grossed-up gift rather than deducting the net gift from taxable income. +# https://www.legislation.gov.uk/ukpga/2007/3/section/414 +# https://www.legislation.gov.uk/ukpga/2007/3/section/415 +# s.58 Step 2 deducts the gross gift from adjusted net income (ANI). +# https://www.legislation.gov.uk/ukpga/2007/3/section/58 -- name: Higher rate taxpayer gets 40% relief +- name: Higher-rate taxpayer gets relief from the extended basic-rate limit period: 2025 - absolute_error_margin: 1 + absolute_error_margin: 0.01 input: employment_income: 60000 gift_aid: 1000 output: - # Basic rate band is £37,700, PA is £12,570 - # Higher rate threshold = £50,270 - # £60k income means in 40% band - # £1k gift aid reduces taxable income by £1k, saving 40% - income_tax: 11032 + # £1,000 net = £1,250 gross, moving £1,250 from 40% to 20%. + # £11,432 tax without Gift Aid less £250 relief. + income_tax: 11182 -- name: Basic rate taxpayer gets 20% relief +- name: Basic-rate taxpayer gets no further taxpayer relief period: 2025 - absolute_error_margin: 1 + absolute_error_margin: 0.01 input: employment_income: 30000 gift_aid: 1000 output: - # In basic rate band, £1k gift aid saves 20% - income_tax: 3286 + # s.414(2)(a) treats the gift as made after basic-rate deduction. + # Extending the basic-rate limit leaves this taxpayer's tax unchanged. + income_tax: 3486 - name: Gift Aid reduces ANI for PA taper - full PA restored period: 2025 @@ -52,20 +54,14 @@ # PA = £12,570 - £5k = £7,570 personal_allowance: 7570 -- name: PA taper zone effective relief is ~60% +- name: Gift Aid restores personal allowance and extends rate limits period: 2025 - absolute_error_margin: 50 + absolute_error_margin: 0.01 input: employment_income: 110000 gift_aid: 10000 output: - # Without gift aid: PA = £7,570, tax ~£33,432 - # With £10k gift aid (£12.5k gross): - # - ANI for PA taper reduced to £97,500 (below £100k) - # - Full PA £12,570 restored - # - Taxable = £110k - £12,570 (PA) - £10k (gift aid) = £87,430 - # - Tax at basic rate (£37,700): £7,540 - # - Tax at higher rate (£87,430 - £37,700 = £49,730): £19,892 - # - Total: £27,432 - # Tax savings: ~£6,000 = 60% effective relief on £10k donation - income_tax: 27432 + # £10,000 net = £12,500 gross; ANI £97,500 restores PA £12,570. + # Taxable income remains £110,000 - £12,570 = £97,430. + # £50,200 at 20% plus £47,230 at 40% = £28,932. + income_tax: 28932 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/is_allowance_eligible.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/is_allowance_eligible.yaml index 9d8e883419..a91dfaee06 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/is_allowance_eligible.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/is_allowance_eligible.yaml @@ -30,10 +30,10 @@ - name: Annual Allowance for Pension Contributions period: 2024 input: - adjusted_net_income: 60_000 + net_income: 60_000 output: # AA = £60,000 from 2023-04-06 (FA 2004 s.228, amended by Finance (No. 2) Act 2023) - # ANI of £60,000 is below the taper threshold of £260,000, so full AA applies + # Net income of £60,000 is below the taper threshold of £260,000, so full AA applies pension_annual_allowance: 60_000 - name: Trading Allowance Deduction @@ -60,7 +60,7 @@ output: savings_allowance: 1000.0 -- name: Allowances Applicable to Adjusted Net Income +- name: Allowances applicable to net income exclude Gift Aid period: 2024 input: personal_allowance: 12000 @@ -70,4 +70,6 @@ charitable_investment_gifts: 100 other_deductions: 50 output: - allowances: 14950.0 + # ITA 2007 s.414(2)(b) extends limits instead of deducting the gift. + # https://www.legislation.gov.uk/ukpga/2007/3/section/414 + allowances: 14450.0 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_earned_income.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_earned_income.yaml index 3f9c06ad8b..6acebb4035 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_earned_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_earned_income.yaml @@ -2,7 +2,7 @@ period: 2024 input: allowances: 12_570 - adjusted_net_income: 10_000 + net_income: 10_000 taxable_savings_interest_income: 500 taxable_dividend_income: 500 output: @@ -12,7 +12,7 @@ period: 2024 input: allowances: 12_570 - adjusted_net_income: 13_570 + net_income: 13_570 taxable_savings_interest_income: 500 taxable_dividend_income: 500 output: @@ -22,7 +22,7 @@ period: 2024 input: allowances: 12_570 - adjusted_net_income: 50_000 + net_income: 50_000 taxable_savings_interest_income: 2_000 taxable_dividend_income: 3_000 output: @@ -32,7 +32,7 @@ period: 2024 input: allowances: 12_570 - adjusted_net_income: 20_000 + net_income: 20_000 taxable_savings_interest_income: 8_000 taxable_dividend_income: 7_000 output: @@ -42,17 +42,17 @@ period: 2024 input: allowances: 12_570 - adjusted_net_income: 30_000 + net_income: 30_000 taxable_savings_interest_income: 0 taxable_dividend_income: 0 output: received_allowances_earned_income: 12_570 -- name: Adjusted net income is erroneously negative +- name: Net income is erroneously negative period: 2024 input: allowances: 12_570 - adjusted_net_income: -10_000 + net_income: -10_000 taxable_savings_interest_income: 0 taxable_dividend_income: 0 output: diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/gift_aid.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/gift_aid.yaml new file mode 100644 index 0000000000..43124e502e --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/gift_aid.yaml @@ -0,0 +1,304 @@ +# ITA 2007 s.414(2)(a)-(b): the net gift is not a taxable-income +# deduction; the grossed-up gift extends rate limits. +# https://www.legislation.gov.uk/ukpga/2007/3/section/414 +# s.415: a net £800 gift is £1,000 gross at the 20% UK basic rate. +# https://www.legislation.gov.uk/ukpga/2007/3/section/415 +# s.58 Step 2: deduct the gross gift from adjusted net income (ANI). +# https://www.legislation.gov.uk/ukpga/2007/3/section/58 + +- name: Gift Aid gives no income tax relief to a UK basic-rate earner + period: 2026 + absolute_error_margin: 0.01 + input: + age: [40, 40] + region: [NORTH_WEST, NORTH_WEST] + employment_income: [30_000, 30_000] + gift_aid: [0, 800] + output: + gift_aid_grossed_up: [0, 1_000] + adjusted_net_income: [30_000, 29_000] + earned_taxable_income: [17_430, 17_430] + # £17,430 at 20%, with no taxpayer relief on the Gift Aid gift. + income_tax: [3_486, 3_486] + uc_income_tax_on_earnings: [3_486, 3_486] + # UC deducts the tax actually due on earnings, plus £1,394.40 NI. + uc_individual_earned_income_before_mif: [25_119.60, 25_119.60] + +- name: Gift Aid gives £200 relief to a UK higher-rate earner + period: 2026 + absolute_error_margin: 0.01 + input: + age: [40, 40] + region: [NORTH_WEST, NORTH_WEST] + employment_income: [60_000, 60_000] + gift_aid: [0, 800] + output: + earned_taxable_income: [47_430, 47_430] + # £1,000 moves from 40% to 20% under s.414(2)(b). + income_tax: [11_432, 11_232] + uc_income_tax_on_earnings: [11_432, 11_232] + uc_individual_earned_income_before_mif: [45_357.40, 45_557.40] + +- name: Gift Aid gives £250 relief to a UK additional-rate earner + period: 2026 + absolute_error_margin: 0.01 + input: + age: [40, 40] + region: [NORTH_WEST, NORTH_WEST] + employment_income: [150_000, 150_000] + gift_aid: [0, 800] + output: + earned_taxable_income: [150_000, 150_000] + # £1,000 moves from 40% to 20% and £1,000 from 45% to 40%. + income_tax: [53_703, 53_453] + uc_income_tax_on_earnings: [53_703, 53_453] + uc_individual_earned_income_before_mif: [91_286.40, 91_536.40] + +- name: Gift Aid extends Scottish basic and higher limits + period: 2026 + absolute_error_margin: 0.02 + input: + age: [40, 40] + region: [SCOTLAND, SCOTLAND] + employment_income: [60_000, 60_000] + gift_aid: [0, 800] + output: + # £1,000 moves from 21% to 20% and £1,000 from 42% to 21%. + # The Scottish starter/basic boundary is not extended by s.414. + income_tax: [13_182.05, 12_962.05] + +- name: Gift Aid restores £500 of personal allowance at £101000 income + period: 2026 + absolute_error_margin: 0.01 + input: + age: [40, 40] + region: [NORTH_WEST, NORTH_WEST] + employment_income: [101_000, 101_000] + gift_aid: [0, 800] + output: + adjusted_net_income: [101_000, 100_000] + # ITA 2007 s.58 Step 2 reduces the taper base exactly once. + personal_allowance: [12_070, 12_570] + earned_taxable_income: [88_930, 88_430] + income_tax: [28_032, 27_632] + +- name: Gift Aid extends UK limits for dividends above the basic-rate limit + period: 2026 + absolute_error_margin: 0.01 + input: + age: [40, 40] + region: [NORTH_WEST, NORTH_WEST] + employment_income: [50_000, 50_000] + dividend_income: [10_000, 10_000] + gift_aid: [0, 800] + output: + earned_taxable_income: [37_430, 37_430] + # The £500 dividend allowance still occupies the band. The extension + # moves £770 of taxed dividends from 35.75% to 10.75%: £192.50 relief. + dividend_income_tax: [3_396.25, 3_203.75] + income_tax: [10_882.25, 10_689.75] + +- name: Gift Aid extends UK limits across savings and dividend slices + period: 2026 + absolute_error_margin: 0.01 + input: + age: [40, 40] + region: [NORTH_WEST, NORTH_WEST] + employment_income: [49_070, 49_070] + savings_interest_income: [2_000, 2_000] + dividend_income: [5_000, 5_000] + gift_aid: [0, 1_600] + output: + # £2,000 gross extension moves £800 taxed savings down 20 percentage + # points and £700 taxed dividends down 25 percentage points. + savings_allowance: [500, 500] + savings_income_tax: [460, 300] + dividend_income_tax: [1_608.75, 1_433.75] + income_tax: [9_368.75, 9_033.75] + +- name: Gift Aid extends UK savings and dividend limits for Scottish taxpayers + period: 2026 + absolute_error_margin: 0.02 + input: + age: [40, 40] + region: [SCOTLAND, SCOTLAND] + employment_income: [50_000, 50_000] + savings_interest_income: [2_000, 2_000] + dividend_income: [5_000, 5_000] + gift_aid: [0, 800] + output: + earned_income_tax: [8_982.05, 8_762.05] + savings_income_tax: [600, 446] + dividend_income_tax: [1_608.75, 1_608.75] + income_tax: [11_190.80, 10_816.80] + +- name: Gift Aid extends the basic-rate limit where property occupies the band + period: 2026 + absolute_error_margin: 0.01 + input: + age: [40, 40] + region: [NORTH_WEST, NORTH_WEST] + employment_income: [40_000, 40_000] + property_income: [12_000, 12_000] + property_allowance_deduction: [0, 0] + gift_aid: [0, 800] + output: + earned_taxable_income: [27_430, 27_430] + property_income_tax: [2_746, 2_546] + income_tax: [8_232, 8_032] + +- name: Gift Aid extends Scottish limits where property occupies the higher band + period: 2026 + absolute_error_margin: 0.02 + input: + age: [40, 40] + region: [SCOTLAND, SCOTLAND] + employment_income: [40_000, 40_000] + property_income: [20_000, 20_000] + property_allowance_deduction: [0, 0] + gift_aid: [0, 800] + output: + # Scottish basic-limit relief falls on employment; higher-limit relief + # falls on property. Their total equals the Scottish earned-only case. + earned_income_tax: [5_551.07, 5_541.07] + property_income_tax: [7_630.98, 7_420.98] + income_tax: [13_182.05, 12_962.05] + +- name: Gift Aid extends the UK higher-rate limit for dividends + period: 2026 + absolute_error_margin: 0.02 + input: + age: [40, 40] + region: [NORTH_WEST, NORTH_WEST] + employment_income: [120_000, 120_000] + dividend_income: [40_000, 40_000] + gift_aid: [0, 800] + output: + # The basic-limit extension gives £200 earned relief. The higher-limit + # extension moves £1,000 dividends from 39.35% to 35.75%: £36 relief. + earned_income_tax: [40_460, 40_260] + dividend_income_tax: [15_376.21, 15_340.21] + income_tax: [55_836.21, 55_600.21] + +- name: Gift Aid leaves the Scottish starter-rate limit unchanged + period: 2026 + absolute_error_margin: 0.01 + input: + age: [40, 40] + region: [SCOTLAND, SCOTLAND] + employment_income: [18_000, 18_000] + gift_aid: [0, 800] + output: + # £3,967 at 19% and £1,463 at 20%, with the same starter limit. + income_tax: [1_046.33, 1_046.33] + +- name: Gift Aid extends every Scottish limit above the Scottish basic rate + period: 2026 + absolute_error_margin: 0.02 + input: + age: [40, 40] + region: [SCOTLAND, SCOTLAND] + employment_income: [150_000, 150_000] + gift_aid: [0, 800] + output: + # Rate steps 1%, 21%, 3% and 3% on £1,000 gross give £280 relief. + income_tax: [60_011.45, 59_731.45] + +- name: A large Gift Aid gift can cross several Scottish bands + period: 2026 + absolute_error_margin: 0.02 + input: + age: 40 + region: SCOTLAND + employment_income: 150_000 + gift_aid: 80_000 + output: + adjusted_net_income: 50_000 + personal_allowance: 12_570 + # Taxable £137,430 is split at £3,967, £116,956 and £131,092. + income_tax: 28_982.05 + +- name: Gift Aid can change the band used for the personal savings allowance + period: 2026 + absolute_error_margin: 0.01 + input: + age: [40, 40] + region: [NORTH_WEST, NORTH_WEST] + employment_income: [50_300, 50_300] + savings_interest_income: [100, 100] + gift_aid: [0, 800] + output: + # Tax band remains based on all taxable income as in the existing model, + # with s.414(2)(b)'s extended UK basic-rate limit (ITA 2007 s.12B). + tax_band: [HIGHER, BASIC] + savings_allowance: [500, 1_000] + savings_income_tax: [0, 0] + +- name: Gift Aid reduces adjusted net income for the Child Benefit tax charge + period: 2026 + absolute_error_margin: 0.01 + input: + age: [40, 40] + region: [NORTH_WEST, NORTH_WEST] + employment_income: [62_000, 62_000] + gift_aid: [0, 800] + child_benefit: [1_000, 1_000] + is_higher_earner: [true, true] + output: + # ITA 2007 s.58 Step 2 applies beyond the personal allowance taper. + adjusted_net_income: [62_000, 61_000] + CB_HITC: [100, 50] + +- name: Grossed-up Gift Aid can make adjusted net income negative + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + region: NORTH_WEST + employment_income: 10_000 + gift_aid: 20_000 + # Capital Gains Tax can cover the tax attributed to a qualifying gift + # even where the gift exceeds this year's income (ITA 2007 s.423). + # https://www.legislation.gov.uk/ukpga/2007/3/section/423 + capital_gains: 50_000 + output: + # ITA 2007 s.58 Step 2 subtracts the gross gift without a floor at nil. + net_income: 10_000 + gift_aid_grossed_up: 25_000 + adjusted_net_income: -15_000 + personal_allowance: 12_570 + income_tax: 0 + +- name: Zero Gift Aid preserves the marriage-neutral income tax reform + period: 2026 + absolute_error_margin: 0.01 + reforms: + - policyengine_uk.reforms.cps.marriage_tax_reforms.marriage_neutral_it_reform + input: + people: + earner: + age: 40 + employment_income: 100_000 + gift_aid: 0 + personal_allowance: 12_570 + marriage_allowance: 0 + spouse: + age: 40 + employment_income: 0 + gift_aid: 0 + personal_allowance: 12_570 + marriage_allowance: 0 + benunits: + benunit: + members: [earner, spouse] + is_married: true + households: + household: + members: [earner, spouse] + region: NORTH_WEST + output: + # The reform pools £100,000 and splits the pre-Gift-Aid income equally. + net_income: [50_000, 50_000] + adjusted_net_income: [50_000, 50_000] + earned_taxable_income: [37_430, 37_430] + income_tax: [7_486, 7_486] diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/property_finance_cost_relief.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/property_finance_cost_relief.yaml index 4cae4c3dda..b205bb7efd 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/property_finance_cost_relief.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/property_finance_cost_relief.yaml @@ -106,9 +106,10 @@ # deducted at Step 2 or Step 3. property_finance_costs_relieved: 5_430 property_finance_costs_carried_forward: 2_570 - # 20% x 5,430 = 1,086, limited to the Step 5 tax, which the model works - # out with Gift Aid deducted: (18,000 - 12,570 - 3,000) x 20% = 486. - property_finance_cost_relief: 486 + # ITA 2007 s.414(2)(b) extends limits without deducting Gift Aid: + # https://www.legislation.gov.uk/ukpga/2007/3/section/414 + # 20% x 5,430 = 1,086, matching the Step 5 tax on £5,430. + property_finance_cost_relief: 1_086 income_tax: 0 - name: A landlord within the personal allowance gets no relief and keeps the costs diff --git a/policyengine_uk/tests/test_capital_gains_tax_components.py b/policyengine_uk/tests/test_capital_gains_tax_components.py index 6972af3693..551d358315 100644 --- a/policyengine_uk/tests/test_capital_gains_tax_components.py +++ b/policyengine_uk/tests/test_capital_gains_tax_components.py @@ -40,11 +40,13 @@ def get(name): basic_rate_band_extension = ( get("gift_aid_grossed_up") + personal_pension_band_extension ) + # Gift Aid is no longer in allowances; the pre-Gift-Aid income base + # reproduces the old income/allowance add-backs without double relief. allowances_for_cgt_income = max_( 0, - get("allowances") - get("gift_aid") - personal_pension_band_extension, + get("allowances") - personal_pension_band_extension, ) - taxable_income = max_(0, get("adjusted_net_income") - allowances_for_cgt_income) + taxable_income = max_(0, get("net_income") - allowances_for_cgt_income) gains = max_(0, get("capital_gains")) aea = cgt.annual_exempt_amount gains_less_aea = max_(0, gains - aea) diff --git a/policyengine_uk/tests/test_gift_aid.py b/policyengine_uk/tests/test_gift_aid.py new file mode 100644 index 0000000000..5adfb43b60 --- /dev/null +++ b/policyengine_uk/tests/test_gift_aid.py @@ -0,0 +1,409 @@ +"""Gift Aid invariants and an independent earned-income tax calculation. + +ITA 2007 s.414(2)(a)-(b) treats gifts as net of basic-rate tax and extends +the UK basic/higher limits and the Scottish basic-rate upper and higher +limits. Section 415 defines grossing up; s.58 deducts that gross gift from +adjusted net income, rather than from taxable income: +https://www.legislation.gov.uk/ukpga/2007/3/section/414 +https://www.legislation.gov.uk/ukpga/2007/3/section/415 +https://www.legislation.gov.uk/ukpga/2007/3/section/58 + +The differential integrates the model's existing rate schedules independently. +It preserves their unrelated baseline policy choices, including the Scottish +top-rate threshold, to isolate the effect of extending limits for Gift Aid. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +YEAR = 2026 +CASES = 12 +BASE_PEOPLE = 2 * CASES # The same draws, once in rUK and once in Scotland. +PEOPLE = 3 * BASE_PEOPLE # Zero, drawn and larger gifts for each person. +INPUTS = ( + "employment_income", + "savings_interest_income", + "dividend_income", + "property_income", +) +PROPERTY_SETTINGS = settings( + max_examples=30, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) + +income = st.one_of( + st.integers(0, 300_000), + st.floats(0, 300_000, allow_nan=False, allow_infinity=False, width=32), + st.sampled_from([0, 12_570, 29_526, 43_662, 50_270, 100_000, 101_000, 150_000]), +) +# Multiples of four net pounds give integer gross gifts, avoiding rounding of +# Personal Allowance's statutory whole-pound taper in the random reference. +gift = st.integers(0, 10_000).map(lambda value: value * 4) +earned_cases = st.lists(st.tuples(income, gift, gift), min_size=CASES, max_size=CASES) +mixed_cases = st.lists( + st.tuples(income, income, income, income, gift, gift), + min_size=CASES, + max_size=CASES, +) +basic_cases = st.lists( + st.tuples(st.integers(0, 29_526), gift, gift), + min_size=CASES, + max_size=CASES, +) + + +def situation(rows, gifts=None): + people = { + f"p{i}": { + "age": {YEAR: 40}, + **{name: {YEAR: value} for name, value in zip(INPUTS, row)}, + } + for i, row in enumerate(rows) + } + if gifts is not None: + for i, amount in enumerate(gifts): + people[f"p{i}"]["gift_aid"] = {YEAR: amount} + return { + "people": people, + "benunits": {f"b{i}": {"members": [f"p{i}"]} for i in range(len(rows))}, + "households": { + f"h{i}": { + "members": [f"p{i}"], + "region": { + YEAR: "SCOTLAND" if i % BASE_PEOPLE >= CASES else "NORTH_WEST" + }, + } + for i in range(len(rows)) + }, + } + + +@pytest.fixture(scope="module") +def sim(): + return Simulation(situation=situation([(0, 0, 0, 0)] * PEOPLE, gifts=[0] * PEOPLE)) + + +@pytest.fixture(scope="module") +def omitted_sim(): + return Simulation(situation=situation([(0, 0, 0, 0)] * BASE_PEOPLE)) + + +def run(sim, drawn, *, earned_only=False): + if earned_only: + drawn = [(wages, 0, 0, 0, amount, extra) for wages, amount, extra in drawn] + columns = list(map(np.array, zip(*drawn))) + for name, values in zip(INPUTS, columns[:4]): + # Simulation moves wage inputs to the source of its labour-supply + # formula during initialization. Update that retained input so + # reset_calculations does not discard the new wage draw. + input_name = ( + "employment_income_before_lsr" if name == "employment_income" else name + ) + sim.set_input(input_name, YEAR, np.tile(values, 6)) + amounts, extras = (np.tile(column, 2) for column in columns[-2:]) + intended_gifts = np.concatenate([np.zeros(BASE_PEOPLE), amounts, amounts + extras]) + sim.set_input("gift_aid", YEAR, intended_gifts) + sim.reset_calculations() + np.testing.assert_array_equal(sim.calculate("gift_aid", YEAR), intended_gifts) + for name, values in zip(INPUTS, columns[:4]): + np.testing.assert_array_equal(sim.calculate(name, YEAR), np.tile(values, 6)) + return { + name: sim.calculate(name, YEAR).astype(float) + for name in ( + *INPUTS, + "gift_aid", + "gift_aid_grossed_up", + "personal_allowance", + "adjusted_net_income", + "income_tax", + ) + } + + +def tolerance(*amounts): + """A penny plus rounding of the model's float32 monetary values.""" + return 0.01 + 4e-7 * sum(np.abs(amount) for amount in amounts) + + +def reference_earned_tax(wages, gross_gift, schedule): + """Integrate raised bands directly, without using any model tax formula. + + The Scottish starter/basic boundary stays fixed under s.414(2)(b). + The duplicated UK top rate (an optional reform bracket) has no rate step. + s.58 lowers ANI for the s.35 Personal Allowance taper, not taxable wages. + """ + allowance = max(0, np.ceil(12_570 - max(0, wages - gross_gift - 100_000) / 2)) + taxable = max(0, wages - allowance) + thresholds, rates, eligible_indices = schedule + raised = [ + threshold + (gross_gift if i in eligible_indices else 0) + for i, threshold in enumerate(thresholds) + ] + tax = 0 + for i, (lower, rate) in enumerate(zip(raised, rates)): + upper = raised[i + 1] if i + 1 < len(raised) else float("inf") + tax += rate * max(0, min(taxable, upper) - lower) + return tax + + +def schedules(sim): + rates = sim.tax_benefit_system.parameters(f"{YEAR}-01-01").gov.hmrc.income_tax.rates + return ( + (rates.uk.thresholds, rates.uk.rates, {1, 2}), + ( + rates.scotland.rates.thresholds, + rates.scotland.rates.rates, + set(range(2, len(rates.scotland.rates.thresholds))), + ), + ) + + +@PROPERTY_SETTINGS +@given(earned_cases) +def test_earned_income_matches_independent_raised_limit_reference(sim, drawn): + result = run(sim, drawn, earned_only=True) + scales = schedules(sim) + expected = np.array( + [ + reference_earned_tax(wages, gross_gift, scales[i % BASE_PEOPLE >= CASES]) + for i, (wages, gross_gift) in enumerate( + zip(result["employment_income"], result["gift_aid_grossed_up"]) + ) + ] + ) + assert ( + np.abs(result["income_tax"] - expected) + <= tolerance(expected, result["income_tax"]) + ).all() + + +@PROPERTY_SETTINGS +@given(mixed_cases) +def test_relief_is_never_negative_for_mixed_income(sim, drawn): + result = run(sim, drawn) + baseline, with_gift, _ = np.split(result["income_tax"], 3) + assert (baseline - with_gift >= -tolerance(baseline, with_gift)).all() + + +@PROPERTY_SETTINGS +@given(mixed_cases) +def test_relief_is_non_decreasing_in_gift_for_mixed_income(sim, drawn): + result = run(sim, drawn) + _, with_gift, with_more = np.split(result["income_tax"], 3) + assert (with_more <= with_gift + tolerance(with_gift, with_more)).all() + + +@PROPERTY_SETTINGS +@given(earned_cases) +def test_earned_relief_is_bounded_by_rate_steps_and_allowance_taper(sim, drawn): + result = run(sim, drawn, earned_only=True) + baseline, with_gift, _ = np.split(result["income_tax"], 3) + allowance_before, allowance_after, _ = np.split(result["personal_allowance"], 3) + _, gross_gift, _ = np.split(result["gift_aid_grossed_up"], 3) + scales = schedules(sim) + # All extended thresholds contribute. At £150,000, the UK 20pp and 5pp + # steps save £250 on a £1,000 gross gift, so the largest *single* step + # would incorrectly bound relief at £200. Sum the eligible rate steps. + cumulative_step, top_rate = [], [] + for i in range(BASE_PEOPLE): + _, rates, eligible = scales[i >= CASES] + cumulative_step.append(sum(rates[j] - rates[j - 1] for j in eligible)) + top_rate.append(max(rates)) + limit = gross_gift * np.array(cumulative_step) + ( + allowance_after - allowance_before + ) * np.array(top_rate) + # Earned-only cases isolate this bound from jumps in the Personal Savings + # Allowance when a moved limit changes the model's taxpayer band. + assert (baseline - with_gift <= limit + tolerance(baseline, with_gift)).all() + + +@PROPERTY_SETTINGS +@given(basic_cases) +def test_no_relief_within_unextended_basic_band_below_taper(sim, drawn): + result = run(sim, drawn, earned_only=True) + baseline, with_gift, with_more = np.split(result["income_tax"], 3) + # £29,526 = £12,570 PA + £16,956 Scottish basic upper limit in 2026. + # It is also below rUK's £50,270 basic upper limit and the £100k taper. + np.testing.assert_array_equal(baseline, with_gift) + np.testing.assert_array_equal(baseline, with_more) + + +@PROPERTY_SETTINGS +@given(mixed_cases) +def test_explicit_zero_gift_matches_omitted_input(sim, omitted_sim, drawn): + result = run(sim, drawn) + for name in INPUTS: + input_name = ( + "employment_income_before_lsr" if name == "employment_income" else name + ) + omitted_sim.set_input(input_name, YEAR, result[name][:BASE_PEOPLE]) + omitted_sim.reset_calculations() + for variable in ( + "adjusted_net_income", + "personal_allowance", + "allowances", + "unused_personal_allowance", + "earned_taxable_income", + "earned_income_tax", + "savings_income_tax", + "dividend_income_tax", + "property_income_tax", + "income_tax", + "savings_allowance", + "tax_band", + "capital_gains_tax", + ): + before = omitted_sim.calculate(variable, YEAR) + after = sim.calculate(variable, YEAR)[:BASE_PEOPLE] + assert before.dtype == after.dtype + assert before.tobytes() == after.tobytes(), variable + + +def test_zero_gift_preserves_main_outputs_bit_for_bit(): + """Freeze main's intermediate and final outputs, including mixed income. + + Gift Aid's zero-input compatibility also applies to inherited baseline + choices about property stacking, the Scottish schedule and taxpayer bands. + Exact float32 expectations catch changes hidden by a tax-level tolerance. + """ + rows = [ + (30_000, 0, 0, 0), + (60_000, 0, 0, 0), + (150_000, 0, 0, 0), + (30_000, 12_000, 18_000, 9_000), + (0, 20_000, 10_000, 10_000), + (101_000, 0, 0, 0), + ] * 2 + inputs = situation(rows, gifts=[0] * len(rows)) + for i in range(len(rows)): + inputs["households"][f"h{i}"]["region"][YEAR] = ( + "SCOTLAND" if i >= len(rows) // 2 else "NORTH_WEST" + ) + inputs["people"][f"p{i}"]["capital_gains"] = {YEAR: 40_000} + snapshot = Simulation(situation=inputs) + allowance = [12_570, 12_570, 0, 12_570, 12_570, 12_070] * 2 + expected = { + "adjusted_net_income": [30_000, 60_000, 150_000, 69_000, 40_000, 101_000] * 2, + "personal_allowance": allowance, + "allowances": allowance, + "unused_personal_allowance": [0] * 12, + "earned_taxable_income": [17_430, 47_430, 150_000, 17_430, 0, 88_930] * 2, + "received_allowances_earned_income": [12_570, 12_570, 0, 12_570, 10_000, 12_070] + * 2, + "received_allowances_savings_income": [0, 0, 0, 0, 2_570, 0] * 2, + "received_allowances_dividend_income": [0] * 12, + "basic_rate_earned_income": [17_430, 37_700, 37_700, 17_430, 0, 37_700] * 2, + "higher_rate_earned_income": [0, 9_730, 87_440, 0, 0, 51_230] * 2, + "add_rate_earned_income": [0, 0, 24_860, 0, 0, 0] * 2, + "basic_rate_savings_income": [0, 0, 0, 6_500, 11_430, 0] * 2, + "higher_rate_savings_income": [0] * 12, + "add_rate_savings_income": [0] * 12, + "savings_allowance": [ + 1_000, + 500, + 0, + 500, + 1_000, + 500, + 1_000, + 500, + 0, + 500, + 1_000, + 0, + ], + "earned_income_tax": [ + 3_486, + 11_432, + 53_703, + 3_486, + 0, + 28_032, + 3451.070068359375, + 13182.0498046875, + 60011.44921875, + 3451.070068359375, + 0, + 31407.05078125, + ], + "savings_income_tax": [0, 0, 0, 1_300, 2_286, 0] * 2, + "dividend_income_tax": [0, 0, 0, 4_313.75, 1_021.25, 0] * 2, + "property_income_tax": [0, 0, 0, 1_800, 0, 0, 0, 0, 0, 1_890, 0, 0], + "income_tax": [ + 3_486, + 11_432, + 53_703, + 10_899.75, + 3_307.25, + 28_032, + 3451.070068359375, + 13182.0498046875, + 60011.44921875, + 10954.8203125, + 3_307.25, + 31407.05078125, + ], + "capital_gains_tax": [ + 7663.7998046875, + 8_880, + 8_880, + 8_880, + 8263.7998046875, + 8_880, + ] + * 2, + } + for variable, values in expected.items(): + actual = snapshot.calculate(variable, YEAR) + assert actual.tobytes() == np.array(values, dtype=actual.dtype).tobytes(), ( + variable + ) + assert snapshot.calculate("tax_band", YEAR).tolist() == [ + "BASIC", + "HIGHER", + "ADDITIONAL", + "HIGHER", + "BASIC", + "HIGHER", + "INTERMEDIATE", + "HIGHER", + "ADDITIONAL", + "HIGHER", + "INTERMEDIATE", + "ADDITIONAL", + ] + + +@pytest.mark.parametrize("ani,net_gift", [(50_000, 0), (49_000, 800)]) +def test_explicit_adjusted_net_income_preserves_tax_base(ani, net_gift): + """An input ANI already includes the s.58 gross Gift Aid adjustment. + + Reconstruct net income once when ANI is supplied directly. Zero Gift Aid + must retain the longstanding ability to supply this intermediate input. + """ + supplied_inputs = situation([(0, 0, 0, 0)], gifts=[net_gift]) + supplied_inputs["people"]["p0"]["adjusted_net_income"] = {YEAR: ani} + supplied = Simulation(situation=supplied_inputs) + raw = Simulation(situation=situation([(50_000, 0, 0, 0)], gifts=[net_gift])) + for variable in ( + "net_income", + "adjusted_net_income", + "personal_allowance", + "earned_taxable_income", + "income_tax", + ): + before = raw.calculate(variable, YEAR) + after = supplied.calculate(variable, YEAR) + assert before.dtype == after.dtype + assert before.tobytes() == after.tobytes(), variable + assert supplied.calculate("net_income", YEAR)[0] == 50_000 + assert supplied.calculate("earned_taxable_income", YEAR)[0] == 37_430 + assert supplied.calculate("income_tax", YEAR)[0] == 7_486 diff --git a/policyengine_uk/utils/income_tax.py b/policyengine_uk/utils/income_tax.py new file mode 100644 index 0000000000..23201e2fd6 --- /dev/null +++ b/policyengine_uk/utils/income_tax.py @@ -0,0 +1,29 @@ +import numpy as np + + +def calc_income_tax_with_gift_aid( + scale, income, gross_gift, first_extended_threshold=1, last_extended_threshold=3 +): + """Apply ITA 2007 s.414(2)(b) to a per-person marginal-rate schedule. + + Moving a threshold t to t + G removes the rate step on the overlap + with [t, t + G]. Summing those steps remains exact even when a gift + spans several bands. Starting at index 2 leaves the Scottish starter + band's upper limit unchanged. The default stop extends only the UK + basic and higher limits, excluding the model's optional extra bracket. + Scottish callers extend all subsequent limits. The scale is never mutated. + https://www.legislation.gov.uk/ukpga/2007/3/section/414 + """ + tax = scale.calc(income) + for index in range( + first_extended_threshold, min(last_extended_threshold, len(scale.thresholds)) + ): + overlap = np.clip(income - scale.thresholds[index], 0, gross_gift) + tax = tax - (scale.rates[index] - scale.rates[index - 1]) * overlap + return tax + + +def first_extended_scottish_threshold(scale): + # The three-rate schedule before 2018 has no starter rate. Later + # schedules have starter, basic, intermediate and higher rates. + return 1 if len(scale.thresholds) == 3 else 2 diff --git a/policyengine_uk/utils/supplied_inputs.py b/policyengine_uk/utils/supplied_inputs.py index 3e7d34caf7..d2bfdfb77c 100644 --- a/policyengine_uk/utils/supplied_inputs.py +++ b/policyengine_uk/utils/supplied_inputs.py @@ -26,6 +26,7 @@ # Variables that formulas ask about through the helpers below. SUPPLIED_INPUT_VARIABLES = frozenset( { + "adjusted_net_income", "bus_fare_spending", "bus_in_london_trips", "other_local_bus_trips", diff --git a/policyengine_uk/variables/gov/dfe/maintenance_loans/maintenance_loan_candidate_benunit_income.py b/policyengine_uk/variables/gov/dfe/maintenance_loans/maintenance_loan_candidate_benunit_income.py index 1ea120174b..9409309222 100644 --- a/policyengine_uk/variables/gov/dfe/maintenance_loans/maintenance_loan_candidate_benunit_income.py +++ b/policyengine_uk/variables/gov/dfe/maintenance_loans/maintenance_loan_candidate_benunit_income.py @@ -13,4 +13,4 @@ class maintenance_loan_candidate_benunit_income(Variable): unit = GBP def formula(person, period, parameters): - return person.benunit.sum(person.benunit.members("adjusted_net_income", period)) + return person.benunit.sum(person.benunit.members("net_income", period)) diff --git a/policyengine_uk/variables/gov/dfe/maintenance_loans/maintenance_loan_household_income.py b/policyengine_uk/variables/gov/dfe/maintenance_loans/maintenance_loan_household_income.py index 4aaf08b03e..816c2e2296 100644 --- a/policyengine_uk/variables/gov/dfe/maintenance_loans/maintenance_loan_household_income.py +++ b/policyengine_uk/variables/gov/dfe/maintenance_loans/maintenance_loan_household_income.py @@ -42,7 +42,7 @@ def formula(person, period, parameters): else np.zeros_like(has_sponsor, dtype=bool) ) benunit_income = person.benunit.sum( - person.benunit.members("adjusted_net_income", period) + person.benunit.members("net_income", period) ) dependent_student_proxy = ( in_higher_education diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_income_tax_on_earnings.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_income_tax_on_earnings.py index 88ba53027c..bd3d93a2ff 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_income_tax_on_earnings.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_income_tax_on_earnings.py @@ -1,4 +1,8 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.income_tax import ( + calc_income_tax_with_gift_aid, + first_extended_scottish_threshold, +) class uc_income_tax_on_earnings(Variable): @@ -44,7 +48,7 @@ def formula(person, period, parameters): # Pension coded against it; that is not modelled. p = parameters(period) income_tax = p.gov.hmrc.income_tax - # earned_taxable_income is adjusted net income less the incomes and + # earned_taxable_income is net income less the incomes and # allowances on the exclusions list. Read that list, so a reform # that changes it (for example one that exempts pensions) is # followed here. @@ -74,7 +78,7 @@ def formula(person, period, parameters): ) # The income left in earned_taxable_income before allowances. The # part of it above earnings is the person's other non-savings income. - income_in_base = person("adjusted_net_income", period) - add( + income_in_base = person("net_income", period) - add( person, period, excluded_incomes ) other_income = max_(0, income_in_base - earnings) @@ -82,10 +86,17 @@ def formula(person, period, parameters): 0, person("earned_taxable_income", period) - other_income ) rates = income_tax.rates + gross_gift = person("gift_aid_grossed_up", period) tax = where( person("pays_scottish_income_tax", period), - rates.scotland.rates.calc(taxable_earnings), - rates.uk.calc(taxable_earnings), + calc_income_tax_with_gift_aid( + rates.scotland.rates, + taxable_earnings, + gross_gift, + first_extended_scottish_threshold(rates.scotland.rates), + len(rates.scotland.rates.thresholds), + ), + calc_income_tax_with_gift_aid(rates.uk, taxable_earnings, gross_gift), ) # Tax reductions (for example the married couple's allowance) come # off the tax on earnings first, as allowances do. So the deduction diff --git a/policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_tax.py b/policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_tax.py index 9a9fef5e8e..31a414c0fb 100644 --- a/policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_tax.py +++ b/policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_tax.py @@ -56,13 +56,11 @@ def formula(person, period, parameters): ) allowances_for_cgt_income = max_( 0, - person("allowances", period) - - person("gift_aid", period) - - personal_pension_band_extension, + person("allowances", period) - personal_pension_band_extension, ) taxable_income = max_( 0, - person("adjusted_net_income", period) - allowances_for_cgt_income, + person("net_income", period) - allowances_for_cgt_income, ) capital_gains = person("capital_gains", period) gains = max_(0, capital_gains) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/adjusted_net_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/adjusted_net_income.py index 3723b6a60a..dc62518612 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/adjusted_net_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/adjusted_net_income.py @@ -4,27 +4,15 @@ class adjusted_net_income(Variable): value_type = float entity = Person - label = "Taxable income after tax reliefs and before allowances" + label = "Adjusted net income" definition_period = YEAR reference = dict( - title="Income Tax Act 2007, s. 23", - href="https://www.legislation.gov.uk/ukpga/2007/3/section/23", + title="Income Tax Act 2007, s. 58", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/58", ) unit = GBP def formula(person, period, parameters): - adjusted_net_income_components = parameters( - period - ).gov.hmrc.income_tax.adjusted_net_income_components - - # Find adjusted net income - ani = add(person, period, adjusted_net_income_components) - - # For basic income contributions, add basic income - # Modifying param list directly is mutative, hence two-step process - if parameters( - period - ).gov.contrib.ubi_center.basic_income.interactions.include_in_taxable_income: - ani += person("basic_income", period) - - return max_(0, ani) + # ITA 2007 s.58, Step 2: deduct the gift grossed up at the basic rate. + # This adjustment affects the PA taper and HICBC, not taxable income. + return person("net_income", period) - person("gift_aid_grossed_up", period) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/allowances.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/allowances.py index 1225fdc7f2..229589a4fc 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/allowances.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/allowances.py @@ -8,10 +8,11 @@ class allowances(Variable): definition_period = YEAR unit = GBP + # ITA 2007 s.414(2): Gift Aid extends rate limits rather than deducting + # the gift from the income charged to tax. adds = [ "personal_allowance", "blind_persons_allowance", - "gift_aid", "covenanted_payments", "charitable_investment_gifts", "other_deductions", diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/gift_aid.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/gift_aid.py index 58ead1962c..c814ae0be0 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/gift_aid.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/gift_aid.py @@ -20,8 +20,8 @@ class gift_aid_grossed_up(Variable): definition_period = YEAR unit = GBP reference = dict( - title="Income Tax Act 2007, s. 58 (2)", - href="https://www.legislation.gov.uk/ukpga/2007/3/section/58", + title="Income Tax Act 2007, ss. 414(2)(a) and 415", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/415", ) def formula(person, period, parameters): diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/pension_annual_allowance.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/pension_annual_allowance.py index c066e301c1..a939105d82 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/pension_annual_allowance.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/pension_annual_allowance.py @@ -10,6 +10,6 @@ class pension_annual_allowance(Variable): def formula(person, period, parameters): allowance = parameters(period).gov.hmrc.income_tax.allowances.annual_allowance - ANI = person("adjusted_net_income", period) + ANI = person("net_income", period) reduction = max_(0, ANI - allowance.taper) * allowance.reduction_rate return max_(allowance.minimum, allowance.default - reduction) 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..735684812c 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 @@ -17,10 +17,7 @@ def formula(person, period, parameters): PA = params.gov.hmrc.income_tax.allowances.personal_allowance personal_allowance = PA.amount ANI = person("adjusted_net_income", period) - # Per ITA 2007 s.58, deduct grossed-up Gift Aid from ANI - # when calculating Personal Allowance taper - gift_aid_grossed_up = person("gift_aid_grossed_up", period) - ANI_for_taper = ANI - gift_aid_grossed_up - excess = max_(0, ANI_for_taper - PA.maximum_ANI) + # adjusted_net_income already includes the s.58 Gift Aid adjustment. + excess = max_(0, ANI - PA.maximum_ANI) reduction = excess * PA.reduction_rate return max_(0, np.ceil(personal_allowance - reduction)) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_earned_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_earned_income.py index bcdd36dcd1..3103af399a 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_earned_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_earned_income.py @@ -14,7 +14,7 @@ def formula(person, period, parameters): all_allowances = person("allowances", period) - all_income = person("adjusted_net_income", period) + all_income = person("net_income", period) excluded_income = person("taxable_savings_interest_income", period) + person( "taxable_dividend_income", period ) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/unused_personal_allowance.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/unused_personal_allowance.py index 4257638e61..8a289e9aaf 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/unused_personal_allowance.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/unused_personal_allowance.py @@ -11,7 +11,6 @@ class unused_personal_allowance(Variable): def formula(person, period, parameters): return max_( - person("personal_allowance", period) - - person("adjusted_net_income", period), + person("personal_allowance", period) - person("net_income", period), 0, ) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/add_rate_earned_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/add_rate_earned_income.py index 73ba9eaaa0..eb2ea3f511 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/add_rate_earned_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/add_rate_earned_income.py @@ -15,4 +15,8 @@ class add_rate_earned_income(Variable): def formula(person, period, parameters): income = person("earned_taxable_income", period) thresholds = parameters(period).gov.hmrc.income_tax.rates.uk.thresholds - return clip(income, thresholds[2], inf) - thresholds[2] + # ITA 2007 s.414(2)(b): extend both UK rate limits by the gross gift. + gross_gift = person("gift_aid_grossed_up", period) + return clip(income, (thresholds[2] + gross_gift), inf) - ( + thresholds[2] + gross_gift + ) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/basic_rate_earned_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/basic_rate_earned_income.py index 7fdf1c463f..c620aaef59 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/basic_rate_earned_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/basic_rate_earned_income.py @@ -15,4 +15,6 @@ class basic_rate_earned_income(Variable): def formula(person, period, parameters): income = person("earned_taxable_income", period) thresholds = parameters(period).gov.hmrc.income_tax.rates.uk.thresholds - return clip(income, thresholds[0], thresholds[1]) + # ITA 2007 s.414(2)(b): extend both UK rate limits by the gross gift. + gross_gift = person("gift_aid_grossed_up", period) + return clip(income, thresholds[0], (thresholds[1] + gross_gift)) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/earned_taxable_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/earned_taxable_income.py index 98dba2b510..5de4387597 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/earned_taxable_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/earned_taxable_income.py @@ -16,6 +16,6 @@ def formula(person, period, parameters): exclusion_list = parameters( period ).gov.hmrc.income_tax.earned_taxable_income_exclusions - ani = person("adjusted_net_income", period) + ani = person("net_income", period) exclusions = add(person, period, exclusion_list) return max_(0, ani - exclusions) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/higher_rate_earned_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/higher_rate_earned_income.py index 6ea178bb96..813a231312 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/higher_rate_earned_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/higher_rate_earned_income.py @@ -15,4 +15,8 @@ class higher_rate_earned_income(Variable): def formula(person, period, parameters): income = person("earned_taxable_income", period) thresholds = parameters(period).gov.hmrc.income_tax.rates.uk.thresholds - return clip(income, thresholds[1], thresholds[2]) - thresholds[1] + # ITA 2007 s.414(2)(b): extend both UK rate limits by the gross gift. + gross_gift = person("gift_aid_grossed_up", period) + return clip( + income, (thresholds[1] + gross_gift), (thresholds[2] + gross_gift) + ) - (thresholds[1] + gross_gift) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_liability/tax_band.py b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_liability/tax_band.py index 7a67966c9f..62f4794ba6 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_liability/tax_band.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_liability/tax_band.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.income_tax import first_extended_scottish_threshold class TaxBand(Enum): @@ -24,11 +25,16 @@ class tax_band(Variable): def formula(person, period, parameters): allowances = person("allowances", period) - ani = person("adjusted_net_income", period) + ani = person("net_income", period) rates = parameters(period).gov.hmrc.income_tax.rates - basic = allowances + rates.uk.thresholds[0] - higher = allowances + rates.uk.thresholds[-2] - add = allowances + rates.uk.thresholds[-1] + gross_gift = person("gift_aid_grossed_up", period) + limits = [ + threshold + (gross_gift if index in (1, 2) else 0) + for index, threshold in enumerate(rates.uk.thresholds) + ] + basic = allowances + limits[0] + higher = allowances + limits[-2] + add = allowances + limits[-1] return select( [ani >= add, ani >= higher, ani > basic], [TaxBand.ADDITIONAL, TaxBand.HIGHER, TaxBand.BASIC], @@ -37,17 +43,26 @@ def formula(person, period, parameters): def formula_2017_04_06(person, period, parameters): allowances = person("allowances", period) - ani = person("adjusted_net_income", period) + ani = person("net_income", period) rates = parameters(period).gov.hmrc.income_tax.rates scot = person("pays_scottish_income_tax", period) income = ani - allowances + gross_gift = person("gift_aid_grossed_up", period) + # s.414(2)(b) extends the limits used to determine the tax band. uk_band = select( - [income < threshold for threshold in rates.uk.thresholds[:3]], + [ + income < threshold + (gross_gift if index > 0 else 0) + for index, threshold in enumerate(rates.uk.thresholds[:3]) + ], [TaxBand.NONE, TaxBand.BASIC, TaxBand.HIGHER], default=TaxBand.ADDITIONAL, ) + first_extended = first_extended_scottish_threshold(rates.scotland.rates) scottish_band = select( - [income < threshold for threshold in rates.scotland.rates.thresholds[:3]], + [ + income < threshold + (gross_gift if index >= first_extended else 0) + for index, threshold in enumerate(rates.scotland.rates.thresholds[:3]) + ], [TaxBand.NONE, TaxBand.BASIC, TaxBand.HIGHER], default=TaxBand.ADDITIONAL, ) @@ -55,17 +70,25 @@ def formula_2017_04_06(person, period, parameters): def formula_2018_06_01(person, period, parameters): allowances = person("allowances", period) - ani = person("adjusted_net_income", period) + ani = person("net_income", period) rates = parameters(period).gov.hmrc.income_tax.rates scot = person("pays_scottish_income_tax", period) income = ani - allowances + gross_gift = person("gift_aid_grossed_up", period) + # s.414(2)(b) extends the limits used to determine the tax band. uk_band = select( - [income < threshold for threshold in rates.uk.thresholds[:3]], + [ + income < threshold + (gross_gift if index > 0 else 0) + for index, threshold in enumerate(rates.uk.thresholds[:3]) + ], [TaxBand.NONE, TaxBand.BASIC, TaxBand.HIGHER], default=TaxBand.ADDITIONAL, ) scottish_band = select( - [income < threshold for threshold in rates.scotland.rates.thresholds[:5]], + [ + income < threshold + (gross_gift if index >= 2 else 0) + for index, threshold in enumerate(rates.scotland.rates.thresholds[:5]) + ], [ TaxBand.NONE, TaxBand.STARTER, diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_savings_income/add_rate_savings_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_savings_income/add_rate_savings_income.py index 610b9481b2..6e09890aeb 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_savings_income/add_rate_savings_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_savings_income/add_rate_savings_income.py @@ -14,6 +14,8 @@ class add_rate_savings_income(Variable): def formula(person, period, parameters): thresholds = parameters(period).gov.hmrc.income_tax.rates.uk.thresholds + # ITA 2007 s.414(2)(b): extend both UK rate limits by the gross gift. + gross_gift = person("gift_aid_grossed_up", period) other_income = person("earned_taxable_income", period) savings_after_allowances = max_( 0, @@ -28,8 +30,10 @@ def formula(person, period, parameters): taxable_savings_start = other_income + zero_rate_savings add_rate_amount_with = clip( other_income + savings_after_allowances, - thresholds[2], + (thresholds[2] + gross_gift), inf, ) - add_rate_amount_without = clip(taxable_savings_start, thresholds[2], inf) + add_rate_amount_without = clip( + taxable_savings_start, (thresholds[2] + gross_gift), inf + ) return max_(0, add_rate_amount_with - add_rate_amount_without) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_savings_income/basic_rate_savings_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_savings_income/basic_rate_savings_income.py index cea212f46d..137055efcb 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_savings_income/basic_rate_savings_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_savings_income/basic_rate_savings_income.py @@ -14,6 +14,8 @@ class basic_rate_savings_income(Variable): def formula(person, period, parameters): thresholds = parameters(period).gov.hmrc.income_tax.rates.uk.thresholds + # ITA 2007 s.414(2)(b): extend both UK rate limits by the gross gift. + gross_gift = person("gift_aid_grossed_up", period) other_income = person("earned_taxable_income", period) savings_after_allowances = max_( 0, @@ -29,11 +31,11 @@ def formula(person, period, parameters): basic_rate_amount_with = clip( other_income + savings_after_allowances, thresholds[0], - thresholds[1], + (thresholds[1] + gross_gift), ) basic_rate_amount_without = clip( taxable_savings_start, thresholds[0], - thresholds[1], + (thresholds[1] + gross_gift), ) return max_(0, basic_rate_amount_with - basic_rate_amount_without) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_savings_income/basic_rate_savings_income_pre_starter.py b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_savings_income/basic_rate_savings_income_pre_starter.py index a3f99e40ce..544671c0e7 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_savings_income/basic_rate_savings_income_pre_starter.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_savings_income/basic_rate_savings_income_pre_starter.py @@ -14,6 +14,8 @@ class basic_rate_savings_income_pre_starter(Variable): def formula(person, period, parameters): thresholds = parameters(period).gov.hmrc.income_tax.rates.uk.thresholds + # ITA 2007 s.414(2)(b): extend both UK rate limits by the gross gift. + gross_gift = person("gift_aid_grossed_up", period) savings_income = max_( 0, person("taxable_savings_interest_income", period) @@ -23,9 +25,9 @@ def formula(person, period, parameters): basic_rate_amount_with_savings = clip( savings_income + other_income, thresholds[0], - thresholds[1], + (thresholds[1] + gross_gift), ) basic_rate_amount_without_savings = clip( - other_income, thresholds[0], thresholds[1] + other_income, thresholds[0], (thresholds[1] + gross_gift) ) return basic_rate_amount_with_savings - basic_rate_amount_without_savings diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_savings_income/higher_rate_savings_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_savings_income/higher_rate_savings_income.py index cfb7da1a31..4ff3f734cb 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_savings_income/higher_rate_savings_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_savings_income/higher_rate_savings_income.py @@ -14,6 +14,8 @@ class higher_rate_savings_income(Variable): def formula(person, period, parameters): thresholds = parameters(period).gov.hmrc.income_tax.rates.uk.thresholds + # ITA 2007 s.414(2)(b): extend both UK rate limits by the gross gift. + gross_gift = person("gift_aid_grossed_up", period) other_income = person("earned_taxable_income", period) savings_after_allowances = max_( 0, @@ -28,12 +30,12 @@ def formula(person, period, parameters): taxable_savings_start = other_income + zero_rate_savings higher_rate_amount_with = clip( other_income + savings_after_allowances, - thresholds[1], - thresholds[2], + (thresholds[1] + gross_gift), + (thresholds[2] + gross_gift), ) higher_rate_amount_without = clip( taxable_savings_start, - thresholds[1], - thresholds[2], + (thresholds[1] + gross_gift), + (thresholds[2] + gross_gift), ) return max_(0, higher_rate_amount_with - higher_rate_amount_without) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/earned_income_tax.py b/policyengine_uk/variables/gov/hmrc/income_tax/earned_income_tax.py index b3fb1187bf..30f71c00ed 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/earned_income_tax.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/earned_income_tax.py @@ -1,4 +1,8 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.income_tax import ( + calc_income_tax_with_gift_aid, + first_extended_scottish_threshold, +) class earned_income_tax(Variable): @@ -14,9 +18,16 @@ class earned_income_tax(Variable): def formula(person, period, parameters): rates = parameters(period).gov.hmrc.income_tax.rates - + income = person("earned_taxable_income", period) + gross_gift = person("gift_aid_grossed_up", period) return where( person("pays_scottish_income_tax", period), - rates.scotland.rates.calc(person("earned_taxable_income", period)), - rates.uk.calc(person("earned_taxable_income", period)), + calc_income_tax_with_gift_aid( + rates.scotland.rates, + income, + gross_gift, + first_extended_scottish_threshold(rates.scotland.rates), + len(rates.scotland.rates.thresholds), + ), + calc_income_tax_with_gift_aid(rates.uk, income, gross_gift), ) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/liability/dividend_income_tax.py b/policyengine_uk/variables/gov/hmrc/income_tax/liability/dividend_income_tax.py index e812a8f769..8a063c2652 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/liability/dividend_income_tax.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/liability/dividend_income_tax.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.income_tax import calc_income_tax_with_gift_aid class dividend_income_tax(Variable): @@ -28,10 +29,11 @@ def formula(person, period, parameters): parameters(period).gov.hmrc.income_tax.allowances.dividend_allowance, dividends_after_allowances, ) - tax_with_dividends = rates.dividends.calc( - other_income + dividends_after_allowances + gross_gift = person("gift_aid_grossed_up", period) + tax_with_dividends = calc_income_tax_with_gift_aid( + rates.dividends, other_income + dividends_after_allowances, gross_gift ) - tax_with_dividend_allowance = rates.dividends.calc( - other_income + dividend_allowance + tax_with_dividend_allowance = calc_income_tax_with_gift_aid( + rates.dividends, other_income + dividend_allowance, gross_gift ) return max_(0, tax_with_dividends - tax_with_dividend_allowance) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/liability/property_income_tax.py b/policyengine_uk/variables/gov/hmrc/income_tax/liability/property_income_tax.py index 0fff929eac..f9bc3374da 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/liability/property_income_tax.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/liability/property_income_tax.py @@ -1,4 +1,8 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.income_tax import ( + calc_income_tax_with_gift_aid, + first_extended_scottish_threshold, +) class property_income_tax(Variable): @@ -37,7 +41,7 @@ def formula(person, period, parameters): taxable_property = person("taxable_property_income", period) non_savings_after_allowances = max_( 0, - person("adjusted_net_income", period) + person("net_income", period) - person("taxable_savings_interest_income", period) - person("taxable_dividend_income", period) - person("allowances", period), @@ -50,15 +54,27 @@ def formula(person, period, parameters): # Scottish taxpayers: use Scottish rates via calc method # Tax on (other earned + property) minus tax on (other earned) - scottish_tax = rates.scotland.rates.calc( - other_earned_taxable + property_after_allowances - ) - rates.scotland.rates.calc(other_earned_taxable) + gross_gift = person("gift_aid_grossed_up", period) + first_extended = first_extended_scottish_threshold(rates.scotland.rates) + scottish_tax = calc_income_tax_with_gift_aid( + rates.scotland.rates, + other_earned_taxable + property_after_allowances, + gross_gift, + first_extended, + len(rates.scotland.rates.thresholds), + ) - calc_income_tax_with_gift_aid( + rates.scotland.rates, + other_earned_taxable, + gross_gift, + first_extended, + len(rates.scotland.rates.thresholds), + ) # rUK taxpayers: use property-specific rates property_rates = rates.property thresholds = rates.uk.thresholds - basic_upper = thresholds[1] # Higher rate threshold - higher_upper = thresholds[2] # Additional rate threshold + basic_upper = thresholds[1] + gross_gift + higher_upper = thresholds[2] + gross_gift # Property income in basic rate band basic_rate_space = max_(0, basic_upper - other_earned_taxable) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/net_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/net_income.py new file mode 100644 index 0000000000..d10575ae77 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/net_income.py @@ -0,0 +1,37 @@ +from policyengine_uk.model_api import * +from policyengine_uk.utils.supplied_inputs import supplied_input + + +class net_income(Variable): + value_type = float + entity = Person + label = "Income before personal allowances and Gift Aid adjustments" + documentation = ( + "The model's taxable income components before the Gift Aid adjustment " + "to adjusted net income. Gift Aid extends rate limits and reduces " + "adjusted net income, but does not reduce the income charged to tax." + ) + definition_period = YEAR + reference = dict( + title="Income Tax Act 2007, ss. 23 and 58", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/58", + ) + unit = GBP + + def formula(person, period, parameters): + # An explicitly supplied ANI already includes the s.58 adjustment. + # Restore it for the taxable base, preserving direct ANI inputs at + # zero Gift Aid. Ordinary cases compute the base directly to avoid + # subtracting and re-adding a large gift through a float32 ANI cache. + supplied_ani = supplied_input(person, "adjusted_net_income", period) + if supplied_ani is not None: + return supplied_ani + person("gift_aid_grossed_up", period) + components = parameters( + period + ).gov.hmrc.income_tax.adjusted_net_income_components + income = add(person, period, components) + if parameters( + period + ).gov.contrib.ubi_center.basic_income.interactions.include_in_taxable_income: + income += person("basic_income", period) + return max_(0, income) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/property_finance_costs_relieved.py b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/property_finance_costs_relieved.py index 1ca09de9fc..b0553c1c2c 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/property_finance_costs_relieved.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/property_finance_costs_relieved.py @@ -46,7 +46,7 @@ def formula(person, period, parameters): ) adjusted_total_income = max_( 0, - person("adjusted_net_income", period) + person("net_income", period) - person("taxable_savings_interest_income", period) - person("taxable_dividend_income", period) - step_2_reliefs diff --git a/policyengine_uk/variables/gov/hmrc/student_loans/plan_2_interest_rate.py b/policyengine_uk/variables/gov/hmrc/student_loans/plan_2_interest_rate.py index fe52b5f26a..ec5a2f2b5e 100644 --- a/policyengine_uk/variables/gov/hmrc/student_loans/plan_2_interest_rate.py +++ b/policyengine_uk/variables/gov/hmrc/student_loans/plan_2_interest_rate.py @@ -16,7 +16,7 @@ class plan_2_interest_rate(Variable): reference = "https://www.legislation.gov.uk/uksi/2012/1309/regulation/10" def formula(person, period, parameters): - income = person("adjusted_net_income", period) + income = person("net_income", period) p = parameters(period).gov.hmrc.student_loans rpi = parameters(period).gov.economic_assumptions.yoy_growth.obr.rpi diff --git a/policyengine_uk/variables/gov/hmrc/student_loans/student_loan_repayment.py b/policyengine_uk/variables/gov/hmrc/student_loans/student_loan_repayment.py index a7590eb33a..f086a65126 100644 --- a/policyengine_uk/variables/gov/hmrc/student_loans/student_loan_repayment.py +++ b/policyengine_uk/variables/gov/hmrc/student_loans/student_loan_repayment.py @@ -19,7 +19,7 @@ class student_loan_repayment(Variable): def formula(person, period, parameters): plan = person("student_loan_plan", period) - income = person("adjusted_net_income", period) + income = person("net_income", period) balance = max_(0, person("student_loan_balance", period)) p = parameters(period).gov.hmrc.student_loans From 64dff7b228ac547ae5b83565194dfeec31d8ee3a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 09:24:00 -0400 Subject: [PATCH 2/6] Keep a supplied adjusted net income in the marriage-neutral reform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the Gift Aid fix. The reform's net_income override read the income components only, so a supplied adjusted_net_income no longer set the taxable base: a married couple with supplied ANI of £50,000 and £0 paid no tax instead of £7,486. The override now returns a supplied ANI plus the grossed-up gift, as the baseline net_income does. Also from the review: - a helper test against each scale shape callers pass: a UK extra bracket at a distinct rate, and the three- and six-rate Scottish scales; - 2017-18 Scottish YAML cases on the three-rate scale; - a frozen positive-gift CGT case (unchanged from main); - corrected descriptions of the CGT test oracle and the maintenance loan income measure. Co-Authored-By: Claude Opus 5.5 --- .../reforms/cps/marriage_tax_reforms.py | 8 ++ .../gov/hmrc/income_tax/gift_aid.yaml | 93 +++++++++++++++++++ .../reforms/cps/marriage_tax_reforms.yaml | 66 +++++++++++++ .../test_capital_gains_tax_components.py | 7 +- policyengine_uk/tests/test_gift_aid.py | 69 ++++++++++++++ .../maintenance_loan_household_income.py | 2 +- 6 files changed, 242 insertions(+), 3 deletions(-) diff --git a/policyengine_uk/reforms/cps/marriage_tax_reforms.py b/policyengine_uk/reforms/cps/marriage_tax_reforms.py index 73efa66e79..32cedd3f82 100644 --- a/policyengine_uk/reforms/cps/marriage_tax_reforms.py +++ b/policyengine_uk/reforms/cps/marriage_tax_reforms.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.supplied_inputs import supplied_input from typing import Union, Optional @@ -163,6 +164,13 @@ class net_income(Variable): value_type = float def formula(person, period, parameters): + # A supplied adjusted net income already includes the s.58 Gift + # Aid step, and it took precedence over this reform's split before + # the reform moved to net_income. Keep that: restore the gift for + # the taxable base, as the baseline net_income does. + supplied_ani = supplied_input(person, "adjusted_net_income", period) + if supplied_ani is not None: + return supplied_ani + person("gift_aid_grossed_up", period) income = person("unadjusted_net_income", period) # Split between the spouses or civil partners only, never their # children. diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/gift_aid.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/gift_aid.yaml index 43124e502e..e3ca92829f 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/gift_aid.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/gift_aid.yaml @@ -302,3 +302,96 @@ adjusted_net_income: [50_000, 50_000] earned_taxable_income: [37_430, 37_430] income_tax: [7_486, 7_486] + +# Before 2018-19 the Scottish scale had three rates (basic, higher, additional), +# so s.414(2)(b) raises its first limit, not the second. +- name: Gift Aid raises the three-rate Scottish basic rate limit in 2017-18 + period: 2017 + absolute_error_margin: 0.01 + input: + people: + donor: + age: 40 + employment_income: 60_000 + gift_aid: 800 + control: + age: 40 + employment_income: 60_000 + benunits: + donor_unit: + members: [donor] + control_unit: + members: [control] + households: + donor_household: + members: [donor] + region: SCOTLAND + control_household: + members: [control] + region: SCOTLAND + output: + # Taxable £48,500 with a £31,500 basic rate limit; the gift raises it to + # £32,500, moving £1,000 from 40% to 20%. + income_tax: [12_900, 13_100] + +- name: Gift Aid raises the three-rate Scottish higher rate limit in 2017-18 + period: 2017 + absolute_error_margin: 0.01 + input: + people: + donor: + age: 40 + employment_income: 160_000 + gift_aid: 800 + control: + age: 40 + employment_income: 160_000 + benunits: + donor_unit: + members: [donor] + control_unit: + members: [control] + households: + donor_household: + members: [donor] + region: SCOTLAND + control_household: + members: [control] + region: SCOTLAND + output: + # No personal allowance either way. £1,000 moves from 40% to 20% (£200) + # and £1,000 from 45% to 40% (£50). + income_tax: [57_950, 58_200] + +- name: Gift Aid leaves capital gains tax as it was, with its band extension + period: 2026 + absolute_error_margin: 0.01 + input: + people: + donor: + age: 40 + employment_income: 45_000 + capital_gains: 20_000 + gift_aid: 800 + control: + age: 40 + employment_income: 45_000 + capital_gains: 20_000 + benunits: + donor_unit: + members: [donor] + control_unit: + members: [control] + households: + donor_household: + members: [donor] + region: NORTH_WEST + control_household: + members: [control] + region: NORTH_WEST + output: + # Frozen from main a3752304e, which gave the same CGT: £17,000 taxable + # gains above the £3,000 exempt amount. With the gift, £6,270 of basic + # band remains (£5,270 without), at 18%, and the rest at 24%. + capital_gains_tax: [3_703.80, 3_763.80] + income_tax: [6_486, 6_486] 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..7dd1c38ab0 100644 --- a/policyengine_uk/tests/policy/reforms/cps/marriage_tax_reforms.yaml +++ b/policyengine_uk/tests/policy/reforms/cps/marriage_tax_reforms.yaml @@ -185,3 +185,69 @@ members: [high_earner, low_earner, student] output: adjusted_net_income: [35_000, 35_000, 8_000] + +# A supplied adjusted net income took precedence over this reform before +# #2253 moved its override to net_income; it still sets the taxable base. +- name: Marriage-neutral IT — a supplied adjusted net income still sets the taxable base + period: 2026 + absolute_error_margin: 1 + reforms: + - policyengine_uk.reforms.cps.marriage_tax_reforms.marriage_neutral_it_reform + input: + people: + earner: + age: 40 + adjusted_net_income: 50_000 + personal_allowance: 12_570 + marriage_allowance: 0 + spouse: + age: 40 + adjusted_net_income: 0 + personal_allowance: 12_570 + marriage_allowance: 0 + benunits: + benunit: + members: [earner, spouse] + is_married: true + households: + household: + members: [earner, spouse] + region: NORTH_WEST + output: + net_income: [50_000, 0] + earned_taxable_income: [37_430, 0] + income_tax: [7_486, 0] + +- name: Marriage-neutral IT — a supplied adjusted net income gets the grossed-up gift back + period: 2026 + absolute_error_margin: 1 + reforms: + - policyengine_uk.reforms.cps.marriage_tax_reforms.marriage_neutral_it_reform + input: + people: + earner: + age: 40 + adjusted_net_income: 49_000 + gift_aid: 800 + personal_allowance: 12_570 + marriage_allowance: 0 + spouse: + age: 40 + adjusted_net_income: 0 + personal_allowance: 12_570 + marriage_allowance: 0 + benunits: + benunit: + members: [earner, spouse] + is_married: true + households: + household: + members: [earner, spouse] + region: NORTH_WEST + output: + # ANI already has the £1,000 grossed-up gift deducted (s.58), so net + # income is £50,000; the raised basic rate limit gives no relief here. + net_income: [50_000, 0] + adjusted_net_income: [49_000, 0] + earned_taxable_income: [37_430, 0] + income_tax: [7_486, 0] diff --git a/policyengine_uk/tests/test_capital_gains_tax_components.py b/policyengine_uk/tests/test_capital_gains_tax_components.py index 551d358315..23d5687c94 100644 --- a/policyengine_uk/tests/test_capital_gains_tax_components.py +++ b/policyengine_uk/tests/test_capital_gains_tax_components.py @@ -23,8 +23,11 @@ def legacy_capital_gains_tax(sim, year): """The single-schedule formula as it stood before #1858, on the same arrays. - Copied operation for operation so that any difference in the result is a - difference in the new formula, not in the comparison. + Its schedule allocation is copied operation for operation, so a difference + there is a difference in the new formula. Its income and allowance inputs + follow the current model (#2253 moved Gift Aid out of the allowances), so + it checks how gains are allocated to the schedules, not equivalence with + any earlier treatment of Gift Aid. """ hmrc = sim.tax_benefit_system.parameters(str(year)).gov.hmrc cgt = hmrc.cgt diff --git a/policyengine_uk/tests/test_gift_aid.py b/policyengine_uk/tests/test_gift_aid.py index 5adfb43b60..248b3a5b17 100644 --- a/policyengine_uk/tests/test_gift_aid.py +++ b/policyengine_uk/tests/test_gift_aid.py @@ -407,3 +407,72 @@ def test_explicit_adjusted_net_income_preserves_tax_base(ani, net_gift): assert supplied.calculate("net_income", YEAR)[0] == 50_000 assert supplied.calculate("earned_taxable_income", YEAR)[0] == 37_430 assert supplied.calculate("income_tax", YEAR)[0] == 7_486 + + +# The helper on its own, against each scale shape a caller passes it. Raising +# a threshold must equal recomputing the scale with that threshold raised; +# thresholds outside the range must not move, which a zero rate step (the +# model's extra UK bracket repeats 45%) would not reveal. +HELPER_SCALES = { + "UK, with an extra bracket at a distinct rate": ( + [0, 37_700, 125_140, 10_000_000], + [0.20, 0.40, 0.45, 0.50], + None, + ), + "Scotland, three rates (before 2018-19)": ( + [0, 31_500, 150_000], + [0.20, 0.40, 0.45], + "scottish", + ), + "Scotland, six rates": ( + [0, 3_967, 16_956, 31_092, 62_430, 112_570], + [0.19, 0.20, 0.21, 0.42, 0.45, 0.48], + "scottish", + ), +} + + +def _scale(thresholds, rates): + from policyengine_core.taxscales import MarginalRateTaxScale + + scale = MarginalRateTaxScale() + for threshold, rate in zip(thresholds, rates): + scale.add_bracket(threshold, rate) + return scale + + +@pytest.mark.parametrize("name", list(HELPER_SCALES)) +@settings(max_examples=200, deadline=None, derandomize=True) +@given( + income=st.floats(0, 12_000_000, allow_nan=False, allow_infinity=False), + gross_gift=st.floats(0, 2_000_000, allow_nan=False, allow_infinity=False), +) +def test_helper_equals_the_scale_with_its_limits_raised(name, income, gross_gift): + from policyengine_uk.utils.income_tax import ( + calc_income_tax_with_gift_aid, + first_extended_scottish_threshold, + ) + + thresholds, rates, kind = HELPER_SCALES[name] + scale = _scale(thresholds, rates) + if kind == "scottish": + first = first_extended_scottish_threshold(scale) + last = len(scale.thresholds) + # Three rates: raise the basic rate limit and above. Six: keep the + # starter limit and raise the basic rate's upper limit and above. + assert first == (1 if len(thresholds) == 3 else 2) + tax = calc_income_tax_with_gift_aid( + scale, np.array([income]), np.array([gross_gift]), first, last + ) + else: + # UK default: the basic and higher rate limits only. + first, last = 1, 3 + tax = calc_income_tax_with_gift_aid( + scale, np.array([income]), np.array([gross_gift]) + ) + raised = [ + threshold + gross_gift if first <= index < last else threshold + for index, threshold in enumerate(thresholds) + ] + expected = _scale(raised, rates).calc(np.array([income])) + assert tax[0] == pytest.approx(expected[0], rel=1e-9, abs=1e-6) diff --git a/policyengine_uk/variables/gov/dfe/maintenance_loans/maintenance_loan_household_income.py b/policyengine_uk/variables/gov/dfe/maintenance_loans/maintenance_loan_household_income.py index 816c2e2296..9c1e621f65 100644 --- a/policyengine_uk/variables/gov/dfe/maintenance_loans/maintenance_loan_household_income.py +++ b/policyengine_uk/variables/gov/dfe/maintenance_loans/maintenance_loan_household_income.py @@ -10,7 +10,7 @@ class maintenance_loan_household_income(Variable): "Student Finance England-style household income for maintenance loan assessment. " "This can be set explicitly in simulations. By default, the model uses a proxy: " "dependent students use sponsor income from a selected older household benefit unit, " - "plus their own benefit-unit income; otherwise the model uses benefit-unit adjusted net income. " + "plus their own benefit-unit income; otherwise the model uses benefit-unit net income before Gift Aid adjustments. " "A student is dependent only if none of the independence tests the model can see applies " "(Education (Student Support) Regulations 2011 Sch 4 para 2(1)): aged under 25 on the first " "day of the academic year (para 2(1)(a)), not in a couple (a proxy for para 2(1)(b)), and " From 09807343489a76225fbd0580303865d6906c9a8a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 05:10:46 -0400 Subject: [PATCH 3/6] Test Gift Aid's raised limits on the property-first band order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With #2352, savings and dividends sit above property income (ITA 2007 s. 16). Two YAML cases check that the s. 414(2)(b) extension applies on that order: £40,000 of property income with £20,000 of dividends (dividend tax £4,278.75 against £4,528.75) or £20,000 of savings (savings tax £5,646 against £5,846). Co-Authored-By: Claude Opus 5.5 --- .../gov/hmrc/income_tax/gift_aid.yaml | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/gift_aid.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/gift_aid.yaml index e3ca92829f..39897c8930 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/gift_aid.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/gift_aid.yaml @@ -395,3 +395,71 @@ # band remains (£5,270 without), at 18%, and the rest at 24%. capital_gains_tax: [3_703.80, 3_763.80] income_tax: [6_486, 6_486] + +# With #2352, savings and dividends sit above property income (ITA 2007 s. 16), +# and the raised limits must apply on that order. +- name: Gift Aid raises the basic rate limit for dividends above property income + period: 2026 + absolute_error_margin: 0.01 + input: + people: + donor: + age: 40 + property_income: 40_000 + dividend_income: 20_000 + gift_aid: 800 + control: + age: 40 + property_income: 40_000 + dividend_income: 20_000 + benunits: + donor_unit: + members: [donor] + control_unit: + members: [control] + households: + donor_household: + members: [donor] + region: NORTH_WEST + control_household: + members: [control] + region: NORTH_WEST + output: + # Non-savings taxable income £27,430. Without the gift, £500 allowance and + # £9,770 at 10.75% fill the band and £9,730 is at 35.75% (#2352's worked + # example). The gift moves £1,000 from 35.75% to 10.75%: £250. + dividend_income_tax: [4_278.75, 4_528.75] + property_income_tax: [5_486, 5_486] + +- name: Gift Aid raises the basic rate limit for savings above property income + period: 2026 + absolute_error_margin: 0.01 + input: + people: + donor: + age: 40 + property_income: 40_000 + savings_interest_income: 20_000 + gift_aid: 800 + control: + age: 40 + property_income: 40_000 + savings_interest_income: 20_000 + benunits: + donor_unit: + members: [donor] + control_unit: + members: [control] + households: + donor_household: + members: [donor] + region: NORTH_WEST + control_household: + members: [control] + region: NORTH_WEST + output: + # Both still have higher-rate income, so a £500 savings allowance. Without + # the gift, £9,770 at 20% and £9,730 at 40%; the gift moves £1,000 from + # 40% to 20%: £200. + savings_allowance: [500, 500] + savings_income_tax: [5_646, 5_846] From 3bf52c5c37479ebf91062c75f78e4916ad420d3c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 05:28:44 -0400 Subject: [PATCH 4/6] Re-freeze the zero-gift test against main after #2352 The frozen expectations predated #2352 (savings and dividends above property income) and main's Scottish personal savings allowance on the UK bands, so the mixed-income rows and one allowance changed on main. Regenerated from main 3381500f4. With no gift, the branch matches main byte for byte on all 21 variables and the tax band. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/tests/test_gift_aid.py | 67 ++++++++++---------------- 1 file changed, 25 insertions(+), 42 deletions(-) diff --git a/policyengine_uk/tests/test_gift_aid.py b/policyengine_uk/tests/test_gift_aid.py index 248b3a5b17..27d62c9d4c 100644 --- a/policyengine_uk/tests/test_gift_aid.py +++ b/policyengine_uk/tests/test_gift_aid.py @@ -270,6 +270,10 @@ def test_explicit_zero_gift_matches_omitted_input(sim, omitted_sim, drawn): def test_zero_gift_preserves_main_outputs_bit_for_bit(): """Freeze main's intermediate and final outputs, including mixed income. + Frozen from main at 3381500f4 (after #2352 put savings and dividends + above property income, and the Scottish personal savings allowance + moved to the UK bands). Re-freeze from main when main moves these. + Gift Aid's zero-input compatibility also applies to inherited baseline choices about property stacking, the Scottish schedule and taxpayer bands. Exact float32 expectations catch changes hidden by a tax-level tolerance. @@ -289,42 +293,28 @@ def test_zero_gift_preserves_main_outputs_bit_for_bit(): ) inputs["people"][f"p{i}"]["capital_gains"] = {YEAR: 40_000} snapshot = Simulation(situation=inputs) - allowance = [12_570, 12_570, 0, 12_570, 12_570, 12_070] * 2 expected = { "adjusted_net_income": [30_000, 60_000, 150_000, 69_000, 40_000, 101_000] * 2, - "personal_allowance": allowance, - "allowances": allowance, - "unused_personal_allowance": [0] * 12, + "personal_allowance": [12_570, 12_570, 0, 12_570, 12_570, 12_070] * 2, + "allowances": [12_570, 12_570, 0, 12_570, 12_570, 12_070] * 2, + "unused_personal_allowance": [0, 0, 0, 0, 0, 0] * 2, "earned_taxable_income": [17_430, 47_430, 150_000, 17_430, 0, 88_930] * 2, "received_allowances_earned_income": [12_570, 12_570, 0, 12_570, 10_000, 12_070] * 2, - "received_allowances_savings_income": [0, 0, 0, 0, 2_570, 0] * 2, - "received_allowances_dividend_income": [0] * 12, + "received_allowances_savings_income": [0, 0, 0, 0, 2570, 0] * 2, + "received_allowances_dividend_income": [0, 0, 0, 0, 0, 0] * 2, "basic_rate_earned_income": [17_430, 37_700, 37_700, 17_430, 0, 37_700] * 2, - "higher_rate_earned_income": [0, 9_730, 87_440, 0, 0, 51_230] * 2, + "higher_rate_earned_income": [0, 9730, 87_440, 0, 0, 51_230] * 2, "add_rate_earned_income": [0, 0, 24_860, 0, 0, 0] * 2, - "basic_rate_savings_income": [0, 0, 0, 6_500, 11_430, 0] * 2, - "higher_rate_savings_income": [0] * 12, - "add_rate_savings_income": [0] * 12, - "savings_allowance": [ - 1_000, - 500, - 0, - 500, - 1_000, - 500, - 1_000, - 500, - 0, - 500, - 1_000, - 0, - ], + "basic_rate_savings_income": [0, 0, 0, 10_770, 11_430, 0] * 2, + "higher_rate_savings_income": [0, 0, 0, 730, 0, 0] * 2, + "add_rate_savings_income": [0, 0, 0, 0, 0, 0] * 2, + "savings_allowance": [1000, 500, 0, 500, 1000, 500] * 2, "earned_income_tax": [ - 3_486, + 3486, 11_432, 53_703, - 3_486, + 3486, 0, 28_032, 3451.070068359375, @@ -334,31 +324,24 @@ def test_zero_gift_preserves_main_outputs_bit_for_bit(): 0, 31407.05078125, ], - "savings_income_tax": [0, 0, 0, 1_300, 2_286, 0] * 2, - "dividend_income_tax": [0, 0, 0, 4_313.75, 1_021.25, 0] * 2, - "property_income_tax": [0, 0, 0, 1_800, 0, 0, 0, 0, 0, 1_890, 0, 0], + "savings_income_tax": [0, 0, 0, 2446, 2286, 0] * 2, + "dividend_income_tax": [0, 0, 0, 6256.25, 1021.25, 0] * 2, + "property_income_tax": [0, 0, 0, 1800, 0, 0, 0, 0, 0, 1890, 0, 0], "income_tax": [ - 3_486, + 3486, 11_432, 53_703, - 10_899.75, - 3_307.25, + 13988.25, + 3307.25, 28_032, 3451.070068359375, 13182.0498046875, 60011.44921875, - 10954.8203125, - 3_307.25, + 14043.3203125, + 3307.25, 31407.05078125, ], - "capital_gains_tax": [ - 7663.7998046875, - 8_880, - 8_880, - 8_880, - 8263.7998046875, - 8_880, - ] + "capital_gains_tax": [7663.7998046875, 8880, 8880, 8880, 8263.7998046875, 8880] * 2, } for variable, values in expected.items(): From f5418da178071450166a684564654b5e936a75c3 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 05:50:44 -0400 Subject: [PATCH 5/6] Describe the Gift Aid limits on the property-first band order Round-3 review nit. The design note listed two limitations the #2352 merge removed: savings and dividends now stack above property income, and the Personal Savings Allowance tests net income against the UK limits raised by the gift. It now describes that behaviour instead. Co-Authored-By: Claude Opus 5.5 --- docs/engineering/gift-aid.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/engineering/gift-aid.md b/docs/engineering/gift-aid.md index b64ef562c3..cdaf506147 100644 --- a/docs/engineering/gift-aid.md +++ b/docs/engineering/gift-aid.md @@ -37,21 +37,21 @@ adjusted net income. The Personal Allowance formula does not deduct Gift Aid a second time. Capital gains tax uses `net_income`, retains its gross Gift Aid band extension, and no longer removes the net gift from `allowances`. +Savings and dividends stack above all non-savings income, property income +included ([ITA 2007 s. 16](https://www.legislation.gov.uk/ukpga/2007/3/section/16); +#2352). Property income, savings and dividends therefore fill the bands in that +order, each against the extended limits. `non_savings_income_after_allowances`, +the base they stack on, starts from `net_income`, so the gift raises the limits +without also shrinking the income beneath them. The Personal Savings Allowance +compares net income less allowances with the UK basic and higher rate limits, +both extended by the gross gift ([s. 12B](https://www.legislation.gov.uk/ukpga/2007/3/section/12B), +including s. 12B(8) for Scottish taxpayers, with s. 414(2)(b)). + ## Existing limitations Zero-gift calculations retain their existing behavior. Several inherited limitations remain relevant when interpreting mixed-income results: -- Savings and dividend stacking currently omits property income from the - income beneath those slices. Gift Aid extends the limits of those existing - stacks. [ITA 2007 s. 16](https://www.legislation.gov.uk/ukpga/2007/3/section/16) - requires savings and dividends above other income; repairing property - occupancy separately would also change zero-gift results. -- The Personal Savings Allowance follows the model's `tax_band` enum, including - its Scottish band classification. [s. 12B(8)](https://www.legislation.gov.uk/ukpga/2007/3/section/12B) - instead tests Scottish non-savings income against the counterfactual UK - higher and additional rates. Extending the existing band tests preserves - that limitation. - The Scottish top-rate threshold parameter is £112,570 of income after allowances for 2024 onwards. The [HMRC rates and allowances table](https://www.gov.uk/government/publications/rates-and-allowances-income-tax/income-tax-rates-and-allowances-current-and-past) From 3ef3793bd7b2aaa675aad9c35146e5be59ab9018 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 06:10:51 -0400 Subject: [PATCH 6/6] Name both stacking bases in the Gift Aid design note Round-4 review nit: property income is taxed on top of non_savings_income_after_allowances less property after allowances, and savings and dividends on top of non_savings_taxable_income. Both derive from net_income; they differ only with Marriage Allowance (#1947). Co-Authored-By: Claude Opus 5.5 --- docs/engineering/gift-aid.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/engineering/gift-aid.md b/docs/engineering/gift-aid.md index cdaf506147..20ac92abe7 100644 --- a/docs/engineering/gift-aid.md +++ b/docs/engineering/gift-aid.md @@ -40,9 +40,13 @@ band extension, and no longer removes the net gift from `allowances`. Savings and dividends stack above all non-savings income, property income included ([ITA 2007 s. 16](https://www.legislation.gov.uk/ukpga/2007/3/section/16); #2352). Property income, savings and dividends therefore fill the bands in that -order, each against the extended limits. `non_savings_income_after_allowances`, -the base they stack on, starts from `net_income`, so the gift raises the limits -without also shrinking the income beneath them. The Personal Savings Allowance +order, each against the extended limits. Property income is taxed on top of +`non_savings_income_after_allowances` less property income after allowances, +and savings and dividends on top of `non_savings_taxable_income` +(`earned_taxable_income` plus property income after allowances). Both bases +derive from `net_income`, so the gift raises the limits without also shrinking +the income beneath them. They differ only when Marriage Allowance applies, +which `earned_taxable_income` subtracts (#1947). The Personal Savings Allowance compares net income less allowances with the UK basic and higher rate limits, both extended by the gross gift ([s. 12B](https://www.legislation.gov.uk/ukpga/2007/3/section/12B), including s. 12B(8) for Scottish taxpayers, with s. 414(2)(b)).