diff --git a/changelog.d/2238.fixed.md b/changelog.d/2238.fixed.md new file mode 100644 index 0000000000..f6cf30904b --- /dev/null +++ b/changelog.d/2238.fixed.md @@ -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. diff --git a/policyengine_uk/data/uprating_indices.yaml b/policyengine_uk/data/uprating_indices.yaml index 7cebef7f9a..4f85c1ba5b 100644 --- a/policyengine_uk/data/uprating_indices.yaml +++ b/policyengine_uk/data/uprating_indices.yaml @@ -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. diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/property_purchased_default.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/property_purchased_default.yaml index f13d0984f6..7ccc4ccc5b 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/property_purchased_default.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/property_purchased_default.yaml @@ -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) diff --git a/policyengine_uk/tests/test_property_transaction_tax_purchase_properties.py b/policyengine_uk/tests/test_property_transaction_tax_purchase_properties.py new file mode 100644 index 0000000000..8817cb7159 --- /dev/null +++ b/policyengine_uk/tests/test_property_transaction_tax_purchase_properties.py @@ -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 + ) diff --git a/policyengine_uk/variables/household/consumption/additional_residential_property_purchased.py b/policyengine_uk/variables/household/consumption/additional_residential_property_purchased.py index 5e5487757a..2f24358ab8 100644 --- a/policyengine_uk/variables/household/consumption/additional_residential_property_purchased.py +++ b/policyengine_uk/variables/household/consumption/additional_residential_property_purchased.py @@ -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", + ) diff --git a/policyengine_uk/variables/household/consumption/non_residential_property_purchased.py b/policyengine_uk/variables/household/consumption/non_residential_property_purchased.py index 39cc2edfb9..cfbd537ac9 100644 --- a/policyengine_uk/variables/household/consumption/non_residential_property_purchased.py +++ b/policyengine_uk/variables/household/consumption/non_residential_property_purchased.py @@ -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"] diff --git a/policyengine_uk/variables/input/consumption/property/property_purchased.py b/policyengine_uk/variables/input/consumption/property/property_purchased.py index cfb64891d9..1103a35dfe 100644 --- a/policyengine_uk/variables/input/consumption/property/property_purchased.py +++ b/policyengine_uk/variables/input/consumption/property/property_purchased.py @@ -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