Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/2238.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Stop charging a household's whole stock of other residential and non-residential property as a purchase whenever it buys its main residence. `property_purchased` now sets only the main-residence purchase; `additional_residential_property_purchased` (the price of one additional dwelling, charged at the higher rates for additional dwellings) and `non_residential_property_purchased` are inputs, nil unless set directly or imputed by a dataset, and are uprated like the property stocks when a dataset supplies them.
3 changes: 3 additions & 0 deletions policyengine_uk/data/uprating_indices.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,9 @@ gov.economic_assumptions.yoy_growth.obr.per_capita.gdp:
- non_residential_property_value
- other_investment_income
- other_residential_property_value
# Purchase prices a dataset may impute; uprated like the property stocks.
- additional_residential_property_purchased
- non_residential_property_purchased
- owned_land
# A formula of benunit_reported_capital; listed so a dataset that stores it
# directly is uprated like the input.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,49 @@
property_purchased: false
main_residential_property_purchased: 0
stamp_duty_land_tax: 0

# Issue #2238 reproducer: one adult, £80,000 earnings, London, a £600,000 main
# residence and £2,000,000 of other residential property. Before the fix a
# main-residence purchase charged the whole other-property stock as one
# additional-dwelling purchase (£233,750 of stamp duty).
- name: Issue 2238 - no purchase, no stamp duty
period: 2025
input:
employment_income: 80_000
region: LONDON
main_residence_value: 600_000
other_residential_property_value: 2_000_000
property_purchased: false
output:
additional_residential_property_purchased: 0
stamp_duty_land_tax: 0

- name: Issue 2238 - buying the main residence does not charge the other-property stock
period: 2025
input:
employment_income: 80_000
region: LONDON
main_residence_value: 600_000
other_residential_property_value: 2_000_000
non_residential_property_value: 500_000
property_purchased: true
output:
main_residential_property_purchased: 600_000
additional_residential_property_purchased: 0
non_residential_property_purchased: 0
# Main rates from 1 April 2025: 2% of 125,000 to 250,000, 5% above.
stamp_duty_land_tax: 0.02 * 125_000 + 0.05 * 350_000

- name: Issue 2238 - an additional-dwelling purchase is charged on its own price
period: 2022
input:
employment_income: 80_000
region: LONDON
main_residence_value: 600_000
other_residential_property_value: 2_000_000
property_purchased: true
additional_residential_property_purchased: 400_000
output:
# Main residence as above; the £400,000 additional dwelling at the higher
# rates in force on 30 April 2022: 3% to 125,000, 5% to 250,000, 8% above.
stamp_duty_land_tax: (0.02 * 125_000 + 0.05 * 350_000) + (0.03 * 125_000 + 0.05 * 125_000 + 0.08 * 150_000)
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
"""Property-based tests for the purchases that property transaction taxes
are charged on (issue #2238).

Stamp Duty Land Tax, Land and Buildings Transaction Tax and Land Transaction
Tax are charged on a purchase's price. A household's stocks of other
residential and non-residential property are not purchases:
property_purchased sets only the main-residence purchase, and an
additional-dwelling or non-residential purchase is its own input. The
additional-dwelling input is a purchase already classified as a higher-rates
transaction: a new main home bought while keeping another dwelling, with no
replacement of a main residence, belongs there rather than in the
main-residence price (Finance Act 2003 Schedule 4ZA).

Invariants, for any generated population of households across the four
nations, with or without a main-residence purchase:

1. Stocks are not purchases: zeroing a household's other residential and
non-residential property never changes any transaction tax.
2. The main-residence flag alone never creates an additional-dwelling
purchase: with that input unset, SDLT on residential property is the
main-rates tax on the main-residence price.
3. An additional-dwelling purchase is charged on its own price at the higher
rates (nil below the £40,000 minimum), on top of the main-residence tax,
and the higher rates are never below the main rates.
4. Monotone: SDLT never falls as the additional-dwelling price rises.
"""

import numpy as np
from hypothesis import HealthCheck, given, settings
from hypothesis import strategies as st

from policyengine_uk import Simulation

PROPERTY_SETTINGS = settings(
max_examples=10,
deadline=None,
derandomize=True,
suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large],
)
YEARS = [2022, 2024, 2025, 2027]
REGIONS = ["LONDON", "NORTH_WEST", "NORTHERN_IRELAND", "SCOTLAND", "WALES"]
TAXES = [
"stamp_duty_land_tax",
"land_and_buildings_transaction_tax",
"land_transaction_tax",
"expected_sdlt",
"expected_lbtt",
"expected_ltt",
]
TOLERANCE = 0.01

household_strategy = st.fixed_dictionaries(
{
"region": st.sampled_from(REGIONS),
"main_residence_value": st.integers(0, 3_000_000),
"other_residential_property_value": st.integers(0, 5_000_000),
"non_residential_property_value": st.integers(0, 2_000_000),
"property_purchased": st.booleans(),
"main_residential_property_purchased_is_first_home": st.booleans(),
"additional_residential_property_purchased": st.integers(0, 3_000_000),
}
)
population_strategy = st.lists(household_strategy, min_size=4, max_size=12)


def _situation(households, year):
situation = {"people": {}, "benunits": {}, "households": {}}
for i, household in enumerate(households):
person = f"p{i}"
situation["people"][person] = {"age": {year: 40}}
situation["benunits"][f"b{i}"] = {"members": [person]}
situation["households"][f"h{i}"] = {
"members": [person],
**{key: {year: value} for key, value in household.items()},
}
return situation


def _calc(households, year, variables):
sim = Simulation(situation=_situation(households, year))
return sim, {v: np.array(sim.calculate(v, year), dtype=float) for v in variables}


@PROPERTY_SETTINGS
@given(population_strategy, st.sampled_from(YEARS))
def test_property_stocks_are_never_purchases(households, year):
_, with_stocks = _calc(households, year, TAXES)
without = [
{
**h,
"other_residential_property_value": 0,
"non_residential_property_value": 0,
}
for h in households
]
_, without_stocks = _calc(without, year, TAXES)
for tax in TAXES:
assert np.allclose(with_stocks[tax], without_stocks[tax], atol=TOLERANCE)


@PROPERTY_SETTINGS
@given(population_strategy, st.sampled_from(YEARS))
def test_main_residence_flag_never_creates_an_additional_purchase(households, year):
# Leave the additional-dwelling purchase unset, so a main-residence
# purchase alone decides it.
households = [
{k: v for k, v in h.items() if k != "additional_residential_property_purchased"}
for h in households
]
sim, out = _calc(
households,
year,
[
"sdlt_on_residential_property_transactions",
"main_residential_property_purchased",
"additional_residential_property_purchased",
],
)
stamp_duty = sim.tax_benefit_system.parameters(year).gov.hmrc.stamp_duty
main = stamp_duty.residential.purchase.main
price = out["main_residential_property_purchased"]
first_home = np.array(
[h["main_residential_property_purchased_is_first_home"] for h in households]
) & (price < main.first.max)
main_rates_tax = np.where(
first_home, main.first.rate.calc(price), main.subsequent.calc(price)
)
assert np.all(out["additional_residential_property_purchased"] == 0)
assert np.allclose(
out["sdlt_on_residential_property_transactions"],
main_rates_tax,
atol=TOLERANCE,
)


@PROPERTY_SETTINGS
@given(population_strategy, st.sampled_from(YEARS))
def test_additional_purchase_is_charged_on_its_own_price(households, year):
sim, with_purchase = _calc(
households, year, ["sdlt_on_residential_property_transactions"]
)
no_purchase = [
{**h, "additional_residential_property_purchased": 0} for h in households
]
_, without_purchase = _calc(
no_purchase, year, ["sdlt_on_residential_property_transactions"]
)
purchase = sim.tax_benefit_system.parameters(year).gov.hmrc.stamp_duty.residential
price = np.array(
[h["additional_residential_property_purchased"] for h in households],
dtype=float,
)
chargeable = np.where(price < purchase.purchase.additional.min, 0, price)
higher_rates_tax = purchase.purchase.additional.rate.calc(chargeable)
extra = (
with_purchase["sdlt_on_residential_property_transactions"]
- without_purchase["sdlt_on_residential_property_transactions"]
)
assert np.allclose(extra, higher_rates_tax, atol=TOLERANCE)
# The higher rates are never below the main rates on the same price.
assert np.all(
higher_rates_tax
>= purchase.purchase.main.subsequent.calc(chargeable) - TOLERANCE
)


@PROPERTY_SETTINGS
@given(population_strategy, st.sampled_from(YEARS), st.integers(1, 1_000_000))
def test_stamp_duty_never_falls_as_the_additional_price_rises(
households, year, increase
):
_, before = _calc(households, year, ["stamp_duty_land_tax"])
higher = [
{
**h,
"additional_residential_property_purchased": h[
"additional_residential_property_purchased"
]
+ increase,
}
for h in households
]
_, after = _calc(higher, year, ["stamp_duty_land_tax"])
assert np.all(
after["stamp_duty_land_tax"] >= before["stamp_duty_land_tax"] - TOLERANCE
)
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,22 @@

class additional_residential_property_purchased(Variable):
label = "Residential property bought (additional)"
documentation = "The price paid for the purchase of a residential property in the year, for use as a second home or another non-main-residence purpose. Only include the value of a single purchase."
documentation = (
"The price paid for a single residential property bought in the year "
"that is not a replacement of the household's main residence, such as "
"a second home or a property to let, so that the higher rates for "
"additional dwellings apply (Finance Act 2003 Schedule 4ZA). Only "
"include the value of a single purchase. A main-residence purchase "
"(property_purchased) does not imply one: the household's stock of "
"other residential property (other_residential_property_value) is not "
"a purchase, so this is nil unless set directly or by a dataset that "
"imputes additional-dwelling purchases."
)
entity = Household
definition_period = YEAR
value_type = float
unit = GBP

def formula(household, period):
property_purchased = household("property_purchased", period)
other_residential_property_value = household(
"other_residential_property_value", period
)
return other_residential_property_value * property_purchased
reference = dict(
title="Finance Act 2003 Schedule 4ZA",
href="https://www.legislation.gov.uk/ukpga/2003/14/schedule/4ZA",
)
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,15 @@

class non_residential_property_purchased(Variable):
label = "Non-residential property bought"
documentation = "The price paid for the purchase of a non-residential property in the year. Only include the value of a single purchase."
documentation = (
"The price paid for the purchase of a non-residential property in the "
"year. Only include the value of a single purchase. A main-residence "
"purchase (property_purchased) does not imply one: the household's "
"stock of non-residential property (non_residential_property_value) is "
"not a purchase, so this is nil unless set directly or by a dataset "
"that imputes non-residential purchases."
)
entity = Household
definition_period = YEAR
value_type = float
unit = GBP
defined_for = "property_purchased"
adds = ["non_residential_property_value"]
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,24 @@


class property_purchased(Variable):
label = "All property bought this year"
documentation = "Whether all property wealth was bought this year"
label = "Main residence bought this year"
documentation = (
"Whether the household bought its main residence this year. It sets "
"the main-residence purchase price (main_residential_property_purchased) "
"only. Purchases of additional dwellings and of non-residential "
"property are separate inputs, so a main-residence purchase never "
"charges the higher rates for additional dwellings on the household's "
"other property."
)
entity = Household
definition_period = YEAR
value_type = bool
# Fail-safe default: a household has NOT bought all its property this year.
# Fail-safe default: a household has NOT bought its home this year.
# main_residential_property_purchased is computed as
# main_residence_value * property_purchased, so a True default charges
# every household stamp duty on its full property wealth (~£370bn of
# phantom SDLT). Population datasets set this explicitly for the small
# share of genuine purchasers; the household calculator and tests set
# main_residential_property_purchased directly. False is the correct
# neutral default for any household where it is not set.
# every household stamp duty on its full main residence value. Population
# datasets set this explicitly for the small share of genuine purchasers;
# the household calculator and tests set main_residential_property_purchased
# directly. False is the correct neutral default for any household where it
# is not set.
default_value = False
Loading