From 5f0cd0b1c1102c982b295df3f64b2d4ecf5f9dff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:33:45 +0100 Subject: [PATCH 1/4] Model the finance-cost tax reduction and stop the property allowance stacking on expenses Adds three optional landlord inputs (property_rental_income, property_finance_costs, property_finance_costs_brought_forward) and models: - the property allowance as ITTOIA 2005 Part 6A Chapter 2 gives it: full relief for receipts within the allowance, partial relief replacing expenses, never on top of expenses already netted out of property_income (#1900), from 2017-18; - the s. 274A/274AA tax reduction for residential finance costs: the property basic rate (basic rate before 2027-28, FA 2026 Sch 1 para 40, Scottish taxpayers included) on the lowest of the relievable costs, the property profits and adjusted total income, limited to the Step 5 tax left, with the carry-forward; - the s. 783BL choice between the two, made by comparing income tax in two branches only for people with a real choice; - the s. 272A phase-in as a parameter (zero restores full deductibility); - UC no longer sets the reduction against the tax on earnings; tax credits deduct finance costs from property income (SI 2002/2006 reg 11(2A)). Fixes #2170. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2170.added.md | 1 + changelog.d/2170.fixed.md | 1 + docs/book/programs/gov/hmrc/income-tax.ipynb | 8 +- policyengine_uk/data/uprating_indices.yaml | 3 + .../allowances/property_allowance.yaml | 9 +- .../income_tax/income_tax_subtractions.yaml | 3 + .../disallowed_share.yaml | 36 +++ .../income/uc_earnings_deductions.yaml | 39 ++- .../gov/dwp/tax_credits_property_income.yaml | 33 +++ .../gov/hmrc/income_tax/allowances.yaml | 11 +- .../allowances/is_allowance_eligible.yaml | 1 + .../property_allowance_deduction.yaml | 125 +++++++++ .../bases/taxable_property_income.yaml | 8 +- .../reliefs/property_finance_cost_relief.yaml | 265 ++++++++++++++++++ .../income_source_tax_rates.yaml | 7 + .../tests/test_property_income_properties.py | 263 +++++++++++++++++ .../gov/dwp/tax_credits_applicable_income.py | 2 +- .../gov/dwp/tax_credits_property_income.py | 24 ++ .../income/uc_income_tax_on_earnings.py | 15 +- .../property_allowance_deduction.py | 36 ++- .../property_allowance_deduction_if_used.py | 62 ++++ .../allowances/uses_property_allowance.py | 103 +++++++ .../deductible_property_finance_costs.py | 22 ++ .../bases/property_allowable_expenses.py | 27 ++ .../bases/taxable_property_income.py | 20 +- .../gov/hmrc/income_tax/other_tax_credits.py | 3 +- .../reliefs/property_finance_cost_relief.py | 57 ++++ .../property_finance_costs_carried_forward.py | 28 ++ .../property_finance_costs_relievable.py | 23 ++ .../property_finance_costs_relieved.py | 47 ++++ .../variables/input/property_finance_costs.py | 30 ++ .../property_finance_costs_brought_forward.py | 23 ++ .../variables/input/property_income.py | 23 +- .../variables/input/property_rental_income.py | 32 +++ 34 files changed, 1362 insertions(+), 28 deletions(-) create mode 100644 changelog.d/2170.added.md create mode 100644 changelog.d/2170.fixed.md create mode 100644 policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/property_finance_costs/disallowed_share.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits_property_income.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/property_allowance_deduction.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/property_finance_cost_relief.yaml create mode 100644 policyengine_uk/tests/test_property_income_properties.py create mode 100644 policyengine_uk/variables/gov/dwp/tax_credits_property_income.py create mode 100644 policyengine_uk/variables/gov/hmrc/income_tax/allowances/property_allowance_deduction_if_used.py create mode 100644 policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_property_allowance.py create mode 100644 policyengine_uk/variables/gov/hmrc/income_tax/bases/deductible_property_finance_costs.py create mode 100644 policyengine_uk/variables/gov/hmrc/income_tax/bases/property_allowable_expenses.py create mode 100644 policyengine_uk/variables/gov/hmrc/income_tax/reliefs/property_finance_cost_relief.py create mode 100644 policyengine_uk/variables/gov/hmrc/income_tax/reliefs/property_finance_costs_carried_forward.py create mode 100644 policyengine_uk/variables/gov/hmrc/income_tax/reliefs/property_finance_costs_relievable.py create mode 100644 policyengine_uk/variables/gov/hmrc/income_tax/reliefs/property_finance_costs_relieved.py create mode 100644 policyengine_uk/variables/input/property_finance_costs.py create mode 100644 policyengine_uk/variables/input/property_finance_costs_brought_forward.py create mode 100644 policyengine_uk/variables/input/property_rental_income.py diff --git a/changelog.d/2170.added.md b/changelog.d/2170.added.md new file mode 100644 index 0000000000..655f491092 --- /dev/null +++ b/changelog.d/2170.added.md @@ -0,0 +1 @@ +- Added landlords' residential finance-cost tax reduction (ITTOIA 2005 ss. 272A, 274A and 274AA) from three new optional inputs: `property_rental_income` (gross receipts), `property_finance_costs` and `property_finance_costs_brought_forward`. The reduction is the property basic rate (the basic rate before 2027-28) on the lowest of the relievable costs, the property profits and adjusted total income, is limited to the tax left at Step 5, and reports the unrelieved costs as `property_finance_costs_carried_forward`. A new `disallowed_share` parameter carries the 2017-18 to 2020-21 phase-in, and setting it to nil restores full deductibility. Universal Credit does not set the reduction against the tax on earnings, and tax credits deduct the finance costs from property income (SI 2002/2006 reg. 11(2A)). diff --git a/changelog.d/2170.fixed.md b/changelog.d/2170.fixed.md new file mode 100644 index 0000000000..2eaac93974 --- /dev/null +++ b/changelog.d/2170.fixed.md @@ -0,0 +1 @@ +- Stopped the property allowance being deducted on top of expenses already netted out of `property_income` (ITTOIA 2005 Part 6A Chapter 2). It now gives full relief only where profit, or receipts when known, are within the allowance, and partial relief when `property_rental_income` shows that expenses fall short of it. A landlord takes the allowance or the finance-cost reduction, whichever leaves less income tax. The allowance now starts in 2017-18. diff --git a/docs/book/programs/gov/hmrc/income-tax.ipynb b/docs/book/programs/gov/hmrc/income-tax.ipynb index f523dfb995..05845e6c0e 100644 --- a/docs/book/programs/gov/hmrc/income-tax.ipynb +++ b/docs/book/programs/gov/hmrc/income-tax.ipynb @@ -448,7 +448,7 @@ " Property Allowance\n", " Property Allowance\n", " 1000.0\n", - " 2005-04-01\n", + " 2017-04-06\n", " currency-GBP\n", " \n", " \n", @@ -483,7 +483,7 @@ "15 Personal Savings Allowance Basic Threshold 1000.0 2005-04-01 \n", "16 Personal Savings Allowance Higher Threshold 500.0 2005-04-01 \n", "17 Dividend Allowance Dividend Allowance 2000.0 2015-06-05 \n", - "18 Property Allowance Property Allowance 1000.0 2005-04-01 \n", + "18 Property Allowance Property Allowance 1000.0 2017-04-06 \n", "19 Trading Allowance Trading Allowance 1000.0 2017-04-06 \n", "\n", " Unit \n", @@ -638,8 +638,8 @@ "property_allowance_data = {\n", " \"Attribute\": [\"Property Allowance\"],\n", " \"Type\": [\"Property Allowance\"],\n", - " \"Value\": [property_allowance.values_list[-1].value],\n", - " \"Effective Date\": [property_allowance.values_list[-1].instant_str],\n", + " \"Value\": [property_allowance.values_list[0].value],\n", + " \"Effective Date\": [property_allowance.values_list[0].instant_str],\n", " \"Unit\": [property_allowance.metadata[\"unit\"]],\n", "}\n", "\n", diff --git a/policyengine_uk/data/uprating_indices.yaml b/policyengine_uk/data/uprating_indices.yaml index 578e74813d..cd5b2f2382 100644 --- a/policyengine_uk/data/uprating_indices.yaml +++ b/policyengine_uk/data/uprating_indices.yaml @@ -84,6 +84,8 @@ gov.economic_assumptions.yoy_growth.obr.petrol_spending_litre_proxy: - petrol_spending gov.economic_assumptions.yoy_growth.obr.mortgage_interest: - mortgage_interest_repayment +- property_finance_costs +- property_finance_costs_brought_forward gov.economic_assumptions.yoy_growth.obr.per_capita.gdp: - capital_gains - capital_gains_before_response @@ -110,6 +112,7 @@ gov.economic_assumptions.yoy_growth.obr.per_capita.gdp: - private_pension_wealth - private_transfer_income - property_income +- property_rental_income - savings - savings_interest_income - sublet_income diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/property_allowance.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/property_allowance.yaml index 64170ed238..c5184fa207 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/property_allowance.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/property_allowance.yaml @@ -1,8 +1,15 @@ -description: Amount of income from property untaxed per year. +description: Amount of property allowance, which relieves property receipts in place of deducting actual expenses. values: 2005-04-01: + value: 0 + reference: + - title: Finance (No. 2) Act 2017 Sch. 3 para. 13 (allowance has effect from 2017-18) + href: https://www.legislation.gov.uk/ukpga/2017/32/schedule/3/paragraph/13 + 2017-04-06: value: 1_000 reference: + - title: Income Tax (Trading and Other Income) Act 2005 s. 783BD + href: https://www.legislation.gov.uk/ukpga/2005/5/section/783BD - title: Finance (No. 2) Act 2017 Sch. 3 href: https://www.legislation.gov.uk/ukpga/2017/32/schedule/3 metadata: diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/income_tax_subtractions.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/income_tax_subtractions.yaml index b516d33e30..e6bc103f75 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/income_tax_subtractions.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/income_tax_subtractions.yaml @@ -5,7 +5,10 @@ metadata: reference: - title: Income Tax Act 2007 s. 23 href: https://www.legislation.gov.uk/ukpga/2007/3/section/23 + - title: Income Tax Act 2007 s. 26(1)(a) + href: https://www.legislation.gov.uk/ukpga/2007/3/section/26 values: 0000-01-01: - capped_mcad - other_tax_credits + - property_finance_cost_relief diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/property_finance_costs/disallowed_share.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/property_finance_costs/disallowed_share.yaml new file mode 100644 index 0000000000..453d0b376b --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/property_finance_costs/disallowed_share.yaml @@ -0,0 +1,36 @@ +description: Share of the costs of dwelling-related loans that cannot be deducted in calculating property business profits, and is relieved as a tax reduction instead. +values: + 2005-04-06: + value: 0 + reference: + - title: Finance (No. 2) Act 2015 s. 24 (inserts the restriction, which has effect from 2017-18) + href: https://www.legislation.gov.uk/ukpga/2015/33/section/24 + 2017-04-06: + value: 0.25 + reference: + - title: Income Tax (Trading and Other Income) Act 2005 s. 272A(1) + href: https://www.legislation.gov.uk/ukpga/2005/5/section/272A + 2018-04-06: + value: 0.5 + reference: + - title: Income Tax (Trading and Other Income) Act 2005 s. 272A(2) + href: https://www.legislation.gov.uk/ukpga/2005/5/section/272A + 2019-04-06: + value: 0.75 + reference: + - title: Income Tax (Trading and Other Income) Act 2005 s. 272A(3) + href: https://www.legislation.gov.uk/ukpga/2005/5/section/272A + 2020-04-06: + value: 1 + reference: + - title: Income Tax (Trading and Other Income) Act 2005 s. 272A(4) + href: https://www.legislation.gov.uk/ukpga/2005/5/section/272A +metadata: + label: Disallowed share of residential property finance costs + period: year + unit: /1 + reference: + - title: Income Tax (Trading and Other Income) Act 2005 s. 272A + href: https://www.legislation.gov.uk/ukpga/2005/5/section/272A + - title: Finance (No. 2) Act 2015 s. 24 + href: https://www.legislation.gov.uk/ukpga/2015/33/section/24 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml index 0053eec145..b7d4bb9b48 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml @@ -28,6 +28,7 @@ parent: age: 30 employment_income: 20_000 + property_rental_income: 5_000 property_income: 5_000 child: age: 5 @@ -41,13 +42,45 @@ rent: 9_600 output: # Earnings take the personal allowance first: (20,000 - 12,570) x 20%. - # Property income is taxed on (5,000 - 1,000) x 20% = 800, not deducted. + # Property income (5,000 of receipts with no expenses) is taxed on + # (5,000 - 1,000) x 20% = 800, not deducted. income_tax: [2_286, 0] uc_income_tax_on_earnings: [1_486, 0] # (20,000 - 12,569.96) x 8%. uc_national_insurance_on_earnings: [594.40, 0] # 20,000 - 1,486 - 594.40. uc_individual_earned_income: [17_919.60, 0] + +- name: The finance-cost reduction comes off the tax on property, not on earnings + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 30 + employment_income: 20_000 + property_rental_income: 5_000 + property_income: 5_000 + property_finance_costs: 3_000 + child: + age: 5 + benunits: + benunit: + members: [parent, child] + households: + household: + members: [parent, child] + tenure_type: RENT_FROM_COUNCIL + rent: 9_600 + output: + # Actual expenses beat the allowance: 5,000 x 20% - 20% x 3,000 = 400 of + # tax on property, against 800. The 600 reduction (ITTOIA 2005 s. 274AA) + # is limited by the property profits, so it does not come off the tax on + # earnings, which stays (20,000 - 12,570) x 20%. + property_finance_cost_relief: [600, 0] + income_tax: [1_886, 0] + uc_income_tax_on_earnings: [1_486, 0] + uc_individual_earned_income: [17_919.60, 0] # Less the work allowance of 427 x 12 = 5,124. uc_earned_income: 12_795.60 # Rent from property is not UC unearned income (reg. 66(1) is a closed @@ -247,9 +280,11 @@ input: age: 35 employment_income: 20_000 + property_rental_income: 5_000 property_income: 5_000 output: - # Property: (5,000 - 1,000) x 22% = 880. + # Property: 5,000 of receipts with no expenses, so (5,000 - 1,000) x 22% + # = 880. property_income_tax: 880 income_tax: 2_366 uc_income_tax_on_earnings: 1_486 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits_property_income.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits_property_income.yaml new file mode 100644 index 0000000000..18cb1db809 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits_property_income.yaml @@ -0,0 +1,33 @@ +# Tax credits count property profits with the costs of dwelling-related loans +# deducted: The Tax Credits (Definition and Calculation of Income) Regulations +# 2002 reg. 11(2A) disregards the ITTOIA 2005 s. 272A restriction. Property +# income is "annual taxable profits" (reg. 11(1)), so costs above the profit +# leave nil rather than a loss. + +- name: Finance costs are deducted from property income for tax credits + period: 2023 + absolute_error_margin: 0.01 + input: + property_income: 14_000 + property_finance_costs: 8_000 + output: + tax_credits_property_income: 6_000 + # 6,000 less the 300 disregard on non-earned income. + tax_credits_applicable_income: 5_700 + +- name: Finance costs above the profit leave nil + period: 2023 + absolute_error_margin: 0.01 + input: + property_income: 3_000 + property_finance_costs: 8_000 + output: + tax_credits_property_income: 0 + +- name: Without finance costs property income is unchanged + period: 2023 + absolute_error_margin: 0.01 + input: + property_income: 3_000 + output: + tax_credits_property_income: 3_000 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 5acbec23db..6f2f1f0f16 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 @@ -73,11 +73,20 @@ output: property_allowance_deduction: 100 taxable_property_income: 0 -- name: Property allowance partial deduction +- name: Property allowance does not stack on a profit already net of expenses period: 2020 absolute_error_margin: 0 input: property_income: 1001 + output: + property_allowance_deduction: 0 + taxable_property_income: 1001 +- name: Property allowance partial deduction with receipts and no expenses + period: 2020 + absolute_error_margin: 0 + input: + property_rental_income: 1001 + property_income: 1001 output: property_allowance_deduction: 1000 taxable_property_income: 1 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 9fba0ca2ac..9d8e883419 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 @@ -47,6 +47,7 @@ - name: Property Allowance Deduction period: 2024 input: + property_rental_income: 3000 property_income: 3000 gov.hmrc.income_tax.allowances.property_allowance: 1000 output: diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/property_allowance_deduction.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/property_allowance_deduction.yaml new file mode 100644 index 0000000000..e7e7daebd7 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/property_allowance_deduction.yaml @@ -0,0 +1,125 @@ +# Property allowance, ITTOIA 2005 Part 6A Chapter 2 (ss. 783B-783BQ). +# property_income is profit after actual expenses. The allowance is an +# alternative to those expenses (s. 783BH(3)), so it may only add the part of +# the £1,000 that actual expenses have not already used. Receipts within the +# allowance are fully relieved (s. 783BF). + +- name: Landlord with £25,000 profit gets no allowance on top of expenses + period: 2027 + absolute_error_margin: 0.01 + input: + property_income: 25_000 + output: + property_allowance_deduction: 0 + taxable_property_income: 25_000 + adjusted_net_income: 25_000 + # (25,000 - 12,570) * 22% (property basic rate from 2027-28) + income_tax: 2_734.60 + +- name: Profit within the allowance, receipts unknown, is fully relieved + period: 2027 + absolute_error_margin: 0 + input: + property_income: 600 + output: + uses_property_allowance: true + property_allowance_deduction: 600 + taxable_property_income: 0 + +- name: Profit equal to the allowance, receipts unknown, is fully relieved + period: 2027 + absolute_error_margin: 0 + input: + property_income: 1_000 + output: + property_allowance_deduction: 1_000 + taxable_property_income: 0 + +- name: Profit above the allowance, receipts unknown, is taxed as reported + period: 2027 + absolute_error_margin: 0 + input: + property_income: 1_001 + output: + uses_property_allowance: false + property_allowance_deduction: 0 + taxable_property_income: 1_001 + +- name: Receipts of £900 are fully relieved (s. 783BF) + period: 2025 + absolute_error_margin: 0 + input: + employment_income: 30_000 + property_rental_income: 900 + property_income: 900 + output: + property_allowable_expenses: 0 + property_allowance_deduction: 900 + taxable_property_income: 0 + property_income_tax: 0 + +- name: Partial relief beats £600 of expenses on £5,000 of receipts (s. 783BH) + period: 2025 + absolute_error_margin: 0 + input: + employment_income: 30_000 + property_rental_income: 5_000 + property_income: 4_400 + output: + property_allowable_expenses: 600 + uses_property_allowance: true + property_allowance_deduction: 400 + # 5,000 receipts - 1,000 allowance + taxable_property_income: 4_000 + property_income_tax: 800 + property_finance_cost_relief: 0 + +- name: Actual expenses above the allowance leave no extra relief + period: 2025 + absolute_error_margin: 0 + input: + property_rental_income: 25_000 + property_income: 20_000 + output: + property_allowable_expenses: 5_000 + property_allowance_deduction: 0 + taxable_property_income: 20_000 + +- name: Receipts below profit are inconsistent and treated as unknown + period: 2025 + absolute_error_margin: 0 + input: + property_rental_income: 500 + property_income: 1_500 + output: + property_allowable_expenses: 0 + property_allowance_deduction: 0 + taxable_property_income: 1_500 + +- name: A property loss gets no allowance and is not taxed + period: 2025 + absolute_error_margin: 0 + input: + property_rental_income: 800 + property_income: -400 + output: + property_allowance_deduction: 0 + taxable_property_income: 0 + +- name: No property allowance before 2017-18 + period: 2016 + absolute_error_margin: 0 + input: + property_income: 600 + output: + property_allowance_deduction: 0 + taxable_property_income: 600 + +- name: The allowance applies from 2017-18 + period: 2017 + absolute_error_margin: 0 + input: + property_income: 600 + output: + property_allowance_deduction: 600 + taxable_property_income: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/bases/taxable_property_income.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/bases/taxable_property_income.yaml index 23522c8d12..40abf8a8c9 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/bases/taxable_property_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/bases/taxable_property_income.yaml @@ -17,18 +17,20 @@ - name: Property income partially taxable period: 2024 input: + property_rental_income: 10000 property_income: 10000 gov.hmrc.income_tax.allowances.property_allowance: 1000 output: taxable_property_income: 9000 -- name: High property income with standard allowance +- name: High property income with standard allowance, receipts unknown period: 2024 input: property_income: 50000 gov.hmrc.income_tax.allowances.property_allowance: 1000 output: - taxable_property_income: 49000 + # The profit is already net of expenses, so the allowance does not stack. + taxable_property_income: 50000 - name: Property income with no allowance period: 2024 @@ -57,6 +59,7 @@ - name: High values for both income and allowance period: 2024 input: + property_rental_income: 100000 property_income: 100000 gov.hmrc.income_tax.allowances.property_allowance: 50000 output: @@ -65,6 +68,7 @@ - name: Decimal values period: 2024 input: + property_rental_income: 1234.56 property_income: 1234.56 gov.hmrc.income_tax.allowances.property_allowance: 1000.00 output: 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 new file mode 100644 index 0000000000..aa1be63c90 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/property_finance_cost_relief.yaml @@ -0,0 +1,265 @@ +# Residential finance costs, ITTOIA 2005 ss. 272A, 274A and 274AA. +# Costs of dwelling-related loans are not deducted from property profits +# (s. 272A(4), from 2020-21). The landlord gets a tax reduction instead: the +# property basic rate (the basic rate before 2027-28, Finance Act 2026 Sch. 1 +# para. 40) times the lowest of the relievable amount, the property profits +# and adjusted total income (s. 274AA). Unrelieved costs are carried forward +# (s. 274AA(4)). The reduction and the property allowance cannot be combined +# (s. 783BL). + +- name: £20,000 receipts, £6,000 expenses and £8,000 finance costs in 2025-26 + period: 2025 + absolute_error_margin: 0.01 + input: + employment_income: 30_000 + property_rental_income: 20_000 + property_income: 14_000 + property_finance_costs: 8_000 + output: + uses_property_allowance: false + taxable_property_income: 14_000 + property_finance_costs_relievable: 8_000 + property_finance_costs_relieved: 8_000 + # 20% x 8,000 + property_finance_cost_relief: 1_600 + property_finance_costs_carried_forward: 0 + # (30,000 - 12,570) x 20% + 14,000 x 20% - 1,600 + income_tax: 4_686 + +- name: The same landlord in 2027-28 gets relief at the 22% property basic rate + period: 2027 + absolute_error_margin: 0.01 + input: + employment_income: 30_000 + property_rental_income: 20_000 + property_income: 14_000 + property_finance_costs: 8_000 + output: + # 22% x 8,000 + property_finance_cost_relief: 1_760 + # (30,000 - 12,570) x 20% + 14,000 x 22% - 1,760 + income_tax: 4_806 + +- name: A Scottish taxpayer gets relief at the basic rate of 20% in 2025-26 + period: 2025 + absolute_error_margin: 0.01 + input: + employment_income: 30_000 + property_income: 14_000 + property_finance_costs: 8_000 + region: SCOTLAND + output: + pays_scottish_income_tax: true + property_finance_cost_relief: 1_600 + +- name: A Scottish taxpayer also gets the 22% property basic rate in 2027-28 + period: 2027 + absolute_error_margin: 0.01 + input: + employment_income: 30_000 + property_income: 14_000 + property_finance_costs: 8_000 + region: SCOTLAND + output: + pays_scottish_income_tax: true + # s. 274AA(5) gives no Scottish rate: 22% x 8,000, although the property + # profits themselves are taxed at Scottish rates. + property_finance_cost_relief: 1_760 + +- name: Relief is limited to the property profits and the rest carried forward + period: 2025 + absolute_error_margin: 0.01 + input: + employment_income: 30_000 + property_income: 3_000 + property_finance_costs: 8_000 + output: + property_finance_costs_relieved: 3_000 + # 20% x 3,000 + property_finance_cost_relief: 600 + property_finance_costs_carried_forward: 5_000 + +- name: Relief is limited to adjusted total income + period: 2025 + absolute_error_margin: 0.01 + input: + property_income: 14_000 + property_finance_costs: 8_000 + output: + # Adjusted total income: 14,000 - 12,570 personal allowance. + property_finance_costs_relieved: 1_430 + property_finance_cost_relief: 286 + property_income_tax: 286 + income_tax: 0 + property_finance_costs_carried_forward: 6_570 + +- name: A landlord within the personal allowance gets no relief and keeps the costs + period: 2025 + absolute_error_margin: 0.01 + input: + property_income: 10_000 + property_finance_costs: 8_000 + output: + property_finance_costs_relieved: 0 + property_finance_cost_relief: 0 + income_tax: 0 + property_finance_costs_carried_forward: 8_000 + +- name: Costs brought forward are relieved with this year's costs + period: 2025 + absolute_error_margin: 0.01 + input: + employment_income: 30_000 + property_income: 10_000 + property_finance_costs: 2_000 + property_finance_costs_brought_forward: 3_000 + output: + property_finance_costs_relievable: 5_000 + property_finance_costs_relieved: 5_000 + property_finance_cost_relief: 1_000 + property_finance_costs_carried_forward: 0 + # (30,000 - 12,570) x 20% + 10,000 x 20% - 1,000 + income_tax: 4_486 + +- name: Relief is limited to the Step 5 tax left after other reductions + period: 2025 + absolute_error_margin: 0.01 + input: + employment_income: 30_000 + property_income: 14_000 + property_finance_costs: 8_000 + other_tax_credits: 5_000 + output: + # Step 5 tax 3,486 + 2,800 = 6,286, less 5,000 of other reductions. + property_finance_cost_relief: 1_286 + income_tax: 0 + # The carry-forward follows the costs relieved, not the tax limit. + property_finance_costs_carried_forward: 0 + +- name: A higher-rate landlord with small finance costs elects for the allowance + period: 2025 + absolute_error_margin: 0.01 + input: + employment_income: 60_000 + property_rental_income: 5_000 + property_income: 4_800 + property_finance_costs: 500 + output: + # Allowance: (5,000 - 1,000) x 40% = 1,600 on property. Expenses: + # 4,800 x 40% - 20% x 500 = 1,820. + uses_property_allowance: true + property_allowance_deduction: 800 + taxable_property_income: 4_000 + property_finance_cost_relief: 0 + # This year's costs are among the expenses the allowance replaces. + property_finance_costs_carried_forward: 0 + # 37,700 x 20% + 9,730 x 40% + 1,600 + income_tax: 13_032 + +- name: Costs brought forward stay carried forward when the allowance is used + period: 2025 + absolute_error_margin: 0.01 + input: + employment_income: 60_000 + property_rental_income: 5_000 + property_income: 4_800 + property_finance_costs: 500 + property_finance_costs_brought_forward: 1_000 + output: + # Expenses: 4,800 x 40% - 20% x 1,500 = 1,620, above the allowance's 1,600. + uses_property_allowance: true + property_finance_cost_relief: 0 + property_finance_costs_carried_forward: 1_000 + +- name: A basic-rate landlord with larger finance costs keeps actual expenses + period: 2025 + absolute_error_margin: 0.01 + input: + employment_income: 30_000 + property_rental_income: 5_000 + property_income: 4_800 + property_finance_costs: 3_000 + output: + # Allowance: 4,000 x 20% = 800. Expenses: 4,800 x 20% - 20% x 3,000 = 360. + uses_property_allowance: false + property_allowance_deduction: 0 + taxable_property_income: 4_800 + property_finance_cost_relief: 600 + +- name: Receipts within the allowance keep full relief when electing out saves nothing + period: 2025 + absolute_error_margin: 0.01 + input: + employment_income: 30_000 + property_rental_income: 900 + property_income: 900 + property_finance_costs: 2_000 + output: + # Full relief: no tax on the 900. Electing out: 900 x 20% - 20% x 900 = 0. + uses_property_allowance: true + taxable_property_income: 0 + property_finance_cost_relief: 0 + property_finance_costs_carried_forward: 0 + +- name: A Scottish starter-rate landlord elects out of full relief + period: 2025 + absolute_error_margin: 0.01 + input: + employment_income: 13_000 + property_rental_income: 900 + property_income: 900 + property_finance_costs: 2_000 + region: SCOTLAND + output: + # Electing out (s. 783BJ): 900 taxed at the 19% starter rate (171) and a + # 20% reduction on 900 (180), against no tax on it with full relief. + uses_property_allowance: false + taxable_property_income: 900 + property_income_tax: 171 + property_finance_cost_relief: 180 + # (13,000 - 12,570) x 19% + 171 - 180 + income_tax: 72.70 + property_finance_costs_carried_forward: 1_100 + +- name: Half of the finance costs are deductible in 2018-19 + period: 2018 + absolute_error_margin: 0.01 + input: + employment_income: 30_000 + property_income: 14_000 + property_finance_costs: 8_000 + output: + deductible_property_finance_costs: 4_000 + taxable_property_income: 10_000 + property_finance_costs_relievable: 4_000 + property_finance_cost_relief: 800 + # (30,000 - 11,850) x 20% + 10,000 x 20% - 800 + income_tax: 4_830 + +- name: Finance costs are fully deductible before 2017-18 + period: 2016 + absolute_error_margin: 0.01 + input: + employment_income: 30_000 + property_income: 14_000 + property_finance_costs: 8_000 + output: + deductible_property_finance_costs: 8_000 + taxable_property_income: 6_000 + property_finance_costs_relievable: 0 + property_finance_cost_relief: 0 + +- name: Restoring full deductibility removes the reduction + period: 2026 + absolute_error_margin: 0.01 + input: + gov.hmrc.income_tax.reliefs.property_finance_costs.disallowed_share: 0 + employment_income: 30_000 + property_income: 14_000 + property_finance_costs: 8_000 + output: + deductible_property_finance_costs: 8_000 + taxable_property_income: 6_000 + property_finance_cost_relief: 0 + # (30,000 - 12,570) x 20% + 6,000 x 20% + income_tax: 4_686 diff --git a/policyengine_uk/tests/policy/reforms/nov_2025_budget/income_source_tax_rates.yaml b/policyengine_uk/tests/policy/reforms/nov_2025_budget/income_source_tax_rates.yaml index 067065fd1d..31a39110fa 100644 --- a/policyengine_uk/tests/policy/reforms/nov_2025_budget/income_source_tax_rates.yaml +++ b/policyengine_uk/tests/policy/reforms/nov_2025_budget/income_source_tax_rates.yaml @@ -136,6 +136,7 @@ absolute_error_margin: 1 input: employment_income: 12570 + property_rental_income: 2000 property_income: 2000 output: # Property allowance is £1000, so £1000 taxable at 20% @@ -146,6 +147,7 @@ absolute_error_margin: 1 input: employment_income: 60000 + property_rental_income: 2000 property_income: 2000 output: # £1000 (after property allowance) all at 40% since employment income @@ -158,6 +160,7 @@ absolute_error_margin: 1 input: employment_income: 12570 + property_rental_income: 2000 property_income: 2000 output: # £1000 at NEW rate 22% @@ -168,6 +171,7 @@ absolute_error_margin: 1 input: employment_income: 60000 + property_rental_income: 2000 property_income: 2000 output: # £1000 at NEW rate 42% @@ -178,6 +182,7 @@ absolute_error_margin: 1 input: employment_income: 200000 + property_rental_income: 2000 property_income: 2000 output: # £1000 at NEW rate 47% @@ -194,6 +199,7 @@ input: # Person with just personal allowance worth of employment income employment_income: 12570 + property_rental_income: 2000 property_income: 2000 output: # Property allowance is £1000, so £1000 taxable at 22% @@ -207,6 +213,7 @@ absolute_error_margin: 1 input: employment_income: 60000 + property_rental_income: 2000 property_income: 2000 output: # £1000 at 42% diff --git a/policyengine_uk/tests/test_property_income_properties.py b/policyengine_uk/tests/test_property_income_properties.py new file mode 100644 index 0000000000..cab1afda22 --- /dev/null +++ b/policyengine_uk/tests/test_property_income_properties.py @@ -0,0 +1,263 @@ +"""Property tests for landlords' property income (ITTOIA 2005 Part 3 Chapter 3 +and Part 6A Chapter 2). + +property_income is profit after actual expenses and before the costs of +dwelling-related loans. Those costs are relieved by a tax reduction at the +property basic rate (ss. 274A-274AA), and the property allowance replaces +actual expenses but cannot be had with the reduction (s. 783BL). These tests +check bounds, the reduction's accounting, that the allowance never stacks on +expenses, that the model's choice between the two is never worse than either +alone, and monotonicity, over many generated cases in one reusable +simulation. +""" + +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 = 2027 +ALLOWANCE = 1_000 +PROPERTY_BASIC_RATE = 0.22 +OTHER_EMPLOYMENT_INCOME = [0, 8_000, 13_000, 30_000, 60_000, 110_000, 200_000] +# Every third person is a Scottish taxpayer. +SCOTTISH = [i % 3 == 2 for i in range(7)] +CASES = 21 # Each case is simulated twice: as drawn, and with more costs. +PEOPLE = 2 * CASES + +amount = st.one_of( + st.floats(min_value=0, max_value=2_500), + st.floats(min_value=0, max_value=60_000), + st.sampled_from([0.0, 999.99, 1_000.0, 1_000.01, 12_570.0]), +) +case = st.tuples( + st.one_of(amount, st.floats(min_value=-3_000, max_value=0)), # profit + amount, # expenses + st.sampled_from([0.0, 0.0, 300.0, 1_500.0]).flatmap( + lambda base: st.one_of(st.just(base), amount) + ), # finance costs + st.sampled_from([0.0, 0.0, 0.0, 500.0, 4_000.0]), # brought forward + # Receipts: unknown, consistent (profit + expenses), or inconsistently + # below profit, which the model must treat as unknown. + st.sampled_from(["unknown", "consistent", "consistent", "below_profit"]), + st.floats(min_value=0.01, max_value=0.99), # receipts share if below profit + st.floats(min_value=0, max_value=20_000), # extra finance costs +) +cases = st.lists(case, min_size=CASES, max_size=CASES) + + +def person_index(i): + return i % CASES % len(OTHER_EMPLOYMENT_INCOME) + + +@pytest.fixture(scope="module") +def sim(): + people = { + f"person_{i}": { + "age": {YEAR: 40}, + "employment_income": {YEAR: OTHER_EMPLOYMENT_INCOME[person_index(i)]}, + "property_income": {YEAR: 0}, + "property_rental_income": {YEAR: 0}, + "property_finance_costs": {YEAR: 0}, + "property_finance_costs_brought_forward": {YEAR: 0}, + } + for i in range(PEOPLE) + } + households = { + f"household_{i}": { + "members": [f"person_{i}"], + "region": {YEAR: "SCOTLAND" if SCOTTISH[person_index(i)] else "LONDON"}, + } + for i in range(PEOPLE) + } + return Simulation( + situation={ + "people": people, + "benunits": { + f"benunit_{i}": {"members": [f"person_{i}"]} for i in range(PEOPLE) + }, + "households": households, + } + ) + + +def set_inputs(sim, drawn, uses_allowance=None): + profit, expenses, costs, brought_forward, mode, share, extra = map( + np.array, zip(*drawn) + ) + profit, expenses, brought_forward, mode, share = ( + np.concatenate([x, x]) for x in (profit, expenses, brought_forward, mode, share) + ) + costs = np.concatenate([costs, costs + extra]) + receipts = np.select( + [mode == "consistent", mode == "below_profit"], + [np.maximum(profit, 0) + expenses, np.maximum(profit, 0) * share], + 0, + ) + sim.set_input("property_income", YEAR, profit) + sim.set_input("property_rental_income", YEAR, receipts) + sim.set_input("property_finance_costs", YEAR, costs) + sim.set_input("property_finance_costs_brought_forward", YEAR, brought_forward) + sim.reset_calculations() + if uses_allowance is not None: + sim.set_input("uses_property_allowance", YEAR, np.full(PEOPLE, uses_allowance)) + + +def run(sim, drawn): + set_inputs(sim, drawn) + + def calc(variable): + return sim.calculate(variable, YEAR).astype(float) + + # Compare against the stored (float32) inputs, not the float64 draws. + profit = calc("property_income") + receipts = calc("property_rental_income") + return dict( + profit=profit, + receipts=receipts, + known=(receipts > 0) & (receipts >= profit), + expenses=calc("property_allowable_expenses"), + costs=calc("property_finance_costs"), + brought_forward=calc("property_finance_costs_brought_forward"), + uses=sim.calculate("uses_property_allowance", YEAR), + deduction=calc("property_allowance_deduction"), + deduction_if_used=calc("property_allowance_deduction_if_used"), + taxable=calc("taxable_property_income"), + relievable=calc("property_finance_costs_relievable"), + relieved=calc("property_finance_costs_relieved"), + relief=calc("property_finance_cost_relief"), + carried_forward=calc("property_finance_costs_carried_forward"), + pre_charges=calc("income_tax_pre_charges"), + adjusted_net_income=calc("adjusted_net_income"), + allowances=calc("allowances"), + income_tax=calc("income_tax"), + ) + + +def income_tax_on_route(sim, drawn, uses_allowance): + set_inputs(sim, drawn, uses_allowance) + return sim.calculate("income_tax", YEAR).astype(float) + + +def tolerance(*values): + """A penny plus float32 rounding on the amounts involved.""" + return 0.01 + 1e-6 * sum(np.abs(v) for v in values) + + +SETTINGS = settings( + max_examples=30, + deadline=None, + suppress_health_check=[HealthCheck.too_slow], +) + + +@SETTINGS +@given(cases) +def test_allowance_is_bounded_and_never_stacks_on_expenses(sim, drawn): + r = run(sim, drawn) + assert (r["deduction"] >= 0).all() + assert (r["deduction"] <= ALLOWANCE + tolerance(ALLOWANCE)).all() + assert (r["deduction"] <= np.maximum(r["profit"], 0) + tolerance(r["profit"])).all() + # Deducted from receipts: never more than the larger of actual expenses + # and the allowance. + known = r["known"] & (r["receipts"] > ALLOWANCE) + deducted = r["receipts"] - r["taxable"] + limit = np.maximum(r["expenses"], ALLOWANCE) + tolerance(r["receipts"]) + assert (deducted[known] <= limit[known]).all() + # Receipts within the allowance are relieved in full unless the person + # elects out of it. + full = r["known"] & (r["receipts"] <= ALLOWANCE) & r["uses"] + assert (r["taxable"][full] <= tolerance(r["receipts"])[full]).all() + + +@SETTINGS +@given(cases) +def test_taxable_profit_matches_statutory_reference(sim, drawn): + r = run(sim, drawn) + profit = np.maximum(r["profit"], 0) + allowance_route = np.where( + r["known"], + np.where( + r["receipts"] <= ALLOWANCE, + 0, + np.minimum(profit, r["receipts"] - ALLOWANCE), + ), + np.where(r["profit"] <= ALLOWANCE, 0, profit), + ) + expected = np.where(r["uses"], allowance_route, profit) + assert ( + np.abs(r["taxable"] - expected) <= tolerance(r["receipts"], r["profit"]) + ).all() + + +@SETTINGS +@given(cases) +def test_relief_accounting(sim, drawn): + r = run(sim, drawn) + expenses_route = ~r["uses"] + # Relievable: this year's disallowed costs plus the amount brought forward. + assert ( + np.abs(r["relievable"] - (r["costs"] + r["brought_forward"])) + <= tolerance(r["costs"], r["brought_forward"]) + ).all() + # Relieved: the lowest of the relievable amount, the property profits and + # adjusted total income, on the expenses route only. + adjusted_total_income = np.maximum(0, r["adjusted_net_income"] - r["allowances"]) + expected_relieved = np.where( + expenses_route, + np.maximum( + 0, + np.minimum( + r["relievable"], + np.minimum(r["taxable"], adjusted_total_income), + ), + ), + 0, + ) + assert ( + np.abs(r["relieved"] - expected_relieved) + <= tolerance(r["relievable"], r["taxable"], adjusted_total_income) + ).all() + # The reduction is at most the property basic rate on the costs, and at + # most the Step 5 tax. + assert (r["relief"] >= 0).all() + assert ( + r["relief"] + <= PROPERTY_BASIC_RATE * r["relievable"] + tolerance(r["relievable"]) + ).all() + assert (r["relief"] <= r["pre_charges"] + tolerance(r["pre_charges"])).all() + # What is not relieved is carried forward; on the allowance route this + # year's costs are replaced by the allowance and only the amount brought + # forward is carried on. + expected_carried = np.where( + expenses_route, r["relievable"] - r["relieved"], r["brought_forward"] + ) + assert ( + np.abs(r["carried_forward"] - expected_carried) + <= tolerance(r["relievable"], r["relieved"]) + ).all() + + +@SETTINGS +@given(cases) +def test_choice_is_never_worse_than_either_route(sim, drawn): + r = run(sim, drawn) + with_allowance = income_tax_on_route(sim, drawn, True) + with_expenses = income_tax_on_route(sim, drawn, False) + margin = tolerance(r["income_tax"], with_allowance, with_expenses) + assert (r["income_tax"] <= with_expenses + margin).all() + # The allowance route exists where the allowance deducts something. + available = r["deduction_if_used"] > 0 + assert (r["income_tax"][available] <= (with_allowance + margin)[available]).all() + + +@SETTINGS +@given(cases) +def test_income_tax_is_non_increasing_in_finance_costs(sim, drawn): + r = run(sim, drawn) + base, more = r["income_tax"][:CASES], r["income_tax"][CASES:] + assert (more <= base + tolerance(base)).all() diff --git a/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py b/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py index 1c698c1921..c20f8820e9 100644 --- a/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py +++ b/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py @@ -25,7 +25,7 @@ def formula(benunit, period, parameters): "private_pension_income", "savings_interest_income", "dividend_income", - "property_income", + "tax_credits_property_income", ] income = add_for_members(benunit, period, STEP_1_COMPONENTS, members) income = max_(income - TC.means_test.non_earned_disregard, 0) diff --git a/policyengine_uk/variables/gov/dwp/tax_credits_property_income.py b/policyengine_uk/variables/gov/dwp/tax_credits_property_income.py new file mode 100644 index 0000000000..9eb88a6d22 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/tax_credits_property_income.py @@ -0,0 +1,24 @@ +from policyengine_uk.model_api import * + + +class tax_credits_property_income(Variable): + value_type = float + entity = Person + label = "property income for tax credits" + documentation = ( + "Property income as tax credits count it: property_income less the " + "costs of dwelling-related loans, which the tax credit rules deduct " + "in full although income tax does not. Finance costs above the " + "profit leave nil, not a loss set against other income." + ) + definition_period = YEAR + unit = GBP + reference = dict( + title="The Tax Credits (Definition and Calculation of Income) Regulations 2002 reg. 11(1) and (2A)", + href="https://www.legislation.gov.uk/uksi/2002/2006/regulation/11", + ) + + def formula(person, period, parameters): + profit = person("property_income", period) + finance_costs = person("property_finance_costs", period) + return where(profit > 0, max_(profit - finance_costs, 0), profit) 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 9f53b210f2..af9bb47d66 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 @@ -91,6 +91,17 @@ def formula(person, period, parameters): # off the tax on earnings first, as allowances do. So the deduction # never exceeds the income tax the person pays, never includes a # charge such as the High Income Child Benefit Charge, and does not - # move when other income absorbs more or less of a reduction. - reductions = add(person, period, income_tax.income_tax_subtractions) + # move when other income absorbs more or less of a reduction. The + # exception is the finance-cost reduction, which is given on property + # profits and limited by them (ITTOIA 2005 s. 274AA), so it comes off + # the tax on property income and never off the tax on earnings. + reductions = add( + person, + period, + [ + variable + for variable in income_tax.income_tax_subtractions + if variable != "property_finance_cost_relief" + ], + ) return max_(0, tax - reductions) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/property_allowance_deduction.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/property_allowance_deduction.py index 9ec79d5574..609965dea5 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/property_allowance_deduction.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/property_allowance_deduction.py @@ -5,15 +5,37 @@ class property_allowance_deduction(Variable): value_type = float entity = Person label = "Deduction applied by the property allowance" + documentation = ( + "Relief from the property allowance beyond the expenses already " + "deducted in property_income: property_allowance_deduction_if_used " + "where the person uses the allowance, and nil where they deduct " + "actual expenses and take the finance-cost tax reduction instead " + "(uses_property_allowance)." + ) definition_period = YEAR - reference = "Income Tax (Trading and Other Income) Act 2005 s. 783AF" + reference = [ + dict( + title="Income Tax (Trading and Other Income) Act 2005, s. 783BD", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/783BD", + ), + dict( + title="Income Tax (Trading and Other Income) Act 2005, s. 783BF (full relief)", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/783BF", + ), + dict( + title="Income Tax (Trading and Other Income) Act 2005, s. 783BH (partial relief)", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/783BH", + ), + dict( + title="Income Tax (Trading and Other Income) Act 2005, s. 783BL", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/783BL", + ), + ] unit = GBP def formula(person, period, parameters): - property_allowance = parameters( - period - ).gov.hmrc.income_tax.allowances.property_allowance - return min_( - person("property_income", period), - property_allowance, + return where( + person("uses_property_allowance", period), + person("property_allowance_deduction_if_used", period), + 0, ) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/property_allowance_deduction_if_used.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/property_allowance_deduction_if_used.py new file mode 100644 index 0000000000..926dfd2560 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/property_allowance_deduction_if_used.py @@ -0,0 +1,62 @@ +from policyengine_uk.model_api import * + + +class property_allowance_deduction_if_used(Variable): + value_type = float + entity = Person + label = "Deduction the property allowance gives if used" + documentation = ( + "What the property allowance would deduct from property profits, " + "beyond the expenses already deducted in them, if the person used it. " + "The allowance replaces actual expenses rather than adding to them: " + "receipts within the allowance are fully relieved, and above it the " + "person may elect to deduct the allowance from receipts instead of " + "their expenses, without creating a loss. With gross receipts " + "supplied, this is the allowance less actual expenses, capped at " + "profit. Without them, a profit within the allowance is treated as " + "coming from receipts within it, and a larger profit as already " + "reflecting the better of actual expenses and the allowance, so " + "taxable profit jumps from nil to the full profit just above the " + "allowance. Receipts below profit are inconsistent and treated as " + "unknown." + ) + definition_period = YEAR + reference = [ + dict( + title="Income Tax (Trading and Other Income) Act 2005, s. 783BF (full relief)", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/783BF", + ), + dict( + title="Income Tax (Trading and Other Income) Act 2005, s. 783BH (partial relief)", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/783BH", + ), + dict( + title="Income Tax (Trading and Other Income) Act 2005, s. 783BI(4)", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/783BI", + ), + ] + unit = GBP + + def formula(person, period, parameters): + allowance = parameters(period).gov.hmrc.income_tax.allowances.property_allowance + receipts = person("property_rental_income", period) + deductible_finance_costs = person("deductible_property_finance_costs", period) + # Profit as computed with actual expenses, which include any finance + # costs still deductible (before 2020-21). The allowance replaces all + # of them. + profit = person("property_income", period) - deductible_finance_costs + expenses = person("property_allowable_expenses", period) + ( + deductible_finance_costs + ) + # Receipts below profit (including the default zero) mean unknown. + receipts_known = (receipts > 0) & ( + receipts >= person("property_income", period) + ) + # Full relief (receipts within the allowance, s. 783BF) makes the + # profit nil; partial relief (s. 783BH) replaces expenses with the + # allowance. Both come to the allowance's excess over actual + # expenses, capped at profit. + relief_with_receipts = min_(profit, allowance - expenses) + relief_without_receipts = where(profit <= allowance, profit, 0) + relief = where(receipts_known, relief_with_receipts, relief_without_receipts) + return max_(relief, 0) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_property_allowance.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_property_allowance.py new file mode 100644 index 0000000000..de5792761e --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_property_allowance.py @@ -0,0 +1,103 @@ +from policyengine_uk.model_api import * + +# Inputs to income tax that do not depend on how property profits are +# computed. Resolving them before branching lets both branches reuse them. +INDEPENDENT_OF_ROUTE = [ + "taxable_employment_income", + "taxable_self_employment_income", + "taxable_pension_income", + "taxable_social_security_income", + "taxable_savings_interest_income", + "taxable_dividend_income", + "taxable_miscellaneous_income", + "pays_scottish_income_tax", + "other_tax_credits", +] + + +def income_tax_with_route(simulation, name, period, uses_allowance): + """Each person's income tax with the given choices of property allowance. + + The branch shares the simulation's cached arrays until it writes, and is + dropped afterwards so a later period starts from a fresh branch. + """ + while name in simulation.branches or name == simulation.branch_name: + name += "_" + branch = simulation.get_branch(name) + try: + branch.set_input("uses_property_allowance", period, uses_allowance) + return branch.populations["person"]("income_tax", period) + finally: + del simulation.branches[name] + + +class uses_property_allowance(Variable): + value_type = bool + entity = Person + label = "Uses the property allowance" + documentation = ( + "Whether the person's property profits are computed with the property " + "allowance, by full relief or an election for partial relief, rather " + "than with actual expenses and the finance-cost tax reduction. The " + "two cannot be combined. Full relief applies to receipts within the " + "allowance unless the person elects out of it, and partial relief " + "only if they elect for it; each election is made only if it lowers " + "the person's income tax. Without finance costs to relieve, the " + "allowance is used whenever it lowers taxable property income." + ) + definition_period = YEAR + reference = [ + dict( + title="Income Tax (Trading and Other Income) Act 2005, s. 783BE", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/783BE", + ), + dict( + title="Income Tax (Trading and Other Income) Act 2005, s. 783BJ", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/783BJ", + ), + dict( + title="Income Tax (Trading and Other Income) Act 2005, s. 783BK", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/783BK", + ), + dict( + title="Income Tax (Trading and Other Income) Act 2005, s. 783BL", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/783BL", + ), + ] + + def formula(person, period, parameters): + deduction = person("property_allowance_deduction_if_used", period) + profit = person("property_income", period) - person( + "deductible_property_finance_costs", period + ) + lowers_income = deduction > 0 + # The finance-cost reduction needs property profits to relieve. + reduction_at_stake = ( + person("property_finance_costs_relievable", period) > 0 + ) & (profit > 0) + # Without a reduction at stake, the allowance is used whenever it + # lowers taxable property income. Otherwise compare income tax on + # each route. + uses = lowers_income & ~reduction_at_stake + choosing = lowers_income & reduction_at_stake + if not choosing.any(): + # Nobody has a choice to weigh: skip the branches. + return uses + for variable in INDEPENDENT_OF_ROUTE: + person(variable, period) + simulation = person.simulation + tax_using = income_tax_with_route( + simulation, "property_allowance_used", period, uses | choosing + ) + tax_not_using = income_tax_with_route( + simulation, "property_allowance_not_used", period, uses + ) + # Tax is computed to the penny; smaller differences are rounding. + saving = np.round(tax_not_using - tax_using, 2) + # The allowance removes all of the profit exactly when receipts are + # within it. Full relief then applies unless electing out of it + # (s. 783BJ) lowers tax; partial relief applies only if electing for + # it (s. 783BK) lowers tax. + full_relief = deduction >= profit + prefers_allowance = where(full_relief, saving >= 0, saving > 0) + return where(choosing, prefers_allowance, uses) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bases/deductible_property_finance_costs.py b/policyengine_uk/variables/gov/hmrc/income_tax/bases/deductible_property_finance_costs.py new file mode 100644 index 0000000000..608b8f935c --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bases/deductible_property_finance_costs.py @@ -0,0 +1,22 @@ +from policyengine_uk.model_api import * + + +class deductible_property_finance_costs(Variable): + value_type = float + entity = Person + label = "deductible residential property finance costs" + documentation = ( + "The part of property_finance_costs still deducted in calculating " + "property profits: all of it before 2017-18, then 75%, 50% and 25%, " + "and none from 2020-21. The rest is relieved as a tax reduction." + ) + definition_period = YEAR + unit = GBP + reference = dict( + title="Income Tax (Trading and Other Income) Act 2005, s. 272A", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/272A", + ) + + def formula(person, period, parameters): + p = parameters(period).gov.hmrc.income_tax.reliefs.property_finance_costs + return (1 - p.disallowed_share) * person("property_finance_costs", period) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bases/property_allowable_expenses.py b/policyengine_uk/variables/gov/hmrc/income_tax/bases/property_allowable_expenses.py new file mode 100644 index 0000000000..f6e5345d5d --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bases/property_allowable_expenses.py @@ -0,0 +1,27 @@ +from policyengine_uk.model_api import * + + +class property_allowable_expenses(Variable): + value_type = float + entity = Person + label = "property allowable expenses" + documentation = ( + "Expenses already deducted in arriving at property_income, implied by " + "gross receipts: property_rental_income less property_income. They do " + "not include the costs of dwelling-related loans " + "(property_finance_costs), which property_income is measured before. " + "Zero when receipts are unknown, that is zero or below " + "property_income." + ) + definition_period = YEAR + unit = GBP + reference = dict( + title="Income Tax (Trading and Other Income) Act 2005, s. 272", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/272", + ) + + def formula(person, period, parameters): + profit = person("property_income", period) + receipts = person("property_rental_income", period) + receipts_known = (receipts > 0) & (receipts >= profit) + return where(receipts_known, receipts - profit, 0) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bases/taxable_property_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/bases/taxable_property_income.py index d70b06548b..cac53280c7 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bases/taxable_property_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bases/taxable_property_income.py @@ -5,16 +5,28 @@ class taxable_property_income(Variable): value_type = float entity = Person label = "Amount of property income that is taxable" - definition_period = YEAR - reference = dict( - title="Income Tax (Trading and Other Income) Act 2005, s. 268", - href="https://www.legislation.gov.uk/ukpga/2005/5/section/268", + documentation = ( + "Property profits for income tax: property_income less any finance " + "costs still deductible (before 2020-21) and the property allowance " + "deduction, floored at nil." ) + definition_period = YEAR + reference = [ + dict( + title="Income Tax (Trading and Other Income) Act 2005, s. 268", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/268", + ), + dict( + title="Income Tax (Trading and Other Income) Act 2005, s. 272A", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/272A", + ), + ] unit = GBP def formula(person, period, parameters): return max_( 0, person("property_income", period) + - person("deductible_property_finance_costs", period) - person("property_allowance_deduction", period), ) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/other_tax_credits.py b/policyengine_uk/variables/gov/hmrc/income_tax/other_tax_credits.py index c1d107d5ff..dcf7260c39 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/other_tax_credits.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/other_tax_credits.py @@ -5,7 +5,8 @@ class other_tax_credits(Variable): label = "other miscellaneous tax credits for Income Tax" documentation = """Includes Venture Capital Trusts, Enterprise Investment Schemes, Seed Enterprise Investment Schemes, Community Investment Tax Relief, maintenance/alimony payments, -Social Investment Tax Relief, foreign tax relief on income and landlord tax relief.""" +Social Investment Tax Relief and foreign tax relief on income. Landlords' tax reduction for +residential finance costs is property_finance_cost_relief, not this.""" entity = Person definition_period = YEAR value_type = float diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/property_finance_cost_relief.py b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/property_finance_cost_relief.py new file mode 100644 index 0000000000..b23956b772 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/property_finance_cost_relief.py @@ -0,0 +1,57 @@ +from policyengine_uk.model_api import * + + +class property_finance_cost_relief(Variable): + value_type = float + entity = Person + label = "residential property finance costs tax reduction" + documentation = ( + "Tax reduction for the costs of dwelling-related loans that cannot be " + "deducted from property profits: the property basic rate (the basic " + "rate before 2027-28) times the finance costs relieved this year. " + "The rate is the same for Scottish taxpayers, whose property income " + "is taxed at Scottish rates. It is deducted at Step 6 of the income " + "tax calculation after the person's other tax reductions, and only " + "so far as there is tax left to deduct it from." + ) + definition_period = YEAR + unit = GBP + reference = [ + dict( + title="Income Tax (Trading and Other Income) Act 2005, s. 274A", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/274A", + ), + dict( + title="Income Tax (Trading and Other Income) Act 2005, s. 274AA(5)", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/274AA", + ), + dict( + title="Finance Act 2026, Sch. 1 para. 40 (property basic rate from 2027-28)", + href="https://www.legislation.gov.uk/ukpga/2026/11/schedule/1", + ), + dict( + title="Income Tax Act 2007, s. 26(1)(a) and s. 29(2)-(3)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/29", + ), + ] + + def formula(person, period, parameters): + income_tax = parameters(period).gov.hmrc.income_tax + reduction = income_tax.rates.property.basic * person( + "property_finance_costs_relieved", period + ) + # The order of Step 6 reductions does not change their total, so the + # person's other reductions come first; this one is limited to the + # Step 5 tax they leave. Step 7 charges (ITA 2007 s. 30), such as the + # High Income Child Benefit Charge, are not reduced. + other_reductions = [ + variable + for variable in income_tax.income_tax_subtractions + if variable != "property_finance_cost_relief" + ] + tax_left = max_( + 0, + person("income_tax_pre_charges", period) + - add(person, period, other_reductions), + ) + return min_(reduction, tax_left) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/property_finance_costs_carried_forward.py b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/property_finance_costs_carried_forward.py new file mode 100644 index 0000000000..ebc812bd20 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/property_finance_costs_carried_forward.py @@ -0,0 +1,28 @@ +from policyengine_uk.model_api import * + + +class property_finance_costs_carried_forward(Variable): + value_type = float + entity = Person + label = "residential property finance costs carried forward" + documentation = ( + "Finance costs left unrelieved this year, which become next year's " + "property_finance_costs_brought_forward: the relievable amount less " + "the costs relieved. Where the person uses the property allowance, " + "this year's costs are among the expenses the allowance replaces, so " + "only the amount brought forward is carried on." + ) + definition_period = YEAR + unit = GBP + reference = dict( + title="Income Tax (Trading and Other Income) Act 2005, s. 274AA(4)", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/274AA", + ) + + def formula(person, period, parameters): + relievable = where( + person("uses_property_allowance", period), + person("property_finance_costs_brought_forward", period), + person("property_finance_costs_relievable", period), + ) + return max_(0, relievable - person("property_finance_costs_relieved", period)) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/property_finance_costs_relievable.py b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/property_finance_costs_relievable.py new file mode 100644 index 0000000000..3add668734 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/property_finance_costs_relievable.py @@ -0,0 +1,23 @@ +from policyengine_uk.model_api import * + + +class property_finance_costs_relievable(Variable): + value_type = float + entity = Person + label = "residential property finance costs available for relief" + documentation = ( + "The relievable amount for the finance-cost tax reduction: this " + "year's finance costs that cannot be deducted from property profits, " + "plus any amount brought forward from earlier years." + ) + definition_period = YEAR + unit = GBP + reference = dict( + title="Income Tax (Trading and Other Income) Act 2005, s. 274A(3)-(4)", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/274A", + ) + + def formula(person, period, parameters): + p = parameters(period).gov.hmrc.income_tax.reliefs.property_finance_costs + current_year = p.disallowed_share * person("property_finance_costs", period) + return current_year + person("property_finance_costs_brought_forward", period) 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 new file mode 100644 index 0000000000..e67edf640b --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/property_finance_costs_relieved.py @@ -0,0 +1,47 @@ +from policyengine_uk.model_api import * + + +class property_finance_costs_relieved(Variable): + value_type = float + entity = Person + label = "residential property finance costs relieved" + documentation = ( + "The finance costs on which the tax reduction is given this year: the " + "lowest of the relievable amount, the property profits, and adjusted " + "total income (net income other than savings and dividend income, " + "less allowances). Nil where the person uses the property allowance, " + "since the allowance replaces the expenses and cannot be had with the " + "reduction." + ) + definition_period = YEAR + unit = GBP + reference = [ + dict( + title="Income Tax (Trading and Other Income) Act 2005, s. 274AA(2), (3) and (6)", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/274AA", + ), + dict( + title="Income Tax (Trading and Other Income) Act 2005, s. 783BL", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/783BL", + ), + ] + + def formula(person, period, parameters): + # Property profits for the year; s. 118 ITA 2007 loss relief, which + # would come off them first, is not modelled. + profits = person("taxable_property_income", period) + # Adjusted total income (s. 274AA(6)): net income less savings and + # dividend income, less allowances. Property income tax stacks + # property profits within the same amount. + adjusted_total_income = max_( + 0, + person("adjusted_net_income", period) + - person("taxable_savings_interest_income", period) + - person("taxable_dividend_income", period) + - person("allowances", period), + ) + relieved = min_( + person("property_finance_costs_relievable", period), + min_(profits, adjusted_total_income), + ) + return where(person("uses_property_allowance", period), 0, max_(relieved, 0)) diff --git a/policyengine_uk/variables/input/property_finance_costs.py b/policyengine_uk/variables/input/property_finance_costs.py new file mode 100644 index 0000000000..4179c30470 --- /dev/null +++ b/policyengine_uk/variables/input/property_finance_costs.py @@ -0,0 +1,30 @@ +from policyengine_uk.model_api import * + + +class property_finance_costs(Variable): + value_type = float + entity = Person + label = "residential property finance costs" + documentation = ( + "The person's share of the year's costs of dwelling-related loans of " + "their property businesses: interest (and returns economically " + "equivalent to interest) on money borrowed for letting homes, such " + "as buy-to-let mortgages, and the incidental costs of obtaining that " + "finance. ITTOIA 2005 s. 272A stops them being deducted from property " + "profits (in full from 2020-21) and ss. 274A-274AA give a tax " + "reduction instead. property_income is measured before these costs." + ) + definition_period = YEAR + unit = GBP + reference = [ + dict( + title="Income Tax (Trading and Other Income) Act 2005, s. 272A", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/272A", + ), + dict( + title="Income Tax (Trading and Other Income) Act 2005, s. 272B", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/272B", + ), + ] + quantity_type = FLOW + uprating = "gov.economic_assumptions.indices.obr.mortgage_interest" diff --git a/policyengine_uk/variables/input/property_finance_costs_brought_forward.py b/policyengine_uk/variables/input/property_finance_costs_brought_forward.py new file mode 100644 index 0000000000..847556cb41 --- /dev/null +++ b/policyengine_uk/variables/input/property_finance_costs_brought_forward.py @@ -0,0 +1,23 @@ +from policyengine_uk.model_api import * + + +class property_finance_costs_brought_forward(Variable): + value_type = float + entity = Person + label = "residential property finance costs brought forward" + documentation = ( + "Finance costs of earlier years that have not yet been relieved and " + "are carried forward into this year (the brought-forward amount of " + "ITTOIA 2005 s. 274AA(4)). They are relieved with this year's " + "property_finance_costs. The previous year's " + "property_finance_costs_carried_forward." + ) + definition_period = YEAR + unit = GBP + reference = dict( + title="Income Tax (Trading and Other Income) Act 2005, s. 274AA(4)", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/274AA", + ) + quantity_type = FLOW + # Projected with the costs they came from. + uprating = "gov.economic_assumptions.indices.obr.mortgage_interest" diff --git a/policyengine_uk/variables/input/property_income.py b/policyengine_uk/variables/input/property_income.py index 9855400106..5ea5f90b65 100644 --- a/policyengine_uk/variables/input/property_income.py +++ b/policyengine_uk/variables/input/property_income.py @@ -4,10 +4,27 @@ class property_income(Variable): value_type = float entity = Person - label = "rental income" - documentation = "Income from rental of property" + label = "property income" + documentation = ( + "Profits of the person's UK and overseas property businesses: rents " + "and other receipts less allowable expenses, before the property " + "allowance and before any costs of dwelling-related loans (such as " + "mortgage interest on let homes), which have not been deductible " + "since 2020-21 and are relieved as a tax reduction instead " + "(property_finance_costs). This is the profit concept of the SPI's " + "net property income. Gross receipts are property_rental_income." + ) definition_period = YEAR - reference = "Income Tax (Trading and Other Income) Act 2005 s. 1(1)(b)" + reference = [ + dict( + title="Income Tax (Trading and Other Income) Act 2005, s. 268", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/268", + ), + dict( + title="Income Tax (Trading and Other Income) Act 2005, s. 272A", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/272A", + ), + ] unit = GBP quantity_type = FLOW uprating = "gov.economic_assumptions.indices.obr.per_capita.gdp" diff --git a/policyengine_uk/variables/input/property_rental_income.py b/policyengine_uk/variables/input/property_rental_income.py new file mode 100644 index 0000000000..214d861544 --- /dev/null +++ b/policyengine_uk/variables/input/property_rental_income.py @@ -0,0 +1,32 @@ +from policyengine_uk.model_api import * + + +class property_rental_income(Variable): + value_type = float + entity = Person + label = "property rental income" + documentation = ( + "Gross rents and other receipts of the person's UK and overseas " + "property businesses, before any expenses. Rent-a-room receipts are " + "not included. Optional: used only to apply the property allowance, " + "which is measured against gross receipts and replaces actual " + "expenses. Must be at least property_income; leave at zero when " + "unknown, and receipts below property_income are treated as unknown. " + "property_income remains the profit used everywhere else." + ) + definition_period = YEAR + unit = GBP + reference = [ + dict( + title="Income Tax (Trading and Other Income) Act 2005, s. 783BB", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/783BB", + ), + dict( + title="Income Tax (Trading and Other Income) Act 2005, s. 783BC", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/783BC", + ), + ] + quantity_type = FLOW + # The same index as property_income, so that uprated receipts never fall + # below uprated profit and the implied expenses keep their share. + uprating = "gov.economic_assumptions.indices.obr.per_capita.gdp" From 9d66e3e84a6d4c43e966a7eebd737ce945dd7741 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:58:23 +0100 Subject: [PATCH 2/4] List the new property inputs in the growth factors page Co-Authored-By: Claude Opus 5.5 --- docs/book/assumptions/growthfactors.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/book/assumptions/growthfactors.md b/docs/book/assumptions/growthfactors.md index 4d88c837b9..1a1d52e1ee 100644 --- a/docs/book/assumptions/growthfactors.md +++ b/docs/book/assumptions/growthfactors.md @@ -60,7 +60,7 @@ We derive these rates from OBR GDP growth and ONS population projections. |-------------|------|------|------|------|------|------|------|------| | Growth rate | 9.2% | 5.0% | 3.8% | 2.8% | 2.8% | 3.1% | 3.3% | 3.3% | -We use per capita GDP for these variables: `capital_gains`, `capital_gains_badr`, `capital_gains_before_response`, `capital_gains_carried_interest`, `capital_gains_residential_property`, `corporate_wealth`, `dividend_income`, `gross_financial_wealth`, `household_lifetime_isa_balance`, `lifetime_isa_balance`, `lump_sum_income`, `main_residence_value`, `maintenance_income`, `miscellaneous_income`, `mortgage_capital_repayment`, `net_financial_wealth`, `non_residential_property_value`, `other_investment_income`, `other_residential_property_value`, `owned_land`, `pension_income`, `private_pension_wealth`, `private_transfer_income`, `property_income`, `savings`, `savings_interest_income`, `sublet_income` +We use per capita GDP for these variables: `capital_gains`, `capital_gains_badr`, `capital_gains_before_response`, `capital_gains_carried_interest`, `capital_gains_residential_property`, `corporate_wealth`, `dividend_income`, `gross_financial_wealth`, `household_lifetime_isa_balance`, `lifetime_isa_balance`, `lump_sum_income`, `main_residence_value`, `maintenance_income`, `miscellaneous_income`, `mortgage_capital_repayment`, `net_financial_wealth`, `non_residential_property_value`, `other_investment_income`, `other_residential_property_value`, `owned_land`, `pension_income`, `private_pension_wealth`, `private_transfer_income`, `property_income`, `property_rental_income`, `savings`, `savings_interest_income`, `sublet_income` ## Council tax @@ -80,7 +80,7 @@ We use the OBR's mortgage interest rate index growth rates. |-------------|------|------|------|------|------|------|------|------| | Growth rate | 26.2% | 48.5% | 22.1% | 13.6% | 12.6% | 8.2% | 4.2% | 4.7% | -We apply this to: `mortgage_interest_repayment` +We apply this to: `mortgage_interest_repayment`, `property_finance_costs`, `property_finance_costs_brought_forward` ## Private pension index From 9112334955ff5408eb5c0491c333a542cd7e9e8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:59:09 +0100 Subject: [PATCH 3/4] Address the independent review: route choice, means tests, UC and the pre-2020 fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Choose routes one person per benefit unit at a time, with everyone else's route held fixed, repeating until nobody changes, so a partner's switch (through the Marriage Allowance) cannot flip a person's choice. - Means tests that count property income (tax credits, Housing Benefit, Income Support, Pension Credit, council tax reduction) count it after finance costs, through property_income_after_finance_costs, which replaces tax_credits_property_income. They deduct income tax net of the reduction, so counting the profit before the costs made finance costs raise their income. - The part of the reduction above the tax on property income comes off the UC deduction for tax on earnings, so that deduction never exceeds the income tax paid. - property_receipts_within_allowance decides full relief exactly, testing property_income (not the profit after deductible costs) when receipts are unknown, which fixes 2017-18 to 2019-20 and float ties at £1,000. - Separate the new UC case from the one it had been spliced into; add YAML cases for each finding and a couples property test. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2170.added.md | 2 +- .../guarantee_credit/income.yaml | 2 +- .../income/uc_earnings_deductions.yaml | 19 ++-- .../gov/dwp/tax_credits_property_income.yaml | 33 ------- .../reliefs/property_finance_cost_relief.yaml | 77 +++++++++++++++ .../property_income_after_finance_costs.yaml | 79 +++++++++++++++ .../tests/test_property_income_properties.py | 96 +++++++++++++++++++ .../housing_benefit_applicable_income.py | 2 +- .../dwp/income_support_applicable_income.py | 2 +- .../gov/dwp/tax_credits_applicable_income.py | 2 +- .../gov/dwp/tax_credits_property_income.py | 24 ----- .../income/uc_income_tax_on_earnings.py | 9 +- .../property_allowance_deduction_if_used.py | 17 ++-- .../property_receipts_within_allowance.py | 28 ++++++ .../allowances/uses_property_allowance.py | 60 +++++++++--- .../council_tax_reduction/_legacy.py | 2 +- ...council_tax_reduction_applicable_income.py | 2 +- .../property_income_after_finance_costs.py | 29 ++++++ .../variables/input/property_rental_income.py | 9 +- 19 files changed, 397 insertions(+), 97 deletions(-) delete mode 100644 policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits_property_income.yaml create mode 100644 policyengine_uk/tests/policy/baseline/household/income/property_income_after_finance_costs.yaml delete mode 100644 policyengine_uk/variables/gov/dwp/tax_credits_property_income.py create mode 100644 policyengine_uk/variables/gov/hmrc/income_tax/allowances/property_receipts_within_allowance.py create mode 100644 policyengine_uk/variables/household/income/property_income_after_finance_costs.py diff --git a/changelog.d/2170.added.md b/changelog.d/2170.added.md index 655f491092..021ba2b42b 100644 --- a/changelog.d/2170.added.md +++ b/changelog.d/2170.added.md @@ -1 +1 @@ -- Added landlords' residential finance-cost tax reduction (ITTOIA 2005 ss. 272A, 274A and 274AA) from three new optional inputs: `property_rental_income` (gross receipts), `property_finance_costs` and `property_finance_costs_brought_forward`. The reduction is the property basic rate (the basic rate before 2027-28) on the lowest of the relievable costs, the property profits and adjusted total income, is limited to the tax left at Step 5, and reports the unrelieved costs as `property_finance_costs_carried_forward`. A new `disallowed_share` parameter carries the 2017-18 to 2020-21 phase-in, and setting it to nil restores full deductibility. Universal Credit does not set the reduction against the tax on earnings, and tax credits deduct the finance costs from property income (SI 2002/2006 reg. 11(2A)). +- Added landlords' residential finance-cost tax reduction (ITTOIA 2005 ss. 272A, 274A and 274AA) from three new optional inputs: `property_rental_income` (gross receipts), `property_finance_costs` and `property_finance_costs_brought_forward`. The reduction is the property basic rate (the basic rate before 2027-28) on the lowest of the relievable costs, the property profits and adjusted total income, is limited to the tax left at Step 5, and reports the unrelieved costs as `property_finance_costs_carried_forward`. A new `disallowed_share` parameter carries the 2017-18 to 2020-21 phase-in, and setting it to nil restores full deductibility. Universal Credit sets the reduction against the tax on property income, not the tax on earnings, and the means tests that count property income (tax credits, Housing Benefit, Income Support, Pension Credit and council tax reduction) count it after finance costs. diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/income.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/income.yaml index 474274eadb..3da4354bd1 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/income.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/income.yaml @@ -16,7 +16,7 @@ values: - working_tax_credit - state_pension - private_pension_income - - property_income + - property_income_after_finance_costs - pension_credit_deemed_income - esa_contrib - jsa_contrib diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml index b7d4bb9b48..d5e2aef012 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml @@ -50,6 +50,16 @@ uc_national_insurance_on_earnings: [594.40, 0] # 20,000 - 1,486 - 594.40. uc_individual_earned_income: [17_919.60, 0] + # Less the work allowance of 427 x 12 = 5,124. + uc_earned_income: 12_795.60 + # Rent from property is not UC unearned income (reg. 66(1) is a closed + # list). + uc_unearned_income: 0 + # 424.90 x 12 + 303.94 x 12 + 9,600 = 5,098.80 + 3,647.28 + 9,600. + uc_maximum_amount: 18_346.08 + # 18,346.08 - 0.55 x 12,795.60. Deducting all of the tax, as before this + # fix, gave 11,748.50: 0.55 x 800 = 440 too much. + universal_credit: 11_308.50 - name: The finance-cost reduction comes off the tax on property, not on earnings period: 2026 @@ -81,15 +91,10 @@ income_tax: [1_886, 0] uc_income_tax_on_earnings: [1_486, 0] uc_individual_earned_income: [17_919.60, 0] - # Less the work allowance of 427 x 12 = 5,124. uc_earned_income: 12_795.60 - # Rent from property is not UC unearned income (reg. 66(1) is a closed - # list). - uc_unearned_income: 0 - # 424.90 x 12 + 303.94 x 12 + 9,600 = 5,098.80 + 3,647.28 + 9,600. uc_maximum_amount: 18_346.08 - # 18,346.08 - 0.55 x 12,795.60. Deducting all of the tax, as before this - # fix, gave 11,748.50: 0.55 x 800 = 440 too much. + # Unchanged by the finance costs. Taking the 600 reduction off the tax on + # earnings would give 10,978.50: 0.55 x 600 = 330 less. universal_credit: 11_308.50 - name: Mixed-age couple, the pensioner's tax on State Pension does not reduce the partner's earnings diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits_property_income.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits_property_income.yaml deleted file mode 100644 index 18cb1db809..0000000000 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits_property_income.yaml +++ /dev/null @@ -1,33 +0,0 @@ -# Tax credits count property profits with the costs of dwelling-related loans -# deducted: The Tax Credits (Definition and Calculation of Income) Regulations -# 2002 reg. 11(2A) disregards the ITTOIA 2005 s. 272A restriction. Property -# income is "annual taxable profits" (reg. 11(1)), so costs above the profit -# leave nil rather than a loss. - -- name: Finance costs are deducted from property income for tax credits - period: 2023 - absolute_error_margin: 0.01 - input: - property_income: 14_000 - property_finance_costs: 8_000 - output: - tax_credits_property_income: 6_000 - # 6,000 less the 300 disregard on non-earned income. - tax_credits_applicable_income: 5_700 - -- name: Finance costs above the profit leave nil - period: 2023 - absolute_error_margin: 0.01 - input: - property_income: 3_000 - property_finance_costs: 8_000 - output: - tax_credits_property_income: 0 - -- name: Without finance costs property income is unchanged - period: 2023 - absolute_error_margin: 0.01 - input: - property_income: 3_000 - output: - tax_credits_property_income: 3_000 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 aa1be63c90..f4641e3238 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 @@ -220,6 +220,24 @@ # (13,000 - 12,570) x 19% + 171 - 180 income_tax: 72.70 property_finance_costs_carried_forward: 1_100 + # The 9 of the reduction above the tax on the property comes off the tax + # on earnings, as it does in income tax: 81.70 - 9. + uc_income_tax_on_earnings: 72.70 + +- name: Receipts of exactly the allowance keep full relief on a tie + period: 2025 + absolute_error_margin: 0.01 + input: + employment_income: 30_000 + property_rental_income: 1_000 + property_income: 1_000 + property_finance_costs: 2_000 + output: + # Electing out: 1,000 x 20% - 20% x 1,000 = 0, the same as full relief. + property_receipts_within_allowance: true + uses_property_allowance: true + taxable_property_income: 0 + property_finance_cost_relief: 0 - name: Half of the finance costs are deductible in 2018-19 period: 2018 @@ -236,6 +254,25 @@ # (30,000 - 11,850) x 20% + 10,000 x 20% - 800 income_tax: 4_830 +- name: Without receipts, a profit above the allowance before 2020-21 gets no full relief + period: 2018 + absolute_error_margin: 0.01 + input: + employment_income: 30_000 + property_income: 1_500 + property_finance_costs: 1_000 + output: + # Receipts are at least the 1,500 of profit before finance costs, so + # above the allowance, although the profit after the 500 still + # deductible is 1,000. + property_receipts_within_allowance: false + uses_property_allowance: false + taxable_property_income: 1_000 + # 20% x min(500 disallowed, 1,000 profit). + property_finance_cost_relief: 100 + # (30,000 - 11,850) x 20% + 1,000 x 20% - 100 + income_tax: 3_730 + - name: Finance costs are fully deductible before 2017-18 period: 2016 absolute_error_margin: 0.01 @@ -263,3 +300,43 @@ property_finance_cost_relief: 0 # (30,000 - 12,570) x 20% + 6,000 x 20% income_tax: 4_686 + +- name: Each partner chooses with the other's route held fixed + period: 2025 + absolute_error_margin: 0.01 + input: + people: + landlord: + age: 40 + employment_income: 30_000 + property_rental_income: 5_000 + property_income: 4_800 + property_finance_costs: 850 + partner: + age: 40 + employment_income: 10_500 + property_rental_income: 900 + property_income: 900 + property_finance_costs: 1_000 + benunits: + benunit: + members: [landlord, partner] + is_married: true + households: + household: + members: [landlord, partner] + output: + # The partner keeps full relief: electing out leaves their tax at nil + # either way (11,400 is within the personal allowance). With it, they + # have 12,570 - 10,500 = 2,070 of allowance unused and transfer the + # maximum 1,260 of Marriage Allowance. + uses_property_allowance: [false, true] + marriage_allowance: [1_260, 0] + # The landlord's own choice, with the partner's held fixed: actual + # expenses give 4,800 x 20% - 20% x 850 = 790 of tax on property against + # (5,000 - 1,000) x 20% = 800 with the allowance. Switching both at once + # would also cut the transfer to 1,170 and make the allowance look + # better. + property_finance_cost_relief: [170, 0] + # (30,000 - 12,570 - 1,260) x 20% + 4,800 x 20% - 170 + income_tax: [4_024, 0] diff --git a/policyengine_uk/tests/policy/baseline/household/income/property_income_after_finance_costs.yaml b/policyengine_uk/tests/policy/baseline/household/income/property_income_after_finance_costs.yaml new file mode 100644 index 0000000000..902f7720a1 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/household/income/property_income_after_finance_costs.yaml @@ -0,0 +1,79 @@ +# Means tests count property income after the costs of dwelling-related +# loans. Tax credits do so by statute: The Tax Credits (Definition and +# Calculation of Income) Regulations 2002 reg. 11(2A) disregards the ITTOIA +# 2005 s. 272A restriction, and reg. 11(1) counts "annual taxable profits", so +# costs above the profit leave nil rather than a loss. The legacy means tests +# deduct income tax, which the finance-cost reduction lowers, so they count +# the profit after the costs too. + +- name: Finance costs are deducted from property income for tax credits + period: 2023 + absolute_error_margin: 0.01 + input: + property_income: 14_000 + property_finance_costs: 8_000 + output: + property_income_after_finance_costs: 6_000 + # 6,000 less the 300 disregard on non-earned income. + tax_credits_applicable_income: 5_700 + +- name: Finance costs above the profit leave nil + period: 2023 + absolute_error_margin: 0.01 + input: + property_income: 3_000 + property_finance_costs: 8_000 + output: + property_income_after_finance_costs: 0 + +- name: Without finance costs property income is unchanged + period: 2023 + absolute_error_margin: 0.01 + input: + property_income: 3_000 + output: + property_income_after_finance_costs: 3_000 + +- name: A property loss is unchanged + period: 2023 + absolute_error_margin: 0.01 + input: + property_income: -500 + property_finance_costs: 1_000 + output: + property_income_after_finance_costs: -500 + +- name: Housing Benefit, no finance costs + period: 2026 + absolute_error_margin: 0.01 + input: + age: 70 + employment_income: 16_000 + state_pension: 12_000 + property_income: 6_000 + tenure_type: RENT_FROM_COUNCIL + rent: 12_000 + output: + # (16,000 + 12,000 + 6,000 - 12,570) x 20%. + income_tax: 4_286 + # 16,000 + 12,000 + 6,000 - 4,286, less the 5 a week earnings disregard. + housing_benefit_applicable_income: 29_454 + +- name: Housing Benefit, finance costs equal to the profit do not lower it + period: 2026 + absolute_error_margin: 0.01 + input: + age: 70 + employment_income: 16_000 + state_pension: 12_000 + property_income: 6_000 + property_finance_costs: 6_000 + tenure_type: RENT_FROM_COUNCIL + rent: 12_000 + output: + # 4,286 less the 20% x 6,000 reduction. + income_tax: 3_086 + property_income_after_finance_costs: 0 + # 16,000 + 12,000 - 3,086 - 260: the property income nets to nil, so the + # lower tax does not raise the income counted. + housing_benefit_applicable_income: 24_654 diff --git a/policyengine_uk/tests/test_property_income_properties.py b/policyengine_uk/tests/test_property_income_properties.py index cab1afda22..6b2584f2aa 100644 --- a/policyengine_uk/tests/test_property_income_properties.py +++ b/policyengine_uk/tests/test_property_income_properties.py @@ -261,3 +261,99 @@ def test_income_tax_is_non_increasing_in_finance_costs(sim, drawn): r = run(sim, drawn) base, more = r["income_tax"][:CASES], r["income_tax"][CASES:] assert (more <= base + tolerance(base)).all() + + +# Couples: a partner's route can move a person's tax through the Marriage +# Allowance, so each person must choose with the other's route held fixed. +COUPLES = 12 +COUPLE_EMPLOYMENT_INCOME = [0, 9_000, 11_000, 20_000, 30_000, 45_000, 60_000] +couple_member = st.tuples( + st.sampled_from(COUPLE_EMPLOYMENT_INCOME), + st.one_of(amount, st.floats(min_value=-1_000, max_value=0)), # profit + amount, # expenses + st.sampled_from([0.0, 300.0, 850.0, 1_500.0, 6_000.0]), # finance costs + st.sampled_from(["unknown", "consistent", "consistent"]), +) +# A close call: the allowance's value over actual expenses (1,000 - expenses) +# within 150 of the finance costs it would give up. +close_call = st.tuples( + st.sampled_from([20_000, 30_000, 45_000]), + st.floats(min_value=1_500, max_value=20_000), # profit + st.floats(min_value=0, max_value=950), # expenses + st.floats(min_value=-150, max_value=150), # finance costs less the gap +).map(lambda m: (m[0], m[1], m[2], max(1_000 - m[2] + m[3], 1.0), "consistent")) +# A partner within the personal allowance with receipts within the property +# allowance and finance costs, whose route moves their unused allowance. +transferor = st.tuples( + st.floats(min_value=8_000, max_value=12_500), # employment income + st.floats(min_value=50, max_value=1_000), # profit = receipts + st.floats(min_value=50, max_value=3_000), # finance costs +).map(lambda m: (m[0], m[1], 0.0, m[2], "consistent")) +couples = st.lists( + st.one_of( + st.tuples(couple_member, couple_member), + st.tuples(close_call, transferor), + ), + min_size=COUPLES, + max_size=COUPLES, +) + + +@pytest.fixture(scope="module") +def couple_sim(): + people, benunits, households = {}, {}, {} + for c in range(COUPLES): + members = [] + for k in range(2): + name = f"person_{c}_{k}" + members.append(name) + people[name] = { + "age": {YEAR: 40}, + "employment_income": {YEAR: 0}, + "property_income": {YEAR: 0}, + "property_rental_income": {YEAR: 0}, + "property_finance_costs": {YEAR: 0}, + } + benunits[f"benunit_{c}"] = {"members": members, "is_married": {YEAR: True}} + households[f"household_{c}"] = {"members": members} + return Simulation( + situation={"people": people, "benunits": benunits, "households": households} + ) + + +def set_couple_inputs(sim, drawn, uses_allowance=None): + members = [member for couple in drawn for member in couple] + employment, profit, expenses, costs, mode = map(np.array, zip(*members)) + receipts = np.where(mode == "consistent", np.maximum(profit, 0) + expenses, 0) + # The simulation moves employment_income to employment_income_before_lsr + # when it is built, so that is the input to set. + sim.set_input("employment_income_before_lsr", YEAR, employment.astype(float)) + sim.set_input("property_income", YEAR, profit) + sim.set_input("property_rental_income", YEAR, receipts) + sim.set_input("property_finance_costs", YEAR, costs) + sim.reset_calculations() + if uses_allowance is not None: + sim.set_input("uses_property_allowance", YEAR, uses_allowance) + + +@SETTINGS +@given(couples) +def test_no_one_lowers_their_tax_by_switching_route_alone(couple_sim, drawn): + set_couple_inputs(couple_sim, drawn) + routes = couple_sim.calculate("uses_property_allowance", YEAR) + income_tax = couple_sim.calculate("income_tax", YEAR).astype(float) + profit = couple_sim.calculate("property_income", YEAR).astype(float) + choosing = ( + (couple_sim.calculate("property_allowance_deduction_if_used", YEAR) > 0) + & (couple_sim.calculate("property_finance_costs_relievable", YEAR) > 0) + & (profit > 0) + ) + position = np.arange(2 * COUPLES) % 2 + for k in range(2): + switching = choosing & (position == k) + if not switching.any(): + continue + set_couple_inputs(couple_sim, drawn, routes ^ switching) + switched_tax = couple_sim.calculate("income_tax", YEAR).astype(float) + margin = tolerance(income_tax, switched_tax) + assert (switched_tax[switching] >= (income_tax - margin)[switching]).all() diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py index 6119abf2e1..8807c5ed6a 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py @@ -66,7 +66,7 @@ def formula(benunit, period, parameters): INCOME_COMPONENTS = [ "employment_income", "self_employment_income", - "property_income", + "property_income_after_finance_costs", "private_pension_income", ] bi = parameters(period).gov.contrib.ubi_center.basic_income diff --git a/policyengine_uk/variables/gov/dwp/income_support_applicable_income.py b/policyengine_uk/variables/gov/dwp/income_support_applicable_income.py index 17aa562d36..eea502562e 100644 --- a/policyengine_uk/variables/gov/dwp/income_support_applicable_income.py +++ b/policyengine_uk/variables/gov/dwp/income_support_applicable_income.py @@ -22,7 +22,7 @@ def formula(benunit, period, parameters): INCOME_COMPONENTS = [ "employment_income", "self_employment_income", - "property_income", + "property_income_after_finance_costs", "private_pension_income", ] bi = parameters(period).gov.contrib.ubi_center.basic_income diff --git a/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py b/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py index c20f8820e9..0d3a4cc2c2 100644 --- a/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py +++ b/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py @@ -25,7 +25,7 @@ def formula(benunit, period, parameters): "private_pension_income", "savings_interest_income", "dividend_income", - "tax_credits_property_income", + "property_income_after_finance_costs", ] income = add_for_members(benunit, period, STEP_1_COMPONENTS, members) income = max_(income - TC.means_test.non_earned_disregard, 0) diff --git a/policyengine_uk/variables/gov/dwp/tax_credits_property_income.py b/policyengine_uk/variables/gov/dwp/tax_credits_property_income.py deleted file mode 100644 index 9eb88a6d22..0000000000 --- a/policyengine_uk/variables/gov/dwp/tax_credits_property_income.py +++ /dev/null @@ -1,24 +0,0 @@ -from policyengine_uk.model_api import * - - -class tax_credits_property_income(Variable): - value_type = float - entity = Person - label = "property income for tax credits" - documentation = ( - "Property income as tax credits count it: property_income less the " - "costs of dwelling-related loans, which the tax credit rules deduct " - "in full although income tax does not. Finance costs above the " - "profit leave nil, not a loss set against other income." - ) - definition_period = YEAR - unit = GBP - reference = dict( - title="The Tax Credits (Definition and Calculation of Income) Regulations 2002 reg. 11(1) and (2A)", - href="https://www.legislation.gov.uk/uksi/2002/2006/regulation/11", - ) - - def formula(person, period, parameters): - profit = person("property_income", period) - finance_costs = person("property_finance_costs", period) - return where(profit > 0, max_(profit - finance_costs, 0), profit) 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 af9bb47d66..88ba53027c 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 @@ -94,7 +94,9 @@ def formula(person, period, parameters): # move when other income absorbs more or less of a reduction. The # exception is the finance-cost reduction, which is given on property # profits and limited by them (ITTOIA 2005 s. 274AA), so it comes off - # the tax on property income and never off the tax on earnings. + # the tax on property income first. Only a part larger than that tax + # (possible where Scottish rates on property income are below the + # reduction's rate) comes off the tax on earnings. reductions = add( person, period, @@ -104,4 +106,9 @@ def formula(person, period, parameters): if variable != "property_finance_cost_relief" ], ) + reductions += max_( + 0, + person("property_finance_cost_relief", period) + - person("property_income_tax", period), + ) return max_(0, tax - reductions) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/property_allowance_deduction_if_used.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/property_allowance_deduction_if_used.py index 926dfd2560..bc6c3d2f6d 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/property_allowance_deduction_if_used.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/property_allowance_deduction_if_used.py @@ -53,10 +53,15 @@ def formula(person, period, parameters): receipts >= person("property_income", period) ) # Full relief (receipts within the allowance, s. 783BF) makes the - # profit nil; partial relief (s. 783BH) replaces expenses with the - # allowance. Both come to the allowance's excess over actual - # expenses, capped at profit. - relief_with_receipts = min_(profit, allowance - expenses) - relief_without_receipts = where(profit <= allowance, profit, 0) - relief = where(receipts_known, relief_with_receipts, relief_without_receipts) + # profit nil. Partial relief (s. 783BH) replaces actual expenses with + # the allowance, which comes to the allowance's excess over them, + # capped at profit; without receipts the expenses are unknown, and a + # profit above the allowance is taken to reflect the better of the + # two already. + partial_relief = where(receipts_known, min_(profit, allowance - expenses), 0) + relief = where( + person("property_receipts_within_allowance", period), + profit, + partial_relief, + ) return max_(relief, 0) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/property_receipts_within_allowance.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/property_receipts_within_allowance.py new file mode 100644 index 0000000000..2c6b6a3c7d --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/property_receipts_within_allowance.py @@ -0,0 +1,28 @@ +from policyengine_uk.model_api import * + + +class property_receipts_within_allowance(Variable): + value_type = bool + entity = Person + label = "Property receipts are within the property allowance" + documentation = ( + "Whether the person's relevant property income (gross receipts) does " + "not exceed the property allowance, so that full relief applies unless " + "the person elects out of it. Without gross receipts, a " + "property_income within the allowance is taken as coming from " + "receipts within it. property_income is measured before finance " + "costs, so receipts are never below it." + ) + definition_period = YEAR + reference = dict( + title="Income Tax (Trading and Other Income) Act 2005, s. 783BE", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/783BE", + ) + + def formula(person, period, parameters): + allowance = parameters(period).gov.hmrc.income_tax.allowances.property_allowance + profit = person("property_income", period) + receipts = person("property_rental_income", period) + # Receipts below profit (including the default zero) mean unknown. + receipts_known = (receipts > 0) & (receipts >= profit) + return where(receipts_known, receipts <= allowance, profit <= allowance) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_property_allowance.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_property_allowance.py index de5792761e..6e77ce8650 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_property_allowance.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_property_allowance.py @@ -13,6 +13,10 @@ "pays_scottish_income_tax", "other_tax_credits", ] +# Rounds of decisions within a benefit unit before the routes are taken as +# settled. Partners' routes interact only through small amounts (such as the +# Marriage Allowance), so a second round rarely changes anything. +MAX_ROUNDS = 3 def income_tax_with_route(simulation, name, period, uses_allowance): @@ -42,7 +46,10 @@ class uses_property_allowance(Variable): "two cannot be combined. Full relief applies to receipts within the " "allowance unless the person elects out of it, and partial relief " "only if they elect for it; each election is made only if it lowers " - "the person's income tax. Without finance costs to relieve, the " + "the person's own income tax, with everyone else's route held fixed. " + "Ties keep the default, so a person with receipts within the " + "allowance keeps full relief even where electing out would carry " + "finance costs forward. Without finance costs to relieve, the " "allowance is used whenever it lowers taxable property income." ) definition_period = YEAR @@ -83,21 +90,44 @@ def formula(person, period, parameters): if not choosing.any(): # Nobody has a choice to weigh: skip the branches. return uses + # Full relief applies unless electing out of it (s. 783BJ) lowers + # tax; partial relief applies only if electing for it (s. 783BK) + # lowers tax. Until a person decides, they keep that default. + full_relief = person("property_receipts_within_allowance", period) + route = where(choosing, full_relief, uses) for variable in INDEPENDENT_OF_ROUTE: person(variable, period) simulation = person.simulation - tax_using = income_tax_with_route( - simulation, "property_allowance_used", period, uses | choosing + # One person per benefit unit decides at a time, with everyone + # else's route held fixed: a partner's route can move this person's + # tax (through the Marriage Allowance, for example), and comparing + # both partners' switches at once would count that. Rounds repeat + # until nobody changes. + order = person.get_rank( + person.benunit, np.zeros(person.count), condition=choosing ) - tax_not_using = income_tax_with_route( - simulation, "property_allowance_not_used", period, uses - ) - # Tax is computed to the penny; smaller differences are rounding. - saving = np.round(tax_not_using - tax_using, 2) - # The allowance removes all of the profit exactly when receipts are - # within it. Full relief then applies unless electing out of it - # (s. 783BJ) lowers tax; partial relief applies only if electing for - # it (s. 783BK) lowers tax. - full_relief = deduction >= profit - prefers_allowance = where(full_relief, saving >= 0, saving > 0) - return where(choosing, prefers_allowance, uses) + for _ in range(MAX_ROUNDS): + changed = False + for rank in range(int(order.max()) + 1): + deciding = choosing & (order == rank) + tax_using = income_tax_with_route( + simulation, "property_allowance_used", period, route | deciding + ) + tax_not_using = income_tax_with_route( + simulation, + "property_allowance_not_used", + period, + route & ~deciding, + ) + # Tax is computed to the penny; smaller differences are + # rounding. + saving = np.round(tax_not_using - tax_using, 2) + prefers_allowance = where(full_relief, saving >= 0, saving > 0) + decided = where(deciding, prefers_allowance, route) + changed = changed or bool((decided != route).any()) + route = decided + # With one person deciding per benefit unit, everyone else's + # route was fixed throughout, so one round settles it. + if order.max() == 0 or not changed: + break + return route diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py index a970b6995b..c6bf7bb2c0 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py @@ -129,7 +129,7 @@ def normal_gross_income_non_dep_deduction( gross_income_components = [ "employment_income", "self_employment_income", - "property_income", + "property_income_after_finance_costs", "private_pension_income", "savings_interest_income", "dividend_income", diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py index b86cabc27d..01fdaaedb1 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py @@ -77,7 +77,7 @@ def formula(benunit, period, parameters): income_components = [ "employment_income", "self_employment_income", - "property_income", + "property_income_after_finance_costs", "private_pension_income", ] bi = parameters(period).gov.contrib.ubi_center.basic_income diff --git a/policyengine_uk/variables/household/income/property_income_after_finance_costs.py b/policyengine_uk/variables/household/income/property_income_after_finance_costs.py new file mode 100644 index 0000000000..1fbfc37134 --- /dev/null +++ b/policyengine_uk/variables/household/income/property_income_after_finance_costs.py @@ -0,0 +1,29 @@ +from policyengine_uk.model_api import * + + +class property_income_after_finance_costs(Variable): + value_type = float + entity = Person + label = "property income after finance costs" + documentation = ( + "property_income less the year's costs of dwelling-related loans " + "(property_finance_costs): the landlord's profit after mortgage " + "interest. Income tax does not deduct these costs, and gives the " + "finance-cost tax reduction instead, but the means tests that count " + "property income count it after them. Tax credits do so by statute. " + "The legacy means tests also deduct the person's income tax, which " + "the reduction lowers, so counting the profit before the costs would " + "make finance costs raise the income they count. Costs above the " + "profit leave nil, not a loss set against other income." + ) + definition_period = YEAR + unit = GBP + reference = dict( + title="The Tax Credits (Definition and Calculation of Income) Regulations 2002 reg. 11(1) and (2A)", + href="https://www.legislation.gov.uk/uksi/2002/2006/regulation/11", + ) + + def formula(person, period, parameters): + profit = person("property_income", period) + finance_costs = person("property_finance_costs", period) + return where(profit > 0, max_(profit - finance_costs, 0), profit) diff --git a/policyengine_uk/variables/input/property_rental_income.py b/policyengine_uk/variables/input/property_rental_income.py index 214d861544..8f24dbf137 100644 --- a/policyengine_uk/variables/input/property_rental_income.py +++ b/policyengine_uk/variables/input/property_rental_income.py @@ -4,15 +4,16 @@ class property_rental_income(Variable): value_type = float entity = Person - label = "property rental income" + label = "gross property rental receipts" documentation = ( "Gross rents and other receipts of the person's UK and overseas " "property businesses, before any expenses. Rent-a-room receipts are " "not included. Optional: used only to apply the property allowance, " "which is measured against gross receipts and replaces actual " - "expenses. Must be at least property_income; leave at zero when " - "unknown, and receipts below property_income are treated as unknown. " - "property_income remains the profit used everywhere else." + "expenses. It does not replace property_income, the profit that is " + "taxed: set both. Must be at least property_income; leave at zero " + "when unknown, and receipts below property_income are treated as " + "unknown." ) definition_period = YEAR unit = GBP From 747a4a3786988c4a25b0c973a7ce65c0908e36d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:47:14 +0100 Subject: [PATCH 4/4] Take only Step 2 reliefs and Step 3 allowances off adjusted total income ITTOIA 2005 s. 274AA(6) defines adjusted total income as net income, less savings and dividend income, less the allowances deducted at Step 3 of ITA 2007 s. 23 (the personal and blind person's allowances). The model's `allowances` also holds Gift Aid, covenanted payments and pension relief, which extend the basic rate band instead of reducing net income, so subtracting them understated the costs relieved and overstated the carry-forward. Gifts of shares to charity and other Step 2 deductions still come off net income. Adds a YAML case with Gift Aid (review round 1, item 1). Co-Authored-By: Claude Opus 5.5 --- .../reliefs/property_finance_cost_relief.yaml | 18 +++++++++++++ .../tests/test_property_income_properties.py | 10 ++++--- .../property_finance_costs_relieved.py | 26 ++++++++++++++----- 3 files changed, 44 insertions(+), 10 deletions(-) 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 f4641e3238..4cae4c3dda 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 @@ -93,6 +93,24 @@ income_tax: 0 property_finance_costs_carried_forward: 6_570 +- name: Gift Aid does not reduce adjusted total income + period: 2026 + absolute_error_margin: 0.01 + input: + property_income: 18_000 + property_finance_costs: 8_000 + gift_aid: 3_000 + output: + # Adjusted total income is net income less the personal allowance: + # 18,000 - 12,570. Gift Aid extends the basic rate band; it is not + # 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 + income_tax: 0 + - name: A landlord within the personal allowance gets no relief and keeps the costs period: 2025 absolute_error_margin: 0.01 diff --git a/policyengine_uk/tests/test_property_income_properties.py b/policyengine_uk/tests/test_property_income_properties.py index 6b2584f2aa..b4b1a59d61 100644 --- a/policyengine_uk/tests/test_property_income_properties.py +++ b/policyengine_uk/tests/test_property_income_properties.py @@ -133,7 +133,7 @@ def calc(variable): carried_forward=calc("property_finance_costs_carried_forward"), pre_charges=calc("income_tax_pre_charges"), adjusted_net_income=calc("adjusted_net_income"), - allowances=calc("allowances"), + personal_allowance=calc("personal_allowance"), income_tax=calc("income_tax"), ) @@ -205,8 +205,12 @@ def test_relief_accounting(sim, drawn): <= tolerance(r["costs"], r["brought_forward"]) ).all() # Relieved: the lowest of the relievable amount, the property profits and - # adjusted total income, on the expenses route only. - adjusted_total_income = np.maximum(0, r["adjusted_net_income"] - r["allowances"]) + # adjusted total income, on the expenses route only. The generated people + # have no savings, dividends, Step 2 reliefs or blind person's allowance, + # so adjusted total income is net income less the personal allowance. + adjusted_total_income = np.maximum( + 0, r["adjusted_net_income"] - r["personal_allowance"] + ) expected_relieved = np.where( expenses_route, np.maximum( 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 e67edf640b..1ca09de9fc 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 @@ -9,9 +9,9 @@ class property_finance_costs_relieved(Variable): "The finance costs on which the tax reduction is given this year: the " "lowest of the relievable amount, the property profits, and adjusted " "total income (net income other than savings and dividend income, " - "less allowances). Nil where the person uses the property allowance, " - "since the allowance replaces the expenses and cannot be had with the " - "reduction." + "less the personal and blind person's allowances). Nil where the " + "person uses the property allowance, since the allowance replaces the " + "expenses and cannot be had with the reduction." ) definition_period = YEAR unit = GBP @@ -30,15 +30,27 @@ def formula(person, period, parameters): # Property profits for the year; s. 118 ITA 2007 loss relief, which # would come off them first, is not modelled. profits = person("taxable_property_income", period) - # Adjusted total income (s. 274AA(6)): net income less savings and - # dividend income, less allowances. Property income tax stacks - # property profits within the same amount. + # Adjusted total income (s. 274AA(6)): net income (after the Step 2 + # reliefs, such as gifts of shares to charity), less savings and + # dividend income, less the allowances deducted at Step 3 of ITA 2007 + # s. 23, which are the personal and blind person's allowances. Gift + # Aid, covenanted payments and pension contributions relieved at + # source extend the basic rate band instead of reducing net income, + # so they do not come off; pension_contributions_relief does not + # separate contributions under net pay arrangements, which would. + step_2_reliefs = add( + person, period, ["charitable_investment_gifts", "other_deductions"] + ) + step_3_allowances = add( + person, period, ["personal_allowance", "blind_persons_allowance"] + ) adjusted_total_income = max_( 0, person("adjusted_net_income", period) - person("taxable_savings_interest_income", period) - person("taxable_dividend_income", period) - - person("allowances", period), + - step_2_reliefs + - step_3_allowances, ) relieved = min_( person("property_finance_costs_relievable", period),