From 4f48f66da616463812b61a44a70d97328fc20d7c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 12:28:14 -0400 Subject: [PATCH 01/12] Charge VAT on private school fees at current law Finance Act 2025 s.47 takes private school education, vocational training and closely related board and lodging out of the VATA 1994 Sch 9 Group 6 exemption, with effect for supplies on or after 1 January 2025 (s.49). The new gov.hmrc.vat.private_school_fees_exempt parameter records the exemption; when it is off the fees bear the standard rate. The contributed reform rate now raises the rate instead of adding to it, so setting it never charges the fees twice. private_school_vat moves to variables/gov/hmrc. Household taxes include it as a change from baseline (private_school_vat_change), as they do VAT; government tax revenue includes the level. Fixes #2318 Co-Authored-By: Claude Opus 5.5 --- changelog.d/2318.fixed.md | 1 + .../contrib/labour/private_school_vat.yaml | 5 +- .../hmrc/vat/private_school_fees_exempt.yaml | 25 ++ .../contrib/labour/private_school_vat.yaml | 65 ---- .../baseline/gov/hmrc/excise_integration.yaml | 1 + .../baseline/gov/hmrc/private_school_vat.yaml | 281 ++++++++++++++++++ .../contrib/labour/private_school_vat.py | 27 -- .../gov/hmrc/baseline_private_school_vat.py | 18 ++ .../variables/gov/hmrc/household_tax.py | 2 +- .../variables/gov/hmrc/private_school_vat.py | 48 +++ .../gov/hmrc/private_school_vat_change.py | 16 + 11 files changed, 394 insertions(+), 95 deletions(-) create mode 100644 changelog.d/2318.fixed.md create mode 100644 policyengine_uk/parameters/gov/hmrc/vat/private_school_fees_exempt.yaml delete mode 100644 policyengine_uk/tests/policy/baseline/contrib/labour/private_school_vat.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/hmrc/private_school_vat.yaml delete mode 100644 policyengine_uk/variables/contrib/labour/private_school_vat.py create mode 100644 policyengine_uk/variables/gov/hmrc/baseline_private_school_vat.py create mode 100644 policyengine_uk/variables/gov/hmrc/private_school_vat.py create mode 100644 policyengine_uk/variables/gov/hmrc/private_school_vat_change.py diff --git a/changelog.d/2318.fixed.md b/changelog.d/2318.fixed.md new file mode 100644 index 0000000000..208f45eaf4 --- /dev/null +++ b/changelog.d/2318.fixed.md @@ -0,0 +1 @@ +- Charge VAT at the standard rate on private school fees from 1 January 2025 (Finance Act 2025 ss.47 and 49), with the contributed reform rate able to raise the rate but never charge the fees twice. Household taxes now include private school VAT as a change from baseline, as they do VAT. diff --git a/policyengine_uk/parameters/gov/contrib/labour/private_school_vat.yaml b/policyengine_uk/parameters/gov/contrib/labour/private_school_vat.yaml index 499a7ef846..3689b05b78 100644 --- a/policyengine_uk/parameters/gov/contrib/labour/private_school_vat.yaml +++ b/policyengine_uk/parameters/gov/contrib/labour/private_school_vat.yaml @@ -1,6 +1,7 @@ -description: VAT rate applied to private schools. +description: Contributed reform rate of VAT on private school fees. The fees bear VAT at the higher of this rate and the statutory rate (the standard rate from 1 January 2025, when the exemption ended), so the reform can raise the rate but never charges VAT on the fees twice. values: 2000-01-01: 0 metadata: unit: /1 - label: Private school VAT rate \ No newline at end of file + period: year + label: Private school VAT rate (reform) diff --git a/policyengine_uk/parameters/gov/hmrc/vat/private_school_fees_exempt.yaml b/policyengine_uk/parameters/gov/hmrc/vat/private_school_fees_exempt.yaml new file mode 100644 index 0000000000..fd4087b020 --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/vat/private_school_fees_exempt.yaml @@ -0,0 +1,25 @@ +description: Education, vocational training and closely related board and lodging supplied by a private school are exempt from VAT if this is true. From 1 January 2025 they are not exempt, so the fees bear VAT at the standard rate. +values: + 1994-09-01: + value: true + metadata: + reference: + - title: Value Added Tax Act 1994, Schedule 9, Group 6, item 1 and note 1(a) (as enacted) + href: https://www.legislation.gov.uk/ukpga/1994/23/schedule/9/enacted + - title: Value Added Tax Act 1994, section 101(1) (commencement on 1 September 1994) + href: https://www.legislation.gov.uk/ukpga/1994/23/section/101/enacted + 2025-01-01: + value: false + metadata: + reference: + - title: Finance Act 2025, section 47 (removal of exemption for private school fees; inserts Value Added Tax Act 1994, Schedule 9, Part 3) + href: https://www.legislation.gov.uk/ukpga/2025/8/section/47/enacted + - title: Finance Act 2025, section 49 (effect for supplies on or after 1 January 2025) + href: https://www.legislation.gov.uk/ukpga/2025/8/section/49/enacted +metadata: + unit: bool + period: year + label: Private school fees exempt from VAT + reference: + - title: Value Added Tax Act 1994, Schedule 9 (exemptions) + href: https://www.legislation.gov.uk/ukpga/1994/23/schedule/9 diff --git a/policyengine_uk/tests/policy/baseline/contrib/labour/private_school_vat.yaml b/policyengine_uk/tests/policy/baseline/contrib/labour/private_school_vat.yaml deleted file mode 100644 index 43346f7cda..0000000000 --- a/policyengine_uk/tests/policy/baseline/contrib/labour/private_school_vat.yaml +++ /dev/null @@ -1,65 +0,0 @@ -- name: Returns 0 when no children - period: 2024 - input: - # Set private school VAT rate to 25% to ensure correct testing - gov.contrib.labour.private_school_vat: 0.25 - people: - adult: - age: 25 - households: - household: - members: [ - adult, - ] - output: - private_school_vat: 0 - -- name: Returns correctly with one child - period: 2024 - input: - # Set private school VAT rate to 25% to ensure correct testing - gov.contrib.labour.private_school_vat: 0.25 - # Override other default settings for easier testing - gov.simulation.private_school_vat.private_school_vat_basis: 1 - gov.simulation.private_school_vat.private_school_fees: 10_000 - people: - adult: - age: 25 - child: - age: 10 - attends_private_school: True - households: - household: - members: [ - adult, - child - ] - output: - private_school_vat: 2_500 - -- name: Returns correct value with 2+ children - period: 2024 - input: - # Set private school VAT rate to 25% to ensure correct testing - gov.contrib.labour.private_school_vat: 0.25 - # Override other default settings for easier testing - gov.simulation.private_school_vat.private_school_vat_basis: 1 - gov.simulation.private_school_vat.private_school_fees: 10_000 - people: - adult: - age: 25 - child1: - age: 10 - attends_private_school: True - child2: - age: 12 - attends_private_school: True - households: - household: - members: [ - adult, - child1, - child2 - ] - output: - private_school_vat: 5_000 \ No newline at end of file diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/excise_integration.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/excise_integration.yaml index 5e7a2a2a47..40b8974c78 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/excise_integration.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/excise_integration.yaml @@ -22,6 +22,7 @@ ni_employer: 0 non_primary_residence_wealth_tax: 0 private_school_vat: 0 + private_school_vat_change: 0 student_loan_repayments: 0 tv_licence: 0 vat: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/private_school_vat.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/private_school_vat.yaml new file mode 100644 index 0000000000..cd7957eff0 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/private_school_vat.yaml @@ -0,0 +1,281 @@ +# VAT on private school fees. VATA 1994 Sch 9 Group 6 exempted education by +# an eligible body, which included registered independent schools. Finance Act +# 2025 s.47 inserts Sch 9 Part 3, which removes private school education, +# vocational training and closely related board and lodging from the +# exemption, for supplies on or after 1 January 2025 (s.49), so the fees bear +# VAT at the standard rate. The tests fix the mean fee and the share of fees +# that bears VAT so the expected values are exact. + +- name: No pupils, no VAT + period: 2025 + input: + gov.simulation.private_school_vat.private_school_fees: 10_000 + gov.simulation.private_school_vat.private_school_vat_basis: 1 + people: + adult: + age: 25 + households: + household: + members: [adult] + output: + private_school_vat: 0 + +- name: Fees were exempt in 2024 + period: 2024 + input: + gov.simulation.private_school_vat.private_school_fees: 10_000 + gov.simulation.private_school_vat.private_school_vat_basis: 1 + people: + adult: + age: 40 + child: + age: 10 + attends_private_school: true + households: + household: + members: [adult, child] + output: + private_school_vat: 0 + +- name: Standard rate on one pupil's fees from 2025 + period: 2025 + input: + gov.simulation.private_school_vat.private_school_fees: 10_000 + gov.simulation.private_school_vat.private_school_vat_basis: 1 + people: + adult: + age: 40 + child: + age: 10 + attends_private_school: true + households: + household: + members: [adult, child] + output: + # 10,000 x 1 x 20% + private_school_vat: 2_000 + +- name: Two pupils, three quarters of fees bearing VAT + period: 2026 + input: + gov.simulation.private_school_vat.private_school_fees: 10_000 + gov.simulation.private_school_vat.private_school_vat_basis: 0.75 + people: + adult: + age: 40 + child1: + age: 10 + attends_private_school: true + child2: + age: 12 + attends_private_school: true + households: + household: + members: [adult, child1, child2] + output: + # 2 x 10,000 x 0.75 x 20% + private_school_vat: 3_000 + +- name: Only pupils count + period: 2025 + input: + gov.simulation.private_school_vat.private_school_fees: 10_000 + gov.simulation.private_school_vat.private_school_vat_basis: 1 + people: + adult: + age: 40 + child1: + age: 10 + attends_private_school: true + child2: + age: 7 + attends_private_school: false + households: + household: + members: [adult, child1, child2] + output: + private_school_vat: 2_000 + +- name: The reform rate at the standard rate does not charge the fees twice + period: 2025 + input: + gov.contrib.labour.private_school_vat: 0.2 + gov.simulation.private_school_vat.private_school_fees: 10_000 + gov.simulation.private_school_vat.private_school_vat_basis: 1 + people: + adult: + age: 40 + child: + age: 10 + attends_private_school: true + households: + household: + members: [adult, child] + output: + # max(20%, 20%), not 20% + 20% + private_school_vat: 2_000 + +- name: A reform rate below the standard rate leaves the statutory charge + period: 2025 + input: + gov.contrib.labour.private_school_vat: 0.1 + gov.simulation.private_school_vat.private_school_fees: 10_000 + gov.simulation.private_school_vat.private_school_vat_basis: 1 + people: + adult: + age: 40 + child: + age: 10 + attends_private_school: true + households: + household: + members: [adult, child] + output: + private_school_vat: 2_000 + +- name: A reform rate above the standard rate replaces it + period: 2025 + input: + gov.contrib.labour.private_school_vat: 0.25 + gov.simulation.private_school_vat.private_school_fees: 10_000 + gov.simulation.private_school_vat.private_school_vat_basis: 1 + people: + adult: + age: 40 + child: + age: 10 + attends_private_school: true + households: + household: + members: [adult, child] + output: + # max(20%, 25%) = 25% + private_school_vat: 2_500 + +- name: The reform rate applies while the fees are exempt + period: 2024 + input: + gov.contrib.labour.private_school_vat: 0.2 + gov.simulation.private_school_vat.private_school_fees: 10_000 + gov.simulation.private_school_vat.private_school_vat_basis: 1 + people: + adult: + age: 40 + child: + age: 10 + attends_private_school: true + households: + household: + members: [adult, child] + output: + private_school_vat: 2_000 + +- name: Restoring the exemption removes the charge + period: 2025 + input: + gov.hmrc.vat.private_school_fees_exempt: true + gov.simulation.private_school_vat.private_school_fees: 10_000 + gov.simulation.private_school_vat.private_school_vat_basis: 1 + people: + adult: + age: 40 + child: + age: 10 + attends_private_school: true + households: + household: + members: [adult, child] + output: + private_school_vat: 0 + +- name: The fees follow a change in the standard rate + period: 2025 + input: + gov.hmrc.vat.standard_rate: 0.22 + gov.simulation.private_school_vat.private_school_fees: 10_000 + gov.simulation.private_school_vat.private_school_vat_basis: 1 + people: + adult: + age: 40 + child: + age: 10 + attends_private_school: true + households: + household: + members: [adult, child] + output: + private_school_vat: 2_200 + +- name: No change from baseline at current law + period: 2026 + input: + people: + adult: + age: 40 + child: + age: 10 + attends_private_school: true + households: + household: + members: [adult, child] + output: + private_school_vat_change: 0 + +- name: Household taxes include the change, government taxes the level + period: 2025 + absolute_error_margin: 0.01 + input: + gov.simulation.private_school_vat.private_school_fees: 10_000 + gov.simulation.private_school_vat.private_school_vat_basis: 1 + people: + adult: + age: 40 + capital_gains_tax: 0 + employer_ni_response_capital_incidence: 0 + employer_ni_response_consumer_incidence: 0 + income_tax: 0 + national_insurance: 0 + ni_employer: 0 + student_loan_repayments: 0 + child: + age: 10 + attends_private_school: true + capital_gains_tax: 0 + employer_ni_response_capital_incidence: 0 + employer_ni_response_consumer_incidence: 0 + income_tax: 0 + national_insurance: 0 + ni_employer: 0 + student_loan_repayments: 0 + households: + household: + members: [adult, child] + # Every other head of the two totals is zero. The change is its + # current-law value (tested above); the baseline parameters keep the + # unmodified mean fee, so it is set rather than computed here. + LVT: 0 + alcohol_duty: 0 + business_rates: 0 + carbon_tax: 0 + consumer_incident_tax_revenue_change: 0 + corporate_incident_tax_revenue_change: 0 + corporate_sdlt: 0 + council_tax: 0 + domestic_rates: 0 + expected_lbtt: 0 + expected_ltt: 0 + expected_sdlt: 0 + fuel_duty: 0 + high_value_council_tax_surcharge: 0 + non_primary_residence_wealth_tax: 0 + private_school_vat_change: 0 + tobacco_duty: 0 + tv_licence: 0 + vat: 0 + vat_change: 0 + vehicle_excise_duty: 0 + wealth_tax: 0 + output: + private_school_vat: 2_000 + gov_tax: 2_000 + household_tax: 0 diff --git a/policyengine_uk/variables/contrib/labour/private_school_vat.py b/policyengine_uk/variables/contrib/labour/private_school_vat.py deleted file mode 100644 index d6e7823eec..0000000000 --- a/policyengine_uk/variables/contrib/labour/private_school_vat.py +++ /dev/null @@ -1,27 +0,0 @@ -from policyengine_uk.model_api import * - - -class private_school_vat(Variable): - label = "private school VAT" - entity = Household - definition_period = YEAR - value_type = float - unit = "currency-GBP" - - def formula(household, period, parameters): - num_private_school_pupils = add(household, period, ["attends_private_school"]) - - ps_vat_params = parameters(period).gov.simulation.private_school_vat - private_school_vat_basis = ps_vat_params.private_school_vat_basis - avg_yearly_private_school_cost = ps_vat_params.private_school_fees - - private_school_vat_rate = parameters( - period - ).gov.contrib.labour.private_school_vat - - return ( - num_private_school_pupils - * avg_yearly_private_school_cost - * private_school_vat_rate - * private_school_vat_basis - ) diff --git a/policyengine_uk/variables/gov/hmrc/baseline_private_school_vat.py b/policyengine_uk/variables/gov/hmrc/baseline_private_school_vat.py new file mode 100644 index 0000000000..5c49c2f1a9 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/baseline_private_school_vat.py @@ -0,0 +1,18 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.hmrc.private_school_vat import ( + private_school_vat_liability, +) + + +class baseline_private_school_vat(Variable): + label = "baseline private school VAT" + documentation = "private_school_vat under baseline parameters." + entity = Household + definition_period = YEAR + value_type = float + unit = "currency-GBP" + + def formula(household, period, parameters): + return private_school_vat_liability( + household, period, parameters(period).baseline.gov + ) diff --git a/policyengine_uk/variables/gov/hmrc/household_tax.py b/policyengine_uk/variables/gov/hmrc/household_tax.py index 3404b8ade9..09a0bea0e2 100644 --- a/policyengine_uk/variables/gov/hmrc/household_tax.py +++ b/policyengine_uk/variables/gov/hmrc/household_tax.py @@ -23,7 +23,7 @@ "carbon_tax", "vat_change", "capital_gains_tax", - "private_school_vat", + "private_school_vat_change", "corporate_incident_tax_revenue_change", "consumer_incident_tax_revenue_change", "employer_ni_response_capital_incidence", diff --git a/policyengine_uk/variables/gov/hmrc/private_school_vat.py b/policyengine_uk/variables/gov/hmrc/private_school_vat.py new file mode 100644 index 0000000000..c2eeee9723 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/private_school_vat.py @@ -0,0 +1,48 @@ +from policyengine_uk.model_api import * + + +def private_school_vat_rate(gov): + """VAT rate on private school fees under the parameters in `gov`. + + Finance Act 2025 s.47 takes education, vocational training and closely + related board and lodging supplied by a private school out of the VAT + Act 1994 Sch 9 Group 6 exemption (new Sch 9 Part 3), with effect for + supplies on or after 1 January 2025 (s.49), so the fees bear the + standard rate. The contributed reform rate can raise the rate on the + fees but is never charged on top of the statutory rate: the fees bear + VAT once, at the higher of the two. + """ + vat = gov.hmrc.vat + statutory_rate = 0 if vat.private_school_fees_exempt else vat.standard_rate + return max_(statutory_rate, gov.contrib.labour.private_school_vat) + + +def private_school_vat_liability(household, period, gov): + pupils = add(household, period, ["attends_private_school"]) + p = gov.simulation.private_school_vat + return ( + pupils + * p.private_school_fees + * p.private_school_vat_basis + * private_school_vat_rate(gov) + ) + + +class private_school_vat(Variable): + label = "private school VAT" + documentation = ( + "VAT on the household's private school fees: pupils times the mean " + "fee times the share of fees that bears VAT net of the schools' " + "input tax, at the standard rate from 1 January 2025." + ) + entity = Household + definition_period = YEAR + value_type = float + unit = "currency-GBP" + reference = [ + "https://www.legislation.gov.uk/ukpga/2025/8/section/47/enacted", + "https://www.legislation.gov.uk/ukpga/2025/8/section/49/enacted", + ] + + def formula(household, period, parameters): + return private_school_vat_liability(household, period, parameters(period).gov) diff --git a/policyengine_uk/variables/gov/hmrc/private_school_vat_change.py b/policyengine_uk/variables/gov/hmrc/private_school_vat_change.py new file mode 100644 index 0000000000..475ec65fc9 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/private_school_vat_change.py @@ -0,0 +1,16 @@ +from policyengine_uk.model_api import * + + +class private_school_vat_change(Variable): + label = "change in private school VAT" + documentation = ( + "Change in private school VAT from its baseline. Household taxes " + "include VAT only as a change, so this is the head they include; " + "government tax revenue includes the level." + ) + entity = Household + definition_period = YEAR + value_type = float + unit = "currency-GBP" + adds = ["private_school_vat"] + subtracts = ["baseline_private_school_vat"] From 7742c8e65be84ac40966023b095461738d30c4bd Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 12:55:47 -0400 Subject: [PATCH 02/12] Partition private school VAT into a level and a change head private_school_vat is now non-zero at current law, so it leaves the zero-at-baseline heads and stays in gov_tax only; household_tax sums the new private_school_vat_change head, which is zero at baseline, with baseline_private_school_vat as its baseline measure. A level-and-change guard mirrors vat and vat_change. The vat overlap guard becomes a disjointness property: vat's full-rate share was trained when the fees were exempt, and neither head reads the other's inputs or parameters. Adds Hypothesis properties: private_school_vat matches the statute computed independently, a reform rate at or below the statutory rate never taxes the fees twice, the head is monotone in both rates, vat and private_school_vat are disjoint, and the level, baseline and change agree. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/data/fiscal_partition.yaml | 68 ++++-- .../test_private_school_vat_properties.py | 225 ++++++++++++++++++ 2 files changed, 279 insertions(+), 14 deletions(-) create mode 100644 policyengine_uk/tests/test_private_school_vat_properties.py diff --git a/policyengine_uk/data/fiscal_partition.yaml b/policyengine_uk/data/fiscal_partition.yaml index 21bc4b9928..b64c239f87 100644 --- a/policyengine_uk/data/fiscal_partition.yaml +++ b/policyengine_uk/data/fiscal_partition.yaml @@ -74,9 +74,9 @@ totals: label: Taxes borne by the household description: >- Taxes subtracted from household net income. VAT and employer NI enter - only as reform-induced changes (vat_change and the employer NI response - incidence), because baseline VAT and employer NI are not deducted from - the incomes the model starts from. + only as reform-induced changes (vat_change, private_school_vat_change + and the employer NI response incidence), because baseline VAT and + employer NI are not deducted from the incomes the model starts from. dropped_when: gov.contrib.abolish_council_tax: - council_tax @@ -205,6 +205,11 @@ tax_heads: - head: vat route: vat_change adds vat and subtracts baseline_vat why: a change measure built from the level; the two are never in the same total + # Standard-rated from 1 January 2025: Finance Act 2025 s.47 inserts VATA + # 1994 Sch 9 Part 3, taking private school education, vocational training + # and closely related board and lodging out of the Group 6 exemption, for + # supplies on or after that date (s.49). Before it the head is zero unless + # the contributed reform rate is set. - id: private_school_vat label: VAT on private school fees variable: private_school_vat @@ -213,12 +218,8 @@ tax_heads: obr_table_3_1: Value added tax report_group: vat payer: household - totals: [gov_tax, household_tax] + totals: [gov_tax] sign: nonnegative - zero_at_baseline: expected - zero_at_baseline_note: >- - gov.contrib.labour.private_school_vat is 0 at current law in this - model, so the head is reform-only here. reads: - head: income_tax route: private_school_vat -> attends_private_school -> income_tax @@ -229,6 +230,30 @@ tax_heads: - head: council_tax route: private_school_vat -> attends_private_school -> household_benefits -> council_tax_benefit -> council_tax why: as above + - id: private_school_vat_change + label: Change in private school VAT from its baseline + variable: private_school_vat_change + entity: household + obr_line: Value added tax + obr_table_3_1: Value added tax + report_group: vat + payer: household + totals: [household_tax] + sign: any + zero_at_baseline: expected + reads: + - head: private_school_vat + route: private_school_vat_change adds private_school_vat and subtracts baseline_private_school_vat + why: a change measure built from the level; the two are never in the same total + - head: income_tax + route: private_school_vat_change -> private_school_vat -> attends_private_school -> income_tax + why: attendance is imputed from income; it decides who attends, not how much tax is counted + - head: nics_individual + route: private_school_vat_change -> private_school_vat -> attends_private_school -> (net income) -> national_insurance + why: as above + - head: council_tax + route: private_school_vat_change -> private_school_vat -> attends_private_school -> household_benefits -> council_tax_benefit -> council_tax + why: as above - id: fuel_duty label: Fuel duties variable: fuel_duty @@ -516,6 +541,7 @@ exclusions: - {variable: student_loan_repayment, kind: component, head: student_loan_repayments} # Consumption - {variable: baseline_vat, kind: baseline_measure, head: vat_change, note: "Subtracted inside vat_change."} + - {variable: baseline_private_school_vat, kind: baseline_measure, head: private_school_vat_change, note: "Subtracted inside private_school_vat_change."} - {variable: beer_duty, kind: component, head: alcohol_duty} - {variable: cider_duty, kind: component, head: alcohol_duty} - {variable: sparkling_cider_duty, kind: component, head: alcohol_duty} @@ -561,17 +587,31 @@ overlap_guards: property: >- Each allocates a different receipt by shareholding; none reads another. corporate_tax_incidence sums two of them and is excluded. + - id: private_school_vat_level_and_change + kind: level_and_change + heads: [private_school_vat, private_school_vat_change] + property: >- + Never in the same total: the level is in gov_tax and the change in + household_tax, as for vat and vat_change. private_school_vat_change + reads private_school_vat. - id: private_school_vat_and_vat - kind: possible_base_overlap + kind: disjoint_bases heads: [vat, private_school_vat] property: >- - vat applies to imputed consumption; if that consumption includes school - fees, turning on private_school_vat would tax them twice. The head is - zero at baseline, so baseline totals are unaffected; check the - consumption imputation before costing a reform that sets it. + Neither head reads the other or the other's inputs and parameters + (tested in test_private_school_vat_properties.py). vat applies one + household full-rate share to all imputed consumption, education + included. The share (full_rate_vat_expenditure_rate) is trained on ETB + VAT paid in the financial year ending 2024, when private school fees + were exempt (policyengine-uk-data datasets/imputations/vat.py), and is + the same in every year, so vat never charges the fees at the standard + rate. private_school_vat charges them once, at the higher of the + statutory and contributed reform rates. Outside the model, + policyengine-uk-data calibrates weighted vat to the OBR VAT receipts + line, which includes VAT on private school fees from 2025-26. - id: eligibility_reads kind: read_not_inclusion - heads: [tv_licence, private_school_vat] + heads: [tv_licence, private_school_vat, private_school_vat_change] property: >- These heads reach other heads of the same total only through eligibility or imputation (see each head's reads). Raising the other diff --git a/policyengine_uk/tests/test_private_school_vat_properties.py b/policyengine_uk/tests/test_private_school_vat_properties.py new file mode 100644 index 0000000000..414b2645c1 --- /dev/null +++ b/policyengine_uk/tests/test_private_school_vat_properties.py @@ -0,0 +1,225 @@ +"""VAT on private school fees is charged once, at the statutory or reform rate. + +VATA 1994 Sch 9 Group 6 exempted education supplied by an eligible body, +which included registered independent schools. Finance Act 2025 s.47 +inserts Sch 9 Part 3, which takes private school education, vocational +training and closely related board and lodging out of the exemption, for +supplies on or after 1 January 2025 (s.49). The fees then bear the standard +rate. gov.contrib.labour.private_school_vat is a contributed reform rate. + +Invariants, for any household with up to three pupils, any year from 2024 +to 2030, any mean fee, share of fees bearing VAT, standard rate and reform +rate, with the exemption at current law, restored or removed: + +- differential: private_school_vat equals pupils x fee x share x the + higher of the statutory rate (0 while the fees are exempt, the standard + rate otherwise) and the reform rate, computed here from the statute and + not from the model's formula; +- once: a reform rate at or below the statutory rate leaves + private_school_vat unchanged, so the fees are never taxed twice; +- monotone: private_school_vat never falls when the reform rate or the + standard rate rises; +- disjoint: vat (VAT on imputed consumption) is the same whatever the + private school parameters, and private_school_vat is the same whatever the + household's consumption, including education consumption; +- level and change: private_school_vat_change equals private_school_vat + minus baseline_private_school_vat; baseline_private_school_vat equals + private_school_vat in a simulation without the reform; and the change is + zero at current law. + +Every household in an example is built in one simulation per parameter +set, so the comparisons hold attendance fixed. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.system import system + +ALWAYS = "2000-01-01.2100-12-31" +CONTRIB = "gov.contrib.labour.private_school_vat" +EXEMPT = "gov.hmrc.vat.private_school_fees_exempt" +STANDARD = "gov.hmrc.vat.standard_rate" +FEES = "gov.simulation.private_school_vat.private_school_fees" +BASIS = "gov.simulation.private_school_vat.private_school_vat_basis" +# Model values are float32. +RTOL = 1e-6 + +household = st.fixed_dictionaries( + { + "pupils": st.integers(0, 3), + "other_children": st.integers(0, 2), + "consumption": st.one_of(st.just(0.0), st.floats(1_000, 100_000)), + "education_share": st.floats(0, 0.5), + } +) +parameter_set = st.fixed_dictionaries( + { + "year": st.integers(2024, 2030), + "fees": st.floats(1_000, 50_000), + "basis": st.floats(0, 1), + "standard_rate": st.one_of(st.none(), st.sampled_from([0.15, 0.2, 0.25])), + "exempt": st.one_of(st.none(), st.booleans()), + "contrib": st.one_of(st.just(0.0), st.floats(0, 0.4)), + } +) + + +def situation(households: list, year: int) -> dict: + people, benunits, hhs = {}, {}, {} + for i, h in enumerate(households): + members = [f"adult_{i}"] + people[f"adult_{i}"] = {"age": {year: 45}} + for kind, count in (("pupil", h["pupils"]), ("child", h["other_children"])): + for j in range(count): + name = f"{kind}_{i}_{j}" + people[name] = { + "age": {year: 6 + j}, + "attends_private_school": {year: kind == "pupil"}, + } + members.append(name) + benunits[f"benunit_{i}"] = {"members": members} + education = h["consumption"] * h["education_share"] + hhs[f"household_{i}"] = { + "members": members, + "education_consumption": {year: education}, + "food_and_non_alcoholic_beverages_consumption": { + year: h["consumption"] - education + }, + } + return {"people": people, "benunits": benunits, "households": hhs} + + +def reform_for(p: dict) -> dict: + reform = { + FEES: {ALWAYS: p["fees"]}, + BASIS: {ALWAYS: p["basis"]}, + CONTRIB: {ALWAYS: p["contrib"]}, + } + if p["standard_rate"] is not None: + reform[STANDARD] = {ALWAYS: p["standard_rate"]} + if p["exempt"] is not None: + reform[EXEMPT] = {ALWAYS: p["exempt"]} + return reform + + +def statutory_rate(p: dict) -> float: + """The statute's rate on the fees, independently of the model's formula.""" + year = p["year"] + exempt = p["exempt"] + if exempt is None: + # Exempt until the s.49 commencement on 1 January 2025. + exempt = year < 2025 + if exempt: + return 0.0 + if p["standard_rate"] is not None: + return p["standard_rate"] + return float(system.parameters.gov.hmrc.vat.standard_rate(f"{year}-01-01")) + + +def calc(households, p, variable, **overrides): + sim = Simulation( + situation=situation(households, p["year"]), + reform=reform_for({**p, **overrides}), + ) + return np.asarray(sim.calculate(variable, p["year"]), dtype=float) + + +def assert_close(actual, expected, message=""): + np.testing.assert_allclose(actual, expected, rtol=RTOL, atol=1e-2, err_msg=message) + + +SETTINGS = settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) + + +@SETTINGS +@given(households=st.lists(household, min_size=1, max_size=3), p=parameter_set) +def test_private_school_vat_matches_the_statute(households, p): + pupils = np.array([h["pupils"] for h in households], dtype=float) + rate = max(statutory_rate(p), p["contrib"]) + expected = pupils * p["fees"] * p["basis"] * rate + assert_close(calc(households, p, "private_school_vat"), expected) + + +@SETTINGS +@given( + households=st.lists(household, min_size=1, max_size=3), + p=parameter_set, + fraction=st.floats(0, 1), +) +def test_a_reform_rate_at_or_below_the_statute_never_taxes_twice( + households, p, fraction +): + without_reform = calc(households, p, "private_school_vat", contrib=0.0) + at_or_below = calc( + households, p, "private_school_vat", contrib=fraction * statutory_rate(p) + ) + assert_close(at_or_below, without_reform) + + +@SETTINGS +@given( + households=st.lists(household, min_size=1, max_size=3), + p=parameter_set, + rise=st.floats(0, 0.2), +) +def test_private_school_vat_never_falls_as_a_rate_rises(households, p, rise): + before = calc(households, p, "private_school_vat") + higher_contrib = calc( + households, p, "private_school_vat", contrib=p["contrib"] + rise + ) + standard = p["standard_rate"] or statutory_rate({**p, "exempt": False}) + higher_standard = calc( + households, p, "private_school_vat", standard_rate=standard + rise + ) + tolerance = RTOL * np.abs(before) + 1e-2 + assert (higher_contrib >= before - tolerance).all() + assert (higher_standard >= before - tolerance).all() + + +@SETTINGS +@given(households=st.lists(household, min_size=1, max_size=3), p=parameter_set) +def test_vat_and_private_school_vat_are_disjoint(households, p): + vat = calc(households, p, "vat") + for overrides in ( + {"exempt": True, "contrib": 0.0}, + {"exempt": False, "contrib": 0.35}, + ): + assert_close(calc(households, p, "vat", **overrides), vat, str(overrides)) + no_consumption = [ + {**h, "consumption": 0.0, "education_share": 0.0} for h in households + ] + assert_close( + calc(no_consumption, p, "private_school_vat"), + calc(households, p, "private_school_vat"), + ) + + +@SETTINGS +@given(households=st.lists(household, min_size=1, max_size=3), p=parameter_set) +def test_level_and_change(households, p): + sim = Simulation(situation=situation(households, p["year"]), reform=reform_for(p)) + level, baseline, change = ( + np.asarray(sim.calculate(v, p["year"]), dtype=float) + for v in ( + "private_school_vat", + "baseline_private_school_vat", + "private_school_vat_change", + ) + ) + assert_close(change, level - baseline) + current_law = Simulation(situation=situation(households, p["year"])) + assert_close( + baseline, + np.asarray(current_law.calculate("private_school_vat", p["year"]), dtype=float), + ) + np.testing.assert_array_equal( + current_law.calculate("private_school_vat_change", p["year"]), 0 + ) From 7f1b1451c86f063eeda3eaf0c502f70198e755e8 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 14:50:11 -0400 Subject: [PATCH 03/12] Hold assumptions fixed in the private school VAT baseline baseline_private_school_vat now applies baseline policy (the exemption, the standard rate and the reform rate) to this simulation's attendance, mean fee and share of fees bearing VAT, so a reform that changes only those assumptions (such as disabling economic assumptions, which moves the fee uprating) is no longer scored as a household tax change. YAML cases now compute the change and include reforms that raise and remove it; a grid test checks the statutory rate exhaustively across the commencement, the exemption, reform rates and standard rates. The exemption parameter starts when VAT did (Finance Act 1972 Sch 5 Group 6), so no earlier lookup reads as taxable. Co-Authored-By: Claude Opus 5.5 --- .../hmrc/vat/private_school_fees_exempt.yaml | 6 +- .../baseline/gov/hmrc/private_school_vat.yaml | 127 +++++++++++++++++- .../test_private_school_vat_properties.py | 68 ++++++++-- .../gov/hmrc/baseline_private_school_vat.py | 12 +- .../variables/gov/hmrc/private_school_vat.py | 22 ++- 5 files changed, 211 insertions(+), 24 deletions(-) diff --git a/policyengine_uk/parameters/gov/hmrc/vat/private_school_fees_exempt.yaml b/policyengine_uk/parameters/gov/hmrc/vat/private_school_fees_exempt.yaml index fd4087b020..7a462f7713 100644 --- a/policyengine_uk/parameters/gov/hmrc/vat/private_school_fees_exempt.yaml +++ b/policyengine_uk/parameters/gov/hmrc/vat/private_school_fees_exempt.yaml @@ -1,13 +1,13 @@ description: Education, vocational training and closely related board and lodging supplied by a private school are exempt from VAT if this is true. From 1 January 2025 they are not exempt, so the fees bear VAT at the standard rate. values: - 1994-09-01: + 1973-04-01: value: true metadata: reference: + - title: Finance Act 1972, Schedule 5, Group 6, item 1 (as enacted) + href: https://www.legislation.gov.uk/ukpga/1972/41/schedule/5/enacted - title: Value Added Tax Act 1994, Schedule 9, Group 6, item 1 and note 1(a) (as enacted) href: https://www.legislation.gov.uk/ukpga/1994/23/schedule/9/enacted - - title: Value Added Tax Act 1994, section 101(1) (commencement on 1 September 1994) - href: https://www.legislation.gov.uk/ukpga/1994/23/section/101/enacted 2025-01-01: value: false metadata: diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/private_school_vat.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/private_school_vat.yaml index cd7957eff0..2f04cbe4b2 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/private_school_vat.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/private_school_vat.yaml @@ -250,9 +250,7 @@ households: household: members: [adult, child] - # Every other head of the two totals is zero. The change is its - # current-law value (tested above); the baseline parameters keep the - # unmodified mean fee, so it is set rather than computed here. + # Every other head of the two totals is zero. LVT: 0 alcohol_duty: 0 business_rates: 0 @@ -268,7 +266,6 @@ fuel_duty: 0 high_value_council_tax_surcharge: 0 non_primary_residence_wealth_tax: 0 - private_school_vat_change: 0 tobacco_duty: 0 tv_licence: 0 vat: 0 @@ -276,6 +273,128 @@ vehicle_excise_duty: 0 wealth_tax: 0 output: + # The baseline applies baseline policy to this simulation's fee and share, so changing those assumptions is not a tax change. private_school_vat: 2_000 + baseline_private_school_vat: 2_000 + private_school_vat_change: 0 gov_tax: 2_000 household_tax: 0 + +- name: A higher reform rate is a household tax rise + period: 2025 + absolute_error_margin: 0.01 + input: + gov.simulation.private_school_vat.private_school_fees: 10_000 + gov.simulation.private_school_vat.private_school_vat_basis: 1 + gov.contrib.labour.private_school_vat: 0.25 + people: + adult: + age: 40 + capital_gains_tax: 0 + employer_ni_response_capital_incidence: 0 + employer_ni_response_consumer_incidence: 0 + income_tax: 0 + national_insurance: 0 + ni_employer: 0 + student_loan_repayments: 0 + child: + age: 10 + attends_private_school: true + capital_gains_tax: 0 + employer_ni_response_capital_incidence: 0 + employer_ni_response_consumer_incidence: 0 + income_tax: 0 + national_insurance: 0 + ni_employer: 0 + student_loan_repayments: 0 + households: + household: + members: [adult, child] + # Every other head of the two totals is zero. + LVT: 0 + alcohol_duty: 0 + business_rates: 0 + carbon_tax: 0 + consumer_incident_tax_revenue_change: 0 + corporate_incident_tax_revenue_change: 0 + corporate_sdlt: 0 + council_tax: 0 + domestic_rates: 0 + expected_lbtt: 0 + expected_ltt: 0 + expected_sdlt: 0 + fuel_duty: 0 + high_value_council_tax_surcharge: 0 + non_primary_residence_wealth_tax: 0 + tobacco_duty: 0 + tv_licence: 0 + vat: 0 + vat_change: 0 + vehicle_excise_duty: 0 + wealth_tax: 0 + output: + # 10,000 x (25% - 20%) + private_school_vat: 2_500 + baseline_private_school_vat: 2_000 + private_school_vat_change: 500 + gov_tax: 2_500 + household_tax: 500 + +- name: Restoring the exemption is a household tax cut + period: 2025 + absolute_error_margin: 0.01 + input: + gov.simulation.private_school_vat.private_school_fees: 10_000 + gov.simulation.private_school_vat.private_school_vat_basis: 1 + gov.hmrc.vat.private_school_fees_exempt: true + people: + adult: + age: 40 + capital_gains_tax: 0 + employer_ni_response_capital_incidence: 0 + employer_ni_response_consumer_incidence: 0 + income_tax: 0 + national_insurance: 0 + ni_employer: 0 + student_loan_repayments: 0 + child: + age: 10 + attends_private_school: true + capital_gains_tax: 0 + employer_ni_response_capital_incidence: 0 + employer_ni_response_consumer_incidence: 0 + income_tax: 0 + national_insurance: 0 + ni_employer: 0 + student_loan_repayments: 0 + households: + household: + members: [adult, child] + # Every other head of the two totals is zero. + LVT: 0 + alcohol_duty: 0 + business_rates: 0 + carbon_tax: 0 + consumer_incident_tax_revenue_change: 0 + corporate_incident_tax_revenue_change: 0 + corporate_sdlt: 0 + council_tax: 0 + domestic_rates: 0 + expected_lbtt: 0 + expected_ltt: 0 + expected_sdlt: 0 + fuel_duty: 0 + high_value_council_tax_surcharge: 0 + non_primary_residence_wealth_tax: 0 + tobacco_duty: 0 + tv_licence: 0 + vat: 0 + vat_change: 0 + vehicle_excise_duty: 0 + wealth_tax: 0 + output: + private_school_vat: 0 + baseline_private_school_vat: 2_000 + private_school_vat_change: -2_000 + gov_tax: 0 + household_tax: -2_000 diff --git a/policyengine_uk/tests/test_private_school_vat_properties.py b/policyengine_uk/tests/test_private_school_vat_properties.py index 414b2645c1..2689503ce0 100644 --- a/policyengine_uk/tests/test_private_school_vat_properties.py +++ b/policyengine_uk/tests/test_private_school_vat_properties.py @@ -19,19 +19,30 @@ private_school_vat unchanged, so the fees are never taxed twice; - monotone: private_school_vat never falls when the reform rate or the standard rate rises; -- disjoint: vat (VAT on imputed consumption) is the same whatever the +- separate inputs: vat (VAT on imputed consumption) is the same whatever the private school parameters, and private_school_vat is the same whatever the - household's consumption, including education consumption; + household's consumption, including education consumption. This shows + neither head reads the other's parameters or inputs; it does not show the + bases are separate, since vat's base includes education consumption (see + the private_school_vat_and_vat guard in data/fiscal_partition.yaml); - level and change: private_school_vat_change equals private_school_vat minus baseline_private_school_vat; baseline_private_school_vat equals - private_school_vat in a simulation without the reform; and the change is - zero at current law. + private_school_vat under baseline policy with this simulation's mean fee + and share of fees bearing VAT; so a reform that changes only those + assumptions changes nothing, and the change is zero at current law. + +A grid test checks the differential exhaustively over years either side of +the commencement, every setting of the exemption, reform rates below, at +and above the standard rate, and two standard rates. Every household in an example is built in one simulation per parameter set, so the comparisons hold attendance fixed. """ import numpy as np +import itertools + +import pytest from hypothesis import HealthCheck, given, settings from hypothesis import strategies as st @@ -186,7 +197,7 @@ def test_private_school_vat_never_falls_as_a_rate_rises(households, p, rise): @SETTINGS @given(households=st.lists(household, min_size=1, max_size=3), p=parameter_set) -def test_vat_and_private_school_vat_are_disjoint(households, p): +def test_vat_and_private_school_vat_read_separate_inputs(households, p): vat = calc(households, p, "vat") for overrides in ( {"exempt": True, "contrib": 0.0}, @@ -215,11 +226,50 @@ def test_level_and_change(households, p): ) ) assert_close(change, level - baseline) - current_law = Simulation(situation=situation(households, p["year"])) + # Baseline policy with this simulation's assumptions. + assumptions_only = Simulation( + situation=situation(households, p["year"]), + reform={FEES: {ALWAYS: p["fees"]}, BASIS: {ALWAYS: p["basis"]}}, + ) assert_close( baseline, - np.asarray(current_law.calculate("private_school_vat", p["year"]), dtype=float), + np.asarray( + assumptions_only.calculate("private_school_vat", p["year"]), dtype=float + ), ) - np.testing.assert_array_equal( - current_law.calculate("private_school_vat_change", p["year"]), 0 + for current_policy in ( + assumptions_only, + Simulation(situation=situation(households, p["year"])), + ): + np.testing.assert_array_equal( + current_policy.calculate("private_school_vat_change", p["year"]), 0 + ) + + +GRID_HOUSEHOLDS = [ + {"pupils": n, "other_children": 1, "consumption": 20_000.0, "education_share": 0.2} + for n in (0, 1, 3) +] +GRID = list( + itertools.product( + (2024, 2025), + (None, True, False), + (0.0, 0.1, 0.2, 0.3), + (None, 0.25), ) +) + + +@pytest.mark.parametrize("year, exempt, contrib, standard_rate", GRID) +def test_private_school_vat_grid(year, exempt, contrib, standard_rate): + p = { + "year": year, + "fees": 12_000.0, + "basis": 0.75, + "standard_rate": standard_rate, + "exempt": exempt, + "contrib": contrib, + } + pupils = np.array([h["pupils"] for h in GRID_HOUSEHOLDS], dtype=float) + expected = pupils * 12_000 * 0.75 * max(statutory_rate(p), contrib) + assert_close(calc(GRID_HOUSEHOLDS, p, "private_school_vat"), expected, str(p)) diff --git a/policyengine_uk/variables/gov/hmrc/baseline_private_school_vat.py b/policyengine_uk/variables/gov/hmrc/baseline_private_school_vat.py index 5c49c2f1a9..f52f458021 100644 --- a/policyengine_uk/variables/gov/hmrc/baseline_private_school_vat.py +++ b/policyengine_uk/variables/gov/hmrc/baseline_private_school_vat.py @@ -6,7 +6,12 @@ class baseline_private_school_vat(Variable): label = "baseline private school VAT" - documentation = "private_school_vat under baseline parameters." + documentation = ( + "private_school_vat under baseline policy (the exemption, the standard " + "rate and the reform rate), with this simulation's attendance, mean " + "fee and share of fees bearing VAT. Holding the assumptions fixed " + "means private_school_vat_change measures policy alone." + ) entity = Household definition_period = YEAR value_type = float @@ -14,5 +19,8 @@ class baseline_private_school_vat(Variable): def formula(household, period, parameters): return private_school_vat_liability( - household, period, parameters(period).baseline.gov + household, + period, + parameters(period).baseline.gov, + parameters(period).gov.simulation.private_school_vat, ) diff --git a/policyengine_uk/variables/gov/hmrc/private_school_vat.py b/policyengine_uk/variables/gov/hmrc/private_school_vat.py index c2eeee9723..1250588b50 100644 --- a/policyengine_uk/variables/gov/hmrc/private_school_vat.py +++ b/policyengine_uk/variables/gov/hmrc/private_school_vat.py @@ -17,14 +17,21 @@ def private_school_vat_rate(gov): return max_(statutory_rate, gov.contrib.labour.private_school_vat) -def private_school_vat_liability(household, period, gov): +def private_school_vat_liability(household, period, policy, assumptions): + """VAT on the household's fees. + + `policy` (a `gov` parameter node) sets the rate; `assumptions` + (gov.simulation.private_school_vat) sets the mean fee and the share of + fees that bears VAT net of the schools' input tax. The two are separate + so that baseline_private_school_vat can apply baseline policy to this + simulation's assumptions. + """ pupils = add(household, period, ["attends_private_school"]) - p = gov.simulation.private_school_vat return ( pupils - * p.private_school_fees - * p.private_school_vat_basis - * private_school_vat_rate(gov) + * assumptions.private_school_fees + * assumptions.private_school_vat_basis + * private_school_vat_rate(policy) ) @@ -45,4 +52,7 @@ class private_school_vat(Variable): ] def formula(household, period, parameters): - return private_school_vat_liability(household, period, parameters(period).gov) + gov = parameters(period).gov + return private_school_vat_liability( + household, period, gov, gov.simulation.private_school_vat + ) From ccbab58ad1a6f46d9f41b01c91adef5542b3c5ba Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 14:56:17 -0400 Subject: [PATCH 04/12] Describe the private school VAT overlap with vat accurately The vat guard becomes overlapping_base: the heads read separate inputs, but vat applies one household share to all consumption including education, so a fee-paying household's fees are charged at its blended share, and in a household calculation education consumption entered as an input is charged in both heads. On calibrated data the overlap is in aggregate, because policyengine-uk-data targets vat at total OBR VAT receipts (policyengine-uk-data#567). The partition header lists the guard kinds and says data overlaps are recorded under their guards. The identity property now draws households with a child at private school and a scenario restoring the exemption, and checks the head is positive exactly where a pupil's fees are charged. The changelog says contributed reforms at 20% now score nothing from 2025. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2318.fixed.md | 2 +- policyengine_uk/data/fiscal_partition.yaml | 41 ++++++++++++------- .../tests/test_fiscal_partition.py | 29 ++++++++++--- 3 files changed, 51 insertions(+), 21 deletions(-) diff --git a/changelog.d/2318.fixed.md b/changelog.d/2318.fixed.md index 208f45eaf4..0646412b40 100644 --- a/changelog.d/2318.fixed.md +++ b/changelog.d/2318.fixed.md @@ -1 +1 @@ -- Charge VAT at the standard rate on private school fees from 1 January 2025 (Finance Act 2025 ss.47 and 49), with the contributed reform rate able to raise the rate but never charge the fees twice. Household taxes now include private school VAT as a change from baseline, as they do VAT. +- Charge VAT at the standard rate on private school fees from 1 January 2025 (Finance Act 2025 ss.47 and 49), through the new `gov.hmrc.vat.private_school_fees_exempt` parameter. The contributed rate `gov.contrib.labour.private_school_vat` now raises the rate rather than adding to it, so reforms that set it to 20% score nothing from 2025. Household taxes include private school VAT as a change from baseline (`private_school_vat_change`), as they do VAT; government tax revenue includes the level. diff --git a/policyengine_uk/data/fiscal_partition.yaml b/policyengine_uk/data/fiscal_partition.yaml index b64c239f87..2335ff7918 100644 --- a/policyengine_uk/data/fiscal_partition.yaml +++ b/policyengine_uk/data/fiscal_partition.yaml @@ -43,9 +43,14 @@ # analytical decomposition already inside `head` through its inputs), # composite (spans several heads), duplicate (same value as `head`), # baseline_measure. -# model_double_counts[]: known violations still in the model (none). +# model_double_counts[]: known violations still in the model's formulas +# (none). An overlap that comes from the data, not the formulas, is +# recorded under the guard it belongs to. # overlap_guards[]: the places double counting could occur, and the -# property the tests check for each. +# property the tests check for each. kind: payer_split, +# geographic_exclusivity, level_and_change, shared_allocation_key, +# overlapping_base (the heads read separate inputs but their bases are +# not separated household by household), read_not_inclusion. schema_version: 1 sources: @@ -595,20 +600,28 @@ overlap_guards: household_tax, as for vat and vat_change. private_school_vat_change reads private_school_vat. - id: private_school_vat_and_vat - kind: disjoint_bases + kind: overlapping_base heads: [vat, private_school_vat] property: >- - Neither head reads the other or the other's inputs and parameters - (tested in test_private_school_vat_properties.py). vat applies one - household full-rate share to all imputed consumption, education - included. The share (full_rate_vat_expenditure_rate) is trained on ETB - VAT paid in the financial year ending 2024, when private school fees - were exempt (policyengine-uk-data datasets/imputations/vat.py), and is - the same in every year, so vat never charges the fees at the standard - rate. private_school_vat charges them once, at the higher of the - statutory and contributed reform rates. Outside the model, - policyengine-uk-data calibrates weighted vat to the OBR VAT receipts - line, which includes VAT on private school fees from 2025-26. + The two heads read separate inputs and parameters (tested in + test_private_school_vat_properties.py), but their bases are not + separated household by household. vat applies one household full-rate + share (full_rate_vat_expenditure_rate) to all imputed consumption, + education included. On the microdata the share is trained on ETB VAT + paid in the financial year ending 2024, when private school fees were + exempt, and predicted from household composition and income only + (policyengine-uk-data datasets/imputations/vat.py): in aggregate it + prices the fees as exempt, and a fee-paying household's fees are + charged at its blended share. In a household calculation with the + default share of 0.5, education consumption entered as an input is + charged in vat as well as in private_school_vat, where attendance is + an input. private_school_vat charges the fees once, at the higher of + the statutory and contributed reform rates. On calibrated data the two + overlap in aggregate: policyengine-uk-data calibrates weighted vat to + the OBR VAT receipts line, which includes VAT on private school fees + from 2025-26 (costed at 1,505m pounds for 2025-26), so gov_tax counts + that VAT in both heads until the target nets it out + (policyengine-uk-data issue 567). - id: eligibility_reads kind: read_not_inclusion heads: [tv_licence, private_school_vat, private_school_vat_change] diff --git a/policyengine_uk/tests/test_fiscal_partition.py b/policyengine_uk/tests/test_fiscal_partition.py index 2988b43371..a48f196511 100644 --- a/policyengine_uk/tests/test_fiscal_partition.py +++ b/policyengine_uk/tests/test_fiscal_partition.py @@ -23,9 +23,10 @@ delta per member and leaves every other head of those totals unchanged. A head counted twice, as corporate SDLT was through ``expected_sdlt`` (#2159), moves its totals by 2 x delta. -5. Identity (property-based): on random households, years 2025-2030, and - under SDLT abolition and council tax abolition, each total equals the sum - of its heads, less the heads ``dropped_when`` names. Heads marked +5. Identity (property-based): on random households, some with a child at + private school, years 2025-2030, and under SDLT abolition, council tax + abolition and a restored private school VAT exemption, each total equals + the sum of its heads, less the heads ``dropped_when`` names. Heads marked ``sign: nonnegative`` are non-negative and heads marked ``zero_at_baseline: expected`` are zero at current law; at most one of SDLT, LTT and LBTT is non-zero per household. @@ -442,6 +443,9 @@ def test_each_head_enters_its_totals_once(head_id): "baseline": None, "sdlt_abolished": {"gov.hmrc.stamp_duty.abolish": {ALWAYS: True}}, "council_tax_abolished": {"gov.contrib.abolish_council_tax": {ALWAYS: True}}, + "private_school_exemption_restored": { + "gov.hmrc.vat.private_school_fees_exempt": {ALWAYS: True} + }, } REGIONS = ["LONDON", "NORTH_WEST", "WALES", "SCOTLAND", "NORTHERN_IRELAND"] amount = lambda top: st.one_of(st.just(0.0), st.floats(1_000, top)) # noqa: E731 @@ -458,6 +462,8 @@ def test_each_head_enters_its_totals_once(head_id): "consumption": amount(80_000), "main_residence_value": amount(5_000_000), "owner": st.booleans(), + # A child at private school, so private school VAT is non-zero. + "pupil": st.booleans(), } ) PERSON_INPUTS = {"age", "employment_income", "self_employment_income", "capital_gains"} @@ -468,16 +474,23 @@ def random_situation(drawn: list, year: int) -> dict: for i, inputs in enumerate(drawn): person = f"person_{i}" people[person] = {key: {year: inputs[key]} for key in PERSON_INPUTS} - benunits[f"benunit_{i}"] = {"members": [person]} + members = [person] + if inputs["pupil"]: + people[f"child_{i}"] = { + "age": {year: 10}, + "attends_private_school": {year: True}, + } + members.append(f"child_{i}") + benunits[f"benunit_{i}"] = {"members": members} household = { key: {year: value} for key, value in inputs.items() - if key not in PERSON_INPUTS and key != "owner" + if key not in PERSON_INPUTS and key not in ("owner", "pupil") } household["tenure_type"] = { year: "OWNED_OUTRIGHT" if inputs["owner"] else "RENT_PRIVATELY" } - households[f"household_{i}"] = {"members": [person], **household} + households[f"household_{i}"] = {"members": members, **household} return {"people": people, "benunits": benunits, "households": households} @@ -528,6 +541,10 @@ def test_each_total_is_the_sum_of_its_heads(drawn, year): assert (values[head["id"]] >= 0).all(), head["id"] if head.get("zero_at_baseline") == "expected": np.testing.assert_array_equal(values[head["id"]], 0, head["id"]) + # Exercise the head: it is positive wherever a pupil's fees are + # charged at current law. + pupils = np.array([h["pupil"] for h in drawn]) + np.testing.assert_array_equal(values["private_school_vat"] > 0, pupils) if scenario == "sdlt_abolished": np.testing.assert_array_equal(values["sdlt_household"], 0) np.testing.assert_array_equal(values["sdlt_corporate"], 0) From 22be892e5d581b507cbc0308c6393d7e8a06bd30 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 15:17:25 -0400 Subject: [PATCH 05/12] Make the private school checks non-vacuous and note the simplifications From the second independent review: - The identity property always includes a household with a pupil, and under a restored exemption checks the head is zero and the change is minus the baseline charge exactly where a pupil's fees bore it. - The grid's reference uses the statutory 20 per cent, not the model's parameter. - The eligibility-reads guard says its check is vacuous for the private school heads on situations; the header says formula overlaps between heads with unseparated bases are recorded under their guards. - private_school_vat notes that every imputed pupil is charged (nursery classes excepted by law, 18-year-olds chargeable), and the baseline notes it reads 1 January values where the level reads 30 April. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/data/fiscal_partition.yaml | 11 +++++-- .../tests/test_fiscal_partition.py | 33 ++++++++++++++++++- .../test_private_school_vat_properties.py | 4 +-- .../gov/hmrc/baseline_private_school_vat.py | 4 +++ .../variables/gov/hmrc/private_school_vat.py | 6 ++++ 5 files changed, 52 insertions(+), 6 deletions(-) diff --git a/policyengine_uk/data/fiscal_partition.yaml b/policyengine_uk/data/fiscal_partition.yaml index 2335ff7918..d390430e1f 100644 --- a/policyengine_uk/data/fiscal_partition.yaml +++ b/policyengine_uk/data/fiscal_partition.yaml @@ -43,9 +43,10 @@ # analytical decomposition already inside `head` through its inputs), # composite (spans several heads), duplicate (same value as `head`), # baseline_measure. -# model_double_counts[]: known violations still in the model's formulas -# (none). An overlap that comes from the data, not the formulas, is -# recorded under the guard it belongs to. +# model_double_counts[]: heads whose formulas count the same receipt +# twice (none). Where two heads read separate inputs but their bases are +# not separated, the overlap, in the formulas or in the data, is +# recorded under its guard. # overlap_guards[]: the places double counting could occur, and the # property the tests check for each. kind: payer_split, # geographic_exclusivity, level_and_change, shared_allocation_key, @@ -629,3 +630,7 @@ overlap_guards: These heads reach other heads of the same total only through eligibility or imputation (see each head's reads). Raising the other head by a small amount leaves them unchanged on the test population. + For the private school heads that check is vacuous: attendance is + imputed only on a dataset (on a situation it is zero or an input), so + a tax change moves them only through re-imputed attendance on the + microdata (policyengine-uk-data issue 568). diff --git a/policyengine_uk/tests/test_fiscal_partition.py b/policyengine_uk/tests/test_fiscal_partition.py index a48f196511..be3d2a461f 100644 --- a/policyengine_uk/tests/test_fiscal_partition.py +++ b/policyengine_uk/tests/test_fiscal_partition.py @@ -42,7 +42,7 @@ import numpy as np import pytest import yaml -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st import policyengine_uk @@ -494,6 +494,24 @@ def random_situation(drawn: list, year: int) -> dict: return {"people": people, "benunits": benunits, "households": households} +# Always one household with a pupil, so the private school checks are never +# vacuous. +PUPIL_HOUSEHOLD = { + "region": "LONDON", + "age": 45, + "employment_income": 120_000.0, + "self_employment_income": 0.0, + "capital_gains": 0.0, + "main_residential_property_purchased": 0.0, + "shareholding": 0.0, + "council_tax": 2_500.0, + "consumption": 50_000.0, + "main_residence_value": 900_000.0, + "owner": True, + "pupil": True, +} + + @settings( max_examples=10, deadline=None, @@ -504,6 +522,7 @@ def random_situation(drawn: list, year: int) -> dict: drawn=st.lists(random_household, min_size=2, max_size=2), year=st.integers(2025, 2030), ) +@example(drawn=[PUPIL_HOUSEHOLD, {**PUPIL_HOUSEHOLD, "pupil": False}], year=2026) def test_each_total_is_the_sum_of_its_heads(drawn, year): for scenario, reform in SCENARIOS.items(): sim = Simulation(situation=random_situation(drawn, year), reform=reform) @@ -545,6 +564,18 @@ def test_each_total_is_the_sum_of_its_heads(drawn, year): # charged at current law. pupils = np.array([h["pupil"] for h in drawn]) np.testing.assert_array_equal(values["private_school_vat"] > 0, pupils) + if scenario == "private_school_exemption_restored": + # Restoring the exemption removes the charge, and household taxes + # fall by the baseline charge wherever a pupil's fees bore it. + pupils = np.array([h["pupil"] for h in drawn]) + np.testing.assert_array_equal(values["private_school_vat"], 0) + change = values["private_school_vat_change"] + np.testing.assert_array_equal(change < 0, pupils) + np.testing.assert_allclose( + change, + -household_values(sim, "baseline_private_school_vat", year), + rtol=1e-6, + ) if scenario == "sdlt_abolished": np.testing.assert_array_equal(values["sdlt_household"], 0) np.testing.assert_array_equal(values["sdlt_corporate"], 0) diff --git a/policyengine_uk/tests/test_private_school_vat_properties.py b/policyengine_uk/tests/test_private_school_vat_properties.py index 2689503ce0..78b69b8b16 100644 --- a/policyengine_uk/tests/test_private_school_vat_properties.py +++ b/policyengine_uk/tests/test_private_school_vat_properties.py @@ -47,7 +47,6 @@ from hypothesis import strategies as st from policyengine_uk import Simulation -from policyengine_uk.system import system ALWAYS = "2000-01-01.2100-12-31" CONTRIB = "gov.contrib.labour.private_school_vat" @@ -127,7 +126,8 @@ def statutory_rate(p: dict) -> float: return 0.0 if p["standard_rate"] is not None: return p["standard_rate"] - return float(system.parameters.gov.hmrc.vat.standard_rate(f"{year}-01-01")) + # VATA 1994 s.2(1): 20 per cent since 4 January 2011. + return 0.2 def calc(households, p, variable, **overrides): diff --git a/policyengine_uk/variables/gov/hmrc/baseline_private_school_vat.py b/policyengine_uk/variables/gov/hmrc/baseline_private_school_vat.py index f52f458021..edf16a99d6 100644 --- a/policyengine_uk/variables/gov/hmrc/baseline_private_school_vat.py +++ b/policyengine_uk/variables/gov/hmrc/baseline_private_school_vat.py @@ -18,6 +18,10 @@ class baseline_private_school_vat(Variable): unit = "currency-GBP" def formula(household, period, parameters): + # parameters.baseline is cloned before the fiscal-year conversion, so + # it reads the exemption and rates at 1 January where the level reads + # them at 30 April. They agree while none changes between those + # dates; baseline_vat has the same property. return private_school_vat_liability( household, period, diff --git a/policyengine_uk/variables/gov/hmrc/private_school_vat.py b/policyengine_uk/variables/gov/hmrc/private_school_vat.py index 1250588b50..c1d28dceb3 100644 --- a/policyengine_uk/variables/gov/hmrc/private_school_vat.py +++ b/policyengine_uk/variables/gov/hmrc/private_school_vat.py @@ -25,6 +25,12 @@ def private_school_vat_liability(household, period, policy, assumptions): fees that bears VAT net of the schools' input tax. The two are separate so that baseline_private_school_vat can apply baseline policy to this simulation's assumptions. + + Every imputed pupil is charged. The law excepts education in a nursery + class (Sch 9 Part 3 item 1(b)) and charges 18-year-olds in sixth forms, + but attends_private_school imputes pupils over all under-18s, calibrated + to the total pupil count; policyengine-uk-data issue 568 moves the + imputation to school ages. """ pupils = add(household, period, ["attends_private_school"]) return ( From 5440614f3625c590f57f7f08516ada361c8c0b8b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 22 Oct 2026 13:53:36 -0400 Subject: [PATCH 06/12] Allocate school-fee VAT out of receipt-calibrated general VAT --- changelog.d/2318.fixed.md | 1 + docs/engineering/vat-coverage.md | 69 ++++++++++++- policyengine_uk/data/fiscal_partition.yaml | 58 ++++++----- .../vat/survey_consumption_coverage.yaml | 7 +- .../test_private_school_vat_properties.py | 12 +-- .../tests/test_private_school_vat_receipts.py | 97 +++++++++++++++++++ .../variables/gov/hmrc/baseline_vat.py | 7 +- policyengine_uk/variables/gov/hmrc/vat.py | 19 +++- 8 files changed, 232 insertions(+), 38 deletions(-) create mode 100644 policyengine_uk/tests/test_private_school_vat_receipts.py diff --git a/changelog.d/2318.fixed.md b/changelog.d/2318.fixed.md index 0646412b40..242ec30b4b 100644 --- a/changelog.d/2318.fixed.md +++ b/changelog.d/2318.fixed.md @@ -1 +1,2 @@ - Charge VAT at the standard rate on private school fees from 1 January 2025 (Finance Act 2025 ss.47 and 49), through the new `gov.hmrc.vat.private_school_fees_exempt` parameter. The contributed rate `gov.contrib.labour.private_school_vat` now raises the rate rather than adding to it, so reforms that set it to 20% score nothing from 2025. Household taxes include private school VAT as a change from baseline (`private_school_vat_change`), as they do VAT; government tax revenue includes the level. +- Allocate school-fee VAT out of receipt-calibrated general VAT before counting it separately, preserving current-law VAT receipts and avoiding a duplicate school-fee response to standard-rate reforms. diff --git a/docs/engineering/vat-coverage.md b/docs/engineering/vat-coverage.md index 49c467898a..e6b51b517b 100644 --- a/docs/engineering/vat-coverage.md +++ b/docs/engineering/vat-coverage.md @@ -3,7 +3,9 @@ `vat` and `baseline_vat` compute the statutory liability on each household's recorded consumption (the standard rate on `full_rate_vat_consumption` plus the reduced rate on `reduced_rate_vat_consumption`) and divide it by two -factors, so that total VAT matches receipts: +factors. This gross amount is calibrated to total VAT receipts. The private +school component is then allocated to `private_school_vat`, as explained +below, so the receipts comparison uses `vat + private_school_vat`: - `gov.simulation.vat.household_share_of_receipts` (0.7): the share of VAT liabilities that falls on household final consumption. HMRC's VAT gap @@ -68,8 +70,9 @@ product = pre-scaling VAT / OBR VAT receipts (same year) survey coverage = product / household share (0.7) ``` -`vat` divides each household's statutory VAT by the product, so total `vat` -equals receipts when the product matches the dataset. +Dividing each household's statutory VAT by the product gives the gross +amount. At current law, total `vat + private_school_vat` equals this amount, +and equals receipts when the product matches the dataset. ### Results @@ -88,7 +91,7 @@ equals receipts when the product matches the dataset. The parameter takes the Microcosm 2024-25 value, 0.67 (product 0.469), the latest outturn year. -With that product, total `vat` against receipts is: +With that product, total current-law `vat + private_school_vat` against receipts is: | Dataset | 2024-25 | 2025-26 | 2026-27 | |---|---|---|---| @@ -146,6 +149,64 @@ For Enhanced FRS, download `enhanced_frs_2024_25.h5` from the model repository `policyengine/policyengine-uk-data-private` at the release tag (for example `revision="1.58.0"`). +## Private school VAT: splitting the existing receipts total + +The OBR VAT total already includes VAT on private school fees. HMRC's +[VAT-only costing](https://www.gov.uk/government/publications/vat-on-private-school-fees/applying-vat-to-private-school-fees) +is £460m in 2024-25, £1,505m in 2025-26 and £1,560m in 2026-27. Adding a +separate school VAT head to the gross amount above would count that receipt +twice. The model instead separates the school component from the existing +receipts allocation, keeping the survey coverage at 0.67. + +For each household, let `G` be the gross VAT amount above, `P0` be +`baseline_private_school_vat`, `P` be `private_school_vat`, `r0` be the +baseline standard rate and `r` be the current standard rate. The allocation +is: + +```text +school component already in gross VAT = P0 * r / r0 +vat = G - P0 * r / r0 +baseline_vat = G0 - P0 +combined VAT = vat + P + = G + P - P0 * r / r0 +``` + +At current law, `r = r0` and `P = P0`, so combined VAT is exactly `G` for +every household, year and dataset. The same identity holds after weighting +and aggregation. It also holds when only the standard rate changes: both +the allocated component and the explicit school charge move with that +rate. Subtracting a fixed `P0` from current `vat` would count that rate +change twice. Restoring the school exemption leaves the allocation in +place and sets `P` to zero, reducing combined VAT by the school charge. + +Both school measures use this simulation's attendance, fees and effective +VAT basis, with baseline policy held fixed for `P0`. Consequently, changes +to those assumptions alone preserve the current-law receipts total. The +official £1,560m costing is a useful external comparison, but subtracting it +instead of the model's own school component would leave a residual equal +to the difference between the two. It would also require a dataset-specific +allocation of that national amount. No new coverage factor is needed for +this accounting split. + +The receipts check therefore retains the original calibration's accuracy. +Using the rounded 2024-25 Microcosm inputs above, +`80.48 / (0.67 * 0.7) = £171.599bn`, compared with OBR's £170.994bn: about +0.35% above receipts. The table's 2025-26 and 2026-27 comparisons also stay +unchanged. This split prevents an additional school VAT overstatement; the +existing differences from receipts, including Enhanced FRS's larger +overstatement, still require dataset-specific calibration. Aggregate impact +CI compares the combined VAT report group against the PR's base. + +Two limits matter when interpreting the split. Model year 2024 represents +2024-25 but reads annual policy before the January 2025 commencement, so +`P0` is zero for that year. The £460m part-year receipt remains in general +VAT; this change does not model its separate timing. Also, general `vat` +can be negative when a household has imputed or entered school attendance +and too little recorded consumption to cover the allocated school +component. It is a receipt-allocation residual. Clipping it to zero would +break the receipts identity; the combined current-law amount still equals +the original gross amount. + ## Interaction with an explicit energy VAT base A domestic fuel and power VAT base built from `electricity_consumption` and diff --git a/policyengine_uk/data/fiscal_partition.yaml b/policyengine_uk/data/fiscal_partition.yaml index c9997e811c..c72d5d561a 100644 --- a/policyengine_uk/data/fiscal_partition.yaml +++ b/policyengine_uk/data/fiscal_partition.yaml @@ -232,7 +232,7 @@ tax_heads: # Consumption - id: vat - label: VAT + label: VAT excluding private school fees variable: vat entity: household obr_line: Value added tax @@ -240,14 +240,28 @@ tax_heads: report_group: vat payer: consumer incidence totals: [gov_tax] - sign: nonnegative + sign: any data_note: >- + A residual receipt allocation: baseline school-fee VAT at the current + standard rate is subtracted before counting the separate school head. + Sparse calculator consumption can make the residual negative; do not + clip it, which would inflate combined receipts. Separately, Negative for 76 households on policyengine-uk-data 1.58.0 (enhanced_frs_2024_25) in every year from 2025 to 2030, -£72m weighted in 2026: full_rate_vat_consumption is negative there because the dataset's consumption (44 households) or full_rate_vat_expenditure_rate (32 households) is negative. policyengine-uk-data issue 565. Measured 2026-10-11. + reads: + - head: income_tax + route: vat -> baseline_private_school_vat -> attends_private_school -> income_tax + why: attendance determines the school-fee receipt allocation; the tax itself is not added + - head: nics_individual + route: vat -> baseline_private_school_vat -> attends_private_school -> national_insurance + why: attendance determines the school-fee receipt allocation; the tax itself is not added + - head: council_tax + route: vat -> baseline_private_school_vat -> attends_private_school -> household_benefits -> council_tax_benefit -> council_tax + why: attendance determines the school-fee receipt allocation; the tax itself is not added - id: vat_change label: Change in VAT from its baseline variable: vat_change @@ -263,6 +277,15 @@ tax_heads: - head: vat route: vat_change adds vat and subtracts baseline_vat why: a change measure built from the level; the two are never in the same total + - head: income_tax + route: vat_change -> vat -> baseline_private_school_vat -> attends_private_school -> income_tax + why: attendance determines the school-fee receipt allocation; the tax itself is not added + - head: nics_individual + route: vat_change -> vat -> baseline_private_school_vat -> attends_private_school -> national_insurance + why: attendance determines the school-fee receipt allocation; the tax itself is not added + - head: council_tax + route: vat_change -> vat -> baseline_private_school_vat -> attends_private_school -> household_benefits -> council_tax_benefit -> council_tax + why: attendance determines the school-fee receipt allocation; the tax itself is not added # Standard-rated from 1 January 2025: Finance Act 2025 s.47 inserts VATA # 1994 Sch 9 Part 3, taking private school education, vocational training # and closely related board and lodging out of the Group 6 exemption, for @@ -681,25 +704,16 @@ overlap_guards: kind: overlapping_base heads: [vat, private_school_vat] property: >- - The two heads read separate inputs and parameters (tested in - test_private_school_vat_properties.py), but their bases are not - separated household by household. vat applies one household full-rate - share (full_rate_vat_expenditure_rate) to all imputed consumption, - education included. On the microdata the share is trained on ETB VAT - paid in the financial year ending 2024, when private school fees were - exempt, and predicted from household composition and income only - (policyengine-uk-data datasets/imputations/vat.py): in aggregate it - prices the fees as exempt, and a fee-paying household's fees are - charged at its blended share. In a household calculation with the - default share of 0.5, education consumption entered as an input is - charged in vat as well as in private_school_vat, where attendance is - an input. private_school_vat charges the fees once, at the higher of - the statutory and contributed reform rates. On calibrated data the two - overlap in aggregate: policyengine-uk-data calibrates weighted vat to - the OBR VAT receipts line, which includes VAT on private school fees - from 2025-26 (costed at 1,505m pounds for 2025-26), so gov_tax counts - that VAT in both heads until the target nets it out - (policyengine-uk-data issue 567). + Grossed-up consumption VAT is fitted to receipts including school-fee + VAT. vat subtracts baseline_private_school_vat times the current + standard rate divided by its baseline rate, then gov_tax adds + private_school_vat. At current law the subtraction equals the school + head household by household, preserving combined receipts exactly. + Scaling the subtraction with the standard rate also prevents a second + school-fee rate response. School-specific exemption and contributed + rate reforms move only the school head. Both baseline measures hold + this simulation's fee and attendance assumptions fixed. See + test_private_school_vat_receipts.py and docs/engineering/vat-coverage.md. - id: council_tax_gross_and_reduction kind: gross_and_net heads: [council_tax] @@ -724,7 +738,7 @@ overlap_guards: here as well. - id: eligibility_reads kind: read_not_inclusion - heads: [tv_licence, private_school_vat, private_school_vat_change] + heads: [tv_licence, vat, vat_change, private_school_vat, private_school_vat_change] property: >- These heads reach other heads of the same total only through eligibility or imputation (see each head's reads). Raising the other diff --git a/policyengine_uk/parameters/gov/simulation/vat/survey_consumption_coverage.yaml b/policyengine_uk/parameters/gov/simulation/vat/survey_consumption_coverage.yaml index e5a16038db..0abedff931 100644 --- a/policyengine_uk/parameters/gov/simulation/vat/survey_consumption_coverage.yaml +++ b/policyengine_uk/parameters/gov/simulation/vat/survey_consumption_coverage.yaml @@ -19,7 +19,12 @@ metadata: 0.927 (policyengine-uk-data 1.56.16) or 0.967 (1.58.0), so this value puts VAT on that dataset 38% or 44% above 2024-25 receipts. Re-derive when the dataset it should match changes, with docs/engineering/vat-coverage.md, - which lists the inputs, file hashes and per-dataset results. + which lists the inputs, file hashes and per-dataset results. These receipts + already include private school VAT. The vat formula therefore subtracts + the baseline school-fee tax base at the current standard rate before + gov_tax adds private_school_vat. At current law the two heads sum to the + original receipt-calibrated VAT, so no extra school receipts are added + to this denominator. See the receipt-partition derivation in that document. reference: - title: OBR, Economic and fiscal outlook March 2026, detailed forecast tables, receipts, table 3.8 href: https://obr.uk/efo/economic-and-fiscal-outlook-march-2026/ diff --git a/policyengine_uk/tests/test_private_school_vat_properties.py b/policyengine_uk/tests/test_private_school_vat_properties.py index 78b69b8b16..8972741b09 100644 --- a/policyengine_uk/tests/test_private_school_vat_properties.py +++ b/policyengine_uk/tests/test_private_school_vat_properties.py @@ -19,12 +19,10 @@ private_school_vat unchanged, so the fees are never taxed twice; - monotone: private_school_vat never falls when the reform rate or the standard rate rises; -- separate inputs: vat (VAT on imputed consumption) is the same whatever the - private school parameters, and private_school_vat is the same whatever the - household's consumption, including education consumption. This shows - neither head reads the other's parameters or inputs; it does not show the - bases are separate, since vat's base includes education consumption (see - the private_school_vat_and_vat guard in data/fiscal_partition.yaml); +- receipt partition: school-specific rate and exemption reforms leave the + residual vat head unchanged, and private_school_vat is independent of + recorded consumption. General VAT nets out the baseline school tax base + before the school head is counted (test_private_school_vat_receipts.py); - level and change: private_school_vat_change equals private_school_vat minus baseline_private_school_vat; baseline_private_school_vat equals private_school_vat under baseline policy with this simulation's mean fee @@ -197,7 +195,7 @@ def test_private_school_vat_never_falls_as_a_rate_rises(households, p, rise): @SETTINGS @given(households=st.lists(household, min_size=1, max_size=3), p=parameter_set) -def test_vat_and_private_school_vat_read_separate_inputs(households, p): +def test_school_specific_policy_leaves_residual_vat_unchanged(households, p): vat = calc(households, p, "vat") for overrides in ( {"exempt": True, "contrib": 0.0}, diff --git a/policyengine_uk/tests/test_private_school_vat_receipts.py b/policyengine_uk/tests/test_private_school_vat_receipts.py new file mode 100644 index 0000000000..8204cab93d --- /dev/null +++ b/policyengine_uk/tests/test_private_school_vat_receipts.py @@ -0,0 +1,97 @@ +"""School VAT is a partition of receipt-calibrated VAT, not extra receipts.""" + +import pytest + +from policyengine_uk import Simulation + +ALWAYS = "2010-01-01.2100-12-31" +STANDARD = "gov.hmrc.vat.standard_rate" +EXEMPT = "gov.hmrc.vat.private_school_fees_exempt" +CONTRIB = "gov.contrib.labour.private_school_vat" +LEGACY = "gov.simulation.microdata_vat_coverage" + + +def simulation(year=2025, full=10_000, reduced=1_000, reforms=None): + changes = { + "gov.simulation.private_school_vat.private_school_fees": {ALWAYS: 10_000}, + "gov.simulation.private_school_vat.private_school_vat_basis": {ALWAYS: 1}, + **{key: {ALWAYS: value} for key, value in (reforms or {}).items()}, + } + return Simulation( + situation={ + "people": { + "adult": {"age": {year: 40}}, + "pupil": { + "age": {year: 10}, + "attends_private_school": {year: True}, + }, + }, + "benunits": {"family": {"members": ["adult", "pupil"]}}, + "households": { + "household": { + "members": ["adult", "pupil"], + "full_rate_vat_consumption": {year: full}, + "reduced_rate_vat_consumption": {year: reduced}, + } + }, + }, + reform=changes, + ) + + +def amount(sim, variable, year=2025): + return float(sim.calculate(variable, year)[0]) + + +@pytest.mark.parametrize("year", [2024, 2025, 2031]) +@pytest.mark.parametrize("standard", [0, 0.2, 0.25]) +@pytest.mark.parametrize("coverage", [None, 0.5]) +def test_combined_vat_preserves_receipt_calibration(year, standard, coverage): + reforms = {STANDARD: standard} + if coverage is not None: + reforms[LEGACY] = coverage + sim = simulation(year, reforms=reforms) + factor = 0.67 * 0.7 if coverage is None else coverage + expected = (10_000 * standard + 1_000 * 0.05) / factor + combined = amount(sim, "vat", year) + amount(sim, "private_school_vat", year) + assert combined == pytest.approx(expected, abs=0.01) + # The fee-rate response is already inside the receipt-calibrated total. + baseline_total = (10_000 * 0.2 + 1_000 * 0.05) / (0.67 * 0.7) + change = amount(sim, "vat_change", year) + amount( + sim, "private_school_vat_change", year + ) + assert change == pytest.approx(expected - baseline_total, abs=0.01) + + +@pytest.mark.parametrize( + "reforms,school,change", + [ + ({EXEMPT: True}, 0, -2_000), + ({CONTRIB: 0.2}, 2_000, 0), + ({CONTRIB: 0.25}, 2_500, 500), + ({STANDARD: 0.25, EXEMPT: True}, 0, -2_500), + ], +) +def test_school_specific_reform_changes_combined_receipts_once(reforms, school, change): + sim = simulation(reforms=reforms) + standard = reforms.get(STANDARD, 0.2) + gross = (10_000 * standard + 1_000 * 0.05) / (0.67 * 0.7) + assert amount(sim, "private_school_vat") == pytest.approx(school) + assert amount(sim, "vat") + school == pytest.approx(gross + change, abs=0.01) + + +def test_sparse_calculator_inputs_do_not_create_extra_receipts(): + sim = simulation(full=0, reduced=0) + assert amount(sim, "private_school_vat") == 2_000 + assert amount(sim, "vat") == -2_000 + assert amount(sim, "baseline_vat") == -2_000 + assert amount(sim, "vat_change") == 0 + + +def test_published_microcosm_receipts_check_is_unchanged(): + # Rounded aggregate inputs published in docs/engineering/vat-coverage.md. + sim = simulation(year=2024, full=396.15e9, reduced=25.02e9) + combined = amount(sim, "vat", 2024) + amount(sim, "private_school_vat", 2024) + expected = (0.2 * 396.15e9 + 0.05 * 25.02e9) / (0.67 * 0.7) + assert combined == pytest.approx(expected) + assert combined / 170.994e9 == pytest.approx(1, rel=0.004) diff --git a/policyengine_uk/variables/gov/hmrc/baseline_vat.py b/policyengine_uk/variables/gov/hmrc/baseline_vat.py index 4c6f4589b4..786792e2b5 100644 --- a/policyengine_uk/variables/gov/hmrc/baseline_vat.py +++ b/policyengine_uk/variables/gov/hmrc/baseline_vat.py @@ -3,7 +3,7 @@ class baseline_vat(Variable): - label = "baseline VAT" + label = "baseline VAT excluding private school fees" entity = Household definition_period = YEAR value_type = float @@ -20,4 +20,7 @@ def formula(household, period, parameters): # Survey consumption under-records spending, and households bear only # part of VAT liabilities, so household VAT is grossed up to receipts. coverage = vat_grossing_factor(p.simulation) - return raw_vat / coverage + # Match vat's receipt allocation under baseline policy. Using this + # simulation's attendance and fee assumptions makes assumptions-only + # reforms leave both VAT change measures at zero. + return raw_vat / coverage - household("baseline_private_school_vat", period) diff --git a/policyengine_uk/variables/gov/hmrc/vat.py b/policyengine_uk/variables/gov/hmrc/vat.py index 70012df9c5..13bb49a897 100644 --- a/policyengine_uk/variables/gov/hmrc/vat.py +++ b/policyengine_uk/variables/gov/hmrc/vat.py @@ -3,7 +3,13 @@ class vat(Variable): - label = "VAT" + label = "VAT excluding private school fees" + documentation = ( + "Receipt-calibrated VAT less the baseline school-fee tax base at the " + "current standard rate. Private school VAT is counted separately. " + "The residual can be negative when recorded consumption is too low " + "to cover the school-fee allocation." + ) entity = Household definition_period = YEAR value_type = float @@ -20,4 +26,13 @@ def formula(household, period, parameters): # Survey consumption under-records spending, and households bear only # part of VAT liabilities, so household VAT is grossed up to receipts. coverage = vat_grossing_factor(p.simulation) - return raw_vat / coverage + # The receipts used to fit coverage already include school-fee VAT. + # Reclassify its baseline tax base, at the current standard rate, so + # neither current law nor a standard-rate reform counts it twice. + # School-specific rate/exemption reforms change the separate head. + baseline_school_vat = household("baseline_private_school_vat", period) + baseline_standard_rate = parameters(period).baseline.gov.hmrc.vat.standard_rate + school_vat_in_receipts = ( + baseline_school_vat * p.hmrc.vat.standard_rate / baseline_standard_rate + ) + return raw_vat / coverage - school_vat_in_receipts From 4dcfa03c2aa940ec1b12f3e41c05c48b797f672c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 22 Oct 2026 14:15:02 -0400 Subject: [PATCH 07/12] Keep school VAT receipt allocation exact at baseline --- .../tests/test_private_school_vat_receipts.py | 12 ++++++++++++ policyengine_uk/variables/gov/hmrc/vat.py | 6 ++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/tests/test_private_school_vat_receipts.py b/policyengine_uk/tests/test_private_school_vat_receipts.py index 8204cab93d..d2ed494478 100644 --- a/policyengine_uk/tests/test_private_school_vat_receipts.py +++ b/policyengine_uk/tests/test_private_school_vat_receipts.py @@ -88,6 +88,18 @@ def test_sparse_calculator_inputs_do_not_create_extra_receipts(): assert amount(sim, "vat_change") == 0 +def test_fractional_fees_do_not_create_a_baseline_vat_change(): + sim = simulation( + full=0, + reduced=0, + reforms={"gov.simulation.private_school_vat.private_school_fees": 5_000.10}, + ) + # P0 is float32 1000.02001953125; multiplying by .2 then dividing + # would produce 1000.0200805664062 and a spurious change at baseline. + assert amount(sim, "vat_change") == 0 + assert amount(sim, "vat") + amount(sim, "private_school_vat") == 0 + + def test_published_microcosm_receipts_check_is_unchanged(): # Rounded aggregate inputs published in docs/engineering/vat-coverage.md. sim = simulation(year=2024, full=396.15e9, reduced=25.02e9) diff --git a/policyengine_uk/variables/gov/hmrc/vat.py b/policyengine_uk/variables/gov/hmrc/vat.py index 13bb49a897..ee78430d0a 100644 --- a/policyengine_uk/variables/gov/hmrc/vat.py +++ b/policyengine_uk/variables/gov/hmrc/vat.py @@ -32,7 +32,9 @@ def formula(household, period, parameters): # School-specific rate/exemption reforms change the separate head. baseline_school_vat = household("baseline_private_school_vat", period) baseline_standard_rate = parameters(period).baseline.gov.hmrc.vat.standard_rate - school_vat_in_receipts = ( - baseline_school_vat * p.hmrc.vat.standard_rate / baseline_standard_rate + # Form the scalar ratio first: it is exactly one at baseline. A + # float32 multiply then divide can otherwise create a tiny vat_change. + school_vat_in_receipts = baseline_school_vat * ( + p.hmrc.vat.standard_rate / baseline_standard_rate ) return raw_vat / coverage - school_vat_in_receipts From 7ed18347cfe23c559a5eabb0609d235ae4e2fa91 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 22 Oct 2026 23:48:42 -0400 Subject: [PATCH 08/12] Take school-fee VAT's official share out of general VAT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vat subtracted each household's own baseline school charge from its own grossed-up VAT, so a pupil with little recorded consumption had negative general VAT (-2,000 for one pupil, 10,000 fees and no consumption), and restoring the exemption left it there. On the certified Enhanced FRS that made 549 households' general VAT negative in 2025-26. General VAT is now gross VAT times one less private school fees' official share of VAT receipts (new gov.simulation.vat.private_school_share_of_receipts: HMRC's OBR-certified costing over OBR March 2026 cash-basis VAT receipts). It keeps the sign of gross VAT, reads neither attendance nor fees, and school-specific reforms move only private_school_vat. A repeal lowers receipts by exactly the weighted school charge. Current-law combined receipts change by the weighted school charge less the share of weighted gross VAT (-£0.17bn in 2025-26 on the certified dataset). The fiscal partition drops vat's attendance reads and restores sign: nonnegative on the vat head. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2318.fixed.md | 2 +- docs/engineering/vat-coverage.md | 137 +++++--- policyengine_uk/data/fiscal_partition.yaml | 60 ++-- .../vat/private_school_share_of_receipts.yaml | 30 ++ .../vat/survey_consumption_coverage.yaml | 11 +- .../baseline/gov/hmrc/vat/vat_grossing.yaml | 19 +- .../test_private_school_vat_properties.py | 6 +- .../tests/test_private_school_vat_receipts.py | 295 +++++++++++++++--- .../tests/test_vat_grossing_legacy.py | 9 +- .../variables/gov/hmrc/baseline_vat.py | 12 +- policyengine_uk/variables/gov/hmrc/vat.py | 25 +- 11 files changed, 434 insertions(+), 172 deletions(-) create mode 100644 policyengine_uk/parameters/gov/simulation/vat/private_school_share_of_receipts.yaml diff --git a/changelog.d/2318.fixed.md b/changelog.d/2318.fixed.md index 242ec30b4b..10710c8f7d 100644 --- a/changelog.d/2318.fixed.md +++ b/changelog.d/2318.fixed.md @@ -1,2 +1,2 @@ - Charge VAT at the standard rate on private school fees from 1 January 2025 (Finance Act 2025 ss.47 and 49), through the new `gov.hmrc.vat.private_school_fees_exempt` parameter. The contributed rate `gov.contrib.labour.private_school_vat` now raises the rate rather than adding to it, so reforms that set it to 20% score nothing from 2025. Household taxes include private school VAT as a change from baseline (`private_school_vat_change`), as they do VAT; government tax revenue includes the level. -- Allocate school-fee VAT out of receipt-calibrated general VAT before counting it separately, preserving current-law VAT receipts and avoiding a duplicate school-fee response to standard-rate reforms. +- Allocate school-fee VAT out of receipt-calibrated general VAT before counting it separately: general VAT is gross VAT less private school fees' official share of VAT receipts (new parameter `gov.simulation.vat.private_school_share_of_receipts`, HMRC's OBR-certified costing over OBR VAT receipts). No household's general VAT is made negative, school-specific reforms move only private school VAT, and standard-rate reforms move each head once. diff --git a/docs/engineering/vat-coverage.md b/docs/engineering/vat-coverage.md index e6b51b517b..45ddbab831 100644 --- a/docs/engineering/vat-coverage.md +++ b/docs/engineering/vat-coverage.md @@ -153,59 +153,98 @@ For Enhanced FRS, download `enhanced_frs_2024_25.h5` from the model repository The OBR VAT total already includes VAT on private school fees. HMRC's [VAT-only costing](https://www.gov.uk/government/publications/vat-on-private-school-fees/applying-vat-to-private-school-fees) -is £460m in 2024-25, £1,505m in 2025-26 and £1,560m in 2026-27. Adding a -separate school VAT head to the gross amount above would count that receipt -twice. The model instead separates the school component from the existing -receipts allocation, keeping the survey coverage at 0.67. - -For each household, let `G` be the gross VAT amount above, `P0` be -`baseline_private_school_vat`, `P` be `private_school_vat`, `r0` be the -baseline standard rate and `r` be the current standard rate. The allocation -is: +(Autumn Budget 2024 Table 5.1, certified by the OBR) is £460m in 2024-25, +£1,505m in 2025-26, £1,560m in 2026-27, £1,610m in 2027-28, £1,665m in +2028-29 and £1,725m in 2029-30. Adding a separate school VAT head to the +gross amount above would count that receipt twice. The model instead takes +the fees' share of receipts out of the gross amount, keeping the survey +coverage at 0.67. + +For each household, let `G` be the gross VAT amount above, `s` be +`gov.simulation.vat.private_school_share_of_receipts`, `P0` be +`baseline_private_school_vat` (baseline policy with this simulation's +attendance, fee and VAT-basis assumptions) and `P` be `private_school_vat`: ```text -school component already in gross VAT = P0 * r / r0 -vat = G - P0 * r / r0 -baseline_vat = G0 - P0 -combined VAT = vat + P - = G + P - P0 * r / r0 +vat = G * (1 - s) +baseline_vat = G0 * (1 - s) +combined VAT = vat + P ``` -At current law, `r = r0` and `P = P0`, so combined VAT is exactly `G` for -every household, year and dataset. The same identity holds after weighting -and aggregation. It also holds when only the standard rate changes: both -the allocated component and the explicit school charge move with that -rate. Subtracting a fixed `P0` from current `vat` would count that rate -change twice. Restoring the school exemption leaves the allocation in -place and sets `P` to zero, reducing combined VAT by the school charge. - -Both school measures use this simulation's attendance, fees and effective -VAT basis, with baseline policy held fixed for `P0`. Consequently, changes -to those assumptions alone preserve the current-law receipts total. The -official £1,560m costing is a useful external comparison, but subtracting it -instead of the model's own school component would leave a residual equal -to the difference between the two. It would also require a dataset-specific -allocation of that national amount. No new coverage factor is needed for -this accounting split. - -The receipts check therefore retains the original calibration's accuracy. -Using the rounded 2024-25 Microcosm inputs above, -`80.48 / (0.67 * 0.7) = £171.599bn`, compared with OBR's £170.994bn: about -0.35% above receipts. The table's 2025-26 and 2026-27 comparisons also stay -unchanged. This split prevents an additional school VAT overstatement; the -existing differences from receipts, including Enhanced FRS's larger -overstatement, still require dataset-specific calibration. Aggregate impact -CI compares the combined VAT report group against the PR's base. - -Two limits matter when interpreting the split. Model year 2024 represents -2024-25 but reads annual policy before the January 2025 commencement, so -`P0` is zero for that year. The £460m part-year receipt remains in general -VAT; this change does not model its separate timing. Also, general `vat` -can be negative when a household has imputed or entered school attendance -and too little recorded consumption to cover the allocated school -component. It is a receipt-allocation residual. Clipping it to zero would -break the receipts identity; the combined current-law amount still equals -the original gross amount. +`s` is the costing divided by OBR cash-basis VAT receipts for the same +fiscal year (March 2026 EFO, table 3.8): + +| Fiscal year | School-fee VAT (£m) | VAT receipts (£m) | Share | +|---|---:|---:|---:| +| 2025-26 | 1,505 | 180,169 | 0.8353% | +| 2026-27 | 1,560 | 187,704 | 0.8311% | +| 2027-28 | 1,610 | 195,923 | 0.8218% | +| 2028-29 | 1,665 | 204,591 | 0.8138% | +| 2029-30 | 1,725 | 213,118 | 0.8094% | + +The 2029-30 share is held after it. Each value starts on 1 April, so a model +year reads its fiscal year's share. `vat` and `baseline_vat` both read it +from the simulation's own parameters, as `baseline_private_school_vat` reads +its assumptions; the baseline parameter tree reads 1 January, before the +fiscal year's value takes effect. + +Properties of the split: + +- Because `0 <= s < 1`, `vat` has the sign of `G`: the allocation never makes + a household's VAT negative, and repealing the school charge cannot either. +- `vat` reads neither attendance nor fees. Restoring the school exemption sets + `P` to zero and leaves `vat` unchanged, so receipts fall by the weighted + school charge, `sum(w * P0)`, with no second grossing factor. A contributed + rate moves only `P`. +- A standard-rate change moves each head on its own base: `G * (1 - s)` with + the rate on recorded consumption, and `P` with the rate on the fees. +- With household weights `w`, current-law combined receipts are + `sum(w * G) - s * sum(w * G) + sum(w * P0)`. They equal the original gross + amount when the population's school charge is the share `s` of its gross + VAT, and otherwise differ from it by `sum(w * P0) - s * sum(w * G)`. + +The earlier design subtracted each household's own school charge from its +own gross VAT. Combined receipts then equalled `G` household by household, +but a pupil with little recorded consumption had negative general VAT, which +a repeal left in place. On the certified Enhanced FRS (policyengine-uk-data +1.58.1) that made general VAT negative for 549 households with non-negative +gross VAT in 2025-26 (306,750 weighted households, -£0.58bn), rising to 572 +in 2030-31. + +A share measured on each simulation's own population, +`sum(w * P0) / sum(w * G)`, would preserve combined receipts exactly, and was +considered. It makes every household's general VAT depend on the imputed +attendance of every other household, and attendance is imputed from net +income. Raising any household's income tax, National Insurance or council tax +could then move every household's VAT, by its gross VAT times the change in +the share. The aggregate-impact counted-once check fails a head wherever such +a response is one for one, as it did for `vat` in fewer than 10 households +under the earlier design; spreading the response to every household makes +that coincidence likelier. A household situation would also measure its own +school charge against its own VAT. The official share keeps each household's +VAT a function of its own consumption. + +Measured on the certified Enhanced FRS, the official share changes +current-law combined receipts by -£0.17bn in 2025-26, -£0.16bn in 2026-27, +-£0.13bn in 2027-28, -£0.11bn in 2028-29 and -£0.04bn in 2029-30, at most +0.07% of that dataset's VAT. Two overstatements nearly offset there: gross +VAT is 41% above OBR receipts (£253.7bn against £180.2bn in 2025-26), and +the modelled school charge is £1.95bn, against HMRC's £1.505bn, because +attendance is imputed for 751,000 weighted pupils (policyengine-uk-data issue +568). The difference belongs to those two calibrations, not to the split. + +The receipts check for the coverage factor is unchanged. Model year 2024 has +no school charge and a zero share, so, using the rounded 2024-25 Microcosm +inputs above, `80.48 / (0.67 * 0.7) = £171.599bn`, compared with OBR's +£170.994bn: about 0.35% above receipts. The existing differences from +receipts, including Enhanced FRS's larger overstatement, still require +dataset-specific calibration. Aggregate impact CI compares the combined VAT +report group against the PR's base. + +Model year 2024 represents 2024-25 but reads annual policy before the +January 2025 commencement, so `P0` and the share are both zero for that +year. The £460m part-year receipt (0.27% of £170,994m) remains in general +VAT; this change does not model its separate timing. ## Interaction with an explicit energy VAT base diff --git a/policyengine_uk/data/fiscal_partition.yaml b/policyengine_uk/data/fiscal_partition.yaml index c72d5d561a..d92e6213a4 100644 --- a/policyengine_uk/data/fiscal_partition.yaml +++ b/policyengine_uk/data/fiscal_partition.yaml @@ -240,28 +240,16 @@ tax_heads: report_group: vat payer: consumer incidence totals: [gov_tax] - sign: any + sign: nonnegative data_note: >- - A residual receipt allocation: baseline school-fee VAT at the current - standard rate is subtracted before counting the separate school head. - Sparse calculator consumption can make the residual negative; do not - clip it, which would inflate combined receipts. Separately, - Negative for 76 households on policyengine-uk-data 1.58.0 - (enhanced_frs_2024_25) in every year from 2025 to 2030, -£72m weighted - in 2026: full_rate_vat_consumption is negative there because the - dataset's consumption (44 households) or full_rate_vat_expenditure_rate - (32 households) is negative. policyengine-uk-data issue 565. Measured - 2026-10-11. - reads: - - head: income_tax - route: vat -> baseline_private_school_vat -> attends_private_school -> income_tax - why: attendance determines the school-fee receipt allocation; the tax itself is not added - - head: nics_individual - route: vat -> baseline_private_school_vat -> attends_private_school -> national_insurance - why: attendance determines the school-fee receipt allocation; the tax itself is not added - - head: council_tax - route: vat -> baseline_private_school_vat -> attends_private_school -> household_benefits -> council_tax_benefit -> council_tax - why: attendance determines the school-fee receipt allocation; the tax itself is not added + Gross VAT less private school fees' official share of VAT receipts, + which the separate school head counts. The share is below one, so the + allocation keeps the sign of gross VAT. Negative for 76 households on + policyengine-uk-data 1.58.0 (enhanced_frs_2024_25) in every year from + 2025 to 2030, -£72m weighted in 2026: full_rate_vat_consumption is + negative there because the dataset's consumption (44 households) or + full_rate_vat_expenditure_rate (32 households) is negative. + policyengine-uk-data issue 565. Measured 2026-10-11. - id: vat_change label: Change in VAT from its baseline variable: vat_change @@ -277,15 +265,6 @@ tax_heads: - head: vat route: vat_change adds vat and subtracts baseline_vat why: a change measure built from the level; the two are never in the same total - - head: income_tax - route: vat_change -> vat -> baseline_private_school_vat -> attends_private_school -> income_tax - why: attendance determines the school-fee receipt allocation; the tax itself is not added - - head: nics_individual - route: vat_change -> vat -> baseline_private_school_vat -> attends_private_school -> national_insurance - why: attendance determines the school-fee receipt allocation; the tax itself is not added - - head: council_tax - route: vat_change -> vat -> baseline_private_school_vat -> attends_private_school -> household_benefits -> council_tax_benefit -> council_tax - why: attendance determines the school-fee receipt allocation; the tax itself is not added # Standard-rated from 1 January 2025: Finance Act 2025 s.47 inserts VATA # 1994 Sch 9 Part 3, taking private school education, vocational training # and closely related board and lodging out of the Group 6 exemption, for @@ -705,15 +684,16 @@ overlap_guards: heads: [vat, private_school_vat] property: >- Grossed-up consumption VAT is fitted to receipts including school-fee - VAT. vat subtracts baseline_private_school_vat times the current - standard rate divided by its baseline rate, then gov_tax adds - private_school_vat. At current law the subtraction equals the school - head household by household, preserving combined receipts exactly. - Scaling the subtraction with the standard rate also prevents a second - school-fee rate response. School-specific exemption and contributed - rate reforms move only the school head. Both baseline measures hold - this simulation's fee and attendance assumptions fixed. See - test_private_school_vat_receipts.py and docs/engineering/vat-coverage.md. + VAT. vat keeps gross VAT less the fees' official share of receipts + (gov.simulation.vat.private_school_share_of_receipts), then gov_tax + adds private_school_vat. vat reads neither attendance nor fees, so + school-specific exemption and contributed rate reforms move only the + school head, and a standard-rate change moves each head on its own + base. At current law combined receipts change by the weighted school + charge less the share of weighted gross VAT. Both baseline measures + hold this simulation's fee, attendance and share assumptions fixed. + See test_private_school_vat_receipts.py and + docs/engineering/vat-coverage.md. - id: council_tax_gross_and_reduction kind: gross_and_net heads: [council_tax] @@ -738,7 +718,7 @@ overlap_guards: here as well. - id: eligibility_reads kind: read_not_inclusion - heads: [tv_licence, vat, vat_change, private_school_vat, private_school_vat_change] + heads: [tv_licence, private_school_vat, private_school_vat_change] property: >- These heads reach other heads of the same total only through eligibility or imputation (see each head's reads). Raising the other diff --git a/policyengine_uk/parameters/gov/simulation/vat/private_school_share_of_receipts.yaml b/policyengine_uk/parameters/gov/simulation/vat/private_school_share_of_receipts.yaml new file mode 100644 index 0000000000..a378f1ee3b --- /dev/null +++ b/policyengine_uk/parameters/gov/simulation/vat/private_school_share_of_receipts.yaml @@ -0,0 +1,30 @@ +description: VAT on private school fees as a share of total VAT receipts. Household VAT grossed up to total receipts is reduced by this share, because private school VAT is counted separately. +metadata: + unit: /1 + period: year + economy: false + label: Private school fees' share of VAT receipts + derivation: >- + HMRC's OBR-certified costing of VAT on private school fees divided by OBR + cash-basis VAT receipts for the same fiscal year: 1,505 / 180,169 in + 2025-26, 1,560 / 187,704 in 2026-27, 1,610 / 195,923 in 2027-28, 1,665 / + 204,591 in 2028-29 and 1,725 / 213,118 in 2029-30 (£m). Each value starts + on 1 April, so a model year reads its fiscal year's share. The 2024-25 + part-year receipt (£460m from 1 January 2025, 0.27% of £170,994m) is left + in general VAT, as private_school_vat is zero in model year 2024. The + survey consumption coverage is fitted to total receipts, so the grossed-up + amount includes this share. vat and baseline_vat both read it from the + simulation's own parameters, so that the baseline reads each fiscal + year's value; docs/engineering/vat-coverage.md sets out the allocation. + reference: + - title: HMRC, Applying VAT to private school fees (updated 15 November 2024), Exchequer impact (Autumn Budget 2024 Table 5.1, certified by the OBR) + href: https://www.gov.uk/government/publications/vat-on-private-school-fees/applying-vat-to-private-school-fees + - title: OBR, Economic and fiscal outlook March 2026, detailed forecast tables, receipts, table 3.8 (Value added tax, cash basis) + href: https://obr.uk/efo/economic-and-fiscal-outlook-march-2026/ +values: + 2010-01-01: 0 + 2025-04-01: 0.008353 + 2026-04-01: 0.008311 + 2027-04-01: 0.008218 + 2028-04-01: 0.008138 + 2029-04-01: 0.008094 diff --git a/policyengine_uk/parameters/gov/simulation/vat/survey_consumption_coverage.yaml b/policyengine_uk/parameters/gov/simulation/vat/survey_consumption_coverage.yaml index 0abedff931..cfaddeb8b3 100644 --- a/policyengine_uk/parameters/gov/simulation/vat/survey_consumption_coverage.yaml +++ b/policyengine_uk/parameters/gov/simulation/vat/survey_consumption_coverage.yaml @@ -20,11 +20,12 @@ metadata: VAT on that dataset 38% or 44% above 2024-25 receipts. Re-derive when the dataset it should match changes, with docs/engineering/vat-coverage.md, which lists the inputs, file hashes and per-dataset results. These receipts - already include private school VAT. The vat formula therefore subtracts - the baseline school-fee tax base at the current standard rate before - gov_tax adds private_school_vat. At current law the two heads sum to the - original receipt-calibrated VAT, so no extra school receipts are added - to this denominator. See the receipt-partition derivation in that document. + already include private school VAT. The vat formula therefore removes + school-fee VAT's share of the grossed-up amount before gov_tax adds + private_school_vat. On data, at current law, the two heads sum to the + original receipt-calibrated VAT in aggregate, so no extra school receipts + are added to this denominator. See the receipt-partition derivation in + that document. reference: - title: OBR, Economic and fiscal outlook March 2026, detailed forecast tables, receipts, table 3.8 href: https://obr.uk/efo/economic-and-fiscal-outlook-march-2026/ diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/vat/vat_grossing.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/vat/vat_grossing.yaml index 934f838e84..4ba456d1f6 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/vat/vat_grossing.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/vat/vat_grossing.yaml @@ -5,9 +5,11 @@ full_rate_vat_consumption: 10_000 reduced_rate_vat_consumption: 1_000 output: - # (10,000 x 20% + 1,000 x 5%) / (0.67 x 0.7) = 2,050 / 0.469 - vat: 4_371.00 - baseline_vat: 4_371.00 + # (10,000 x 20% + 1,000 x 5%) / (0.67 x 0.7) = 2,050 / 0.469 = 4,371.00, + # less private school fees' official share of 2025-26 VAT receipts + # (0.008353), which private_school_vat counts separately. + vat: 4_334.49 + baseline_vat: 4_334.49 - name: With both factors at one, VAT is the statutory liability on recorded consumption period: 2025 @@ -18,7 +20,8 @@ full_rate_vat_consumption: 10_000 reduced_rate_vat_consumption: 1_000 output: - vat: 2_050 + # 2,050 x (1 - 0.008353) + vat: 2_032.88 - name: The household share alone grosses survey-complete VAT up to receipts period: 2025 @@ -28,8 +31,8 @@ full_rate_vat_consumption: 7_000 reduced_rate_vat_consumption: 0 output: - # 7,000 x 20% / 0.7 - vat: 2_000 + # 7,000 x 20% / 0.7 x (1 - 0.008353) + vat: 1_983.29 - name: A reform to the deprecated combined factor still takes effect period: 2025 @@ -39,5 +42,5 @@ full_rate_vat_consumption: 10_000 reduced_rate_vat_consumption: 1_000 output: - # 2,050 / 0.5 - vat: 4_100 + # 2,050 / 0.5 x (1 - 0.008353) + vat: 4_065.75 diff --git a/policyengine_uk/tests/test_private_school_vat_properties.py b/policyengine_uk/tests/test_private_school_vat_properties.py index 8972741b09..a47f9a4883 100644 --- a/policyengine_uk/tests/test_private_school_vat_properties.py +++ b/policyengine_uk/tests/test_private_school_vat_properties.py @@ -20,9 +20,9 @@ - monotone: private_school_vat never falls when the reform rate or the standard rate rises; - receipt partition: school-specific rate and exemption reforms leave the - residual vat head unchanged, and private_school_vat is independent of - recorded consumption. General VAT nets out the baseline school tax base - before the school head is counted (test_private_school_vat_receipts.py); + general vat head unchanged, and private_school_vat is independent of + recorded consumption. General VAT is gross VAT less school-fee VAT's + share of receipts (test_private_school_vat_receipts.py); - level and change: private_school_vat_change equals private_school_vat minus baseline_private_school_vat; baseline_private_school_vat equals private_school_vat under baseline policy with this simulation's mean fee diff --git a/policyengine_uk/tests/test_private_school_vat_receipts.py b/policyengine_uk/tests/test_private_school_vat_receipts.py index d2ed494478..99dd780e8c 100644 --- a/policyengine_uk/tests/test_private_school_vat_receipts.py +++ b/policyengine_uk/tests/test_private_school_vat_receipts.py @@ -1,35 +1,93 @@ -"""School VAT is a partition of receipt-calibrated VAT, not extra receipts.""" +"""School VAT is a share of receipt-calibrated VAT, not extra receipts. +Let G be household VAT grossed up to total VAT receipts, P0 the baseline +school charge (baseline_private_school_vat) and s private school fees' +official share of VAT receipts (gov.simulation.vat.private_school_share_of_receipts). +General VAT is G x (1 - s); private_school_vat counts the school charge. + +Invariants, for any population and year, with the exemption at current law +or restored: + +- non-negative: as 0 <= s < 1, the allocation never makes general VAT + negative, so vat and vat + private_school_vat are non-negative wherever + recorded consumption is; +- national identity: weighted vat + private_school_vat is weighted G, less s + times weighted G, plus weighted P0. It equals weighted G when the + population's school charge is s of its gross VAT, and restoring the + exemption lowers it by exactly weighted P0; +- once: vat reads neither attendance nor fees, so school-specific reforms + leave it unchanged household by household, and vat_change is zero at + baseline. +""" + +import numpy as np import pytest +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st from policyengine_uk import Simulation +from policyengine_uk.system import system ALWAYS = "2010-01-01.2100-12-31" STANDARD = "gov.hmrc.vat.standard_rate" EXEMPT = "gov.hmrc.vat.private_school_fees_exempt" CONTRIB = "gov.contrib.labour.private_school_vat" LEGACY = "gov.simulation.microdata_vat_coverage" +FEES = "gov.simulation.private_school_vat.private_school_fees" +BASIS = "gov.simulation.private_school_vat.private_school_vat_basis" +FACTOR = 0.67 * 0.7 +# HMRC's OBR-certified costing of VAT on private school fees (Autumn Budget +# 2024 Table 5.1, £m) and OBR cash-basis VAT receipts (March 2026 EFO table +# 3.8, £m), by the model year of each fiscal year. +COSTING = {2025: 1_505, 2026: 1_560, 2027: 1_610, 2028: 1_665, 2029: 1_725} +RECEIPTS = { + 2025: 180_168.94, + 2026: 187_704.37, + 2027: 195_922.62, + 2028: 204_591.27, + 2029: 213_118.19, +} +# The share is zero before the 2025-26 model year and the 2029-30 value is +# held from then on. +OFFICIAL_SHARE = { + 2024: 0.0, + **{year: COSTING[year] / RECEIPTS[year] for year in COSTING}, + 2030: COSTING[2029] / RECEIPTS[2029], + 2031: COSTING[2029] / RECEIPTS[2029], +} +# Model values are float32; the parameter is rounded to six decimal places. +RTOL = 1e-6 +SHARE_ATOL = 5e-7 + + +def share(year): + return system.parameters.gov.simulation.vat.private_school_share_of_receipts(year) + +@pytest.mark.parametrize("year", sorted(OFFICIAL_SHARE)) +def test_official_share_is_the_costing_over_receipts(year): + assert share(year) == pytest.approx(OFFICIAL_SHARE[year], abs=SHARE_ATOL) -def simulation(year=2025, full=10_000, reduced=1_000, reforms=None): + +def simulation(year=2025, full=10_000, reduced=1_000, reforms=None, pupils=1): changes = { - "gov.simulation.private_school_vat.private_school_fees": {ALWAYS: 10_000}, - "gov.simulation.private_school_vat.private_school_vat_basis": {ALWAYS: 1}, + FEES: {ALWAYS: 10_000}, + BASIS: {ALWAYS: 1}, **{key: {ALWAYS: value} for key, value in (reforms or {}).items()}, } + people = {"adult": {"age": {year: 40}}} + for i in range(pupils): + people[f"pupil_{i}"] = { + "age": {year: 10}, + "attends_private_school": {year: True}, + } return Simulation( situation={ - "people": { - "adult": {"age": {year: 40}}, - "pupil": { - "age": {year: 10}, - "attends_private_school": {year: True}, - }, - }, - "benunits": {"family": {"members": ["adult", "pupil"]}}, + "people": people, + "benunits": {"family": {"members": list(people)}}, "households": { "household": { - "members": ["adult", "pupil"], + "members": list(people), "full_rate_vat_consumption": {year: full}, "reduced_rate_vat_consumption": {year: reduced}, } @@ -46,21 +104,28 @@ def amount(sim, variable, year=2025): @pytest.mark.parametrize("year", [2024, 2025, 2031]) @pytest.mark.parametrize("standard", [0, 0.2, 0.25]) @pytest.mark.parametrize("coverage", [None, 0.5]) -def test_combined_vat_preserves_receipt_calibration(year, standard, coverage): +def test_vat_is_gross_vat_less_the_official_share(year, standard, coverage): reforms = {STANDARD: standard} if coverage is not None: reforms[LEGACY] = coverage sim = simulation(year, reforms=reforms) - factor = 0.67 * 0.7 if coverage is None else coverage - expected = (10_000 * standard + 1_000 * 0.05) / factor - combined = amount(sim, "vat", year) + amount(sim, "private_school_vat", year) - assert combined == pytest.approx(expected, abs=0.01) - # The fee-rate response is already inside the receipt-calibrated total. - baseline_total = (10_000 * 0.2 + 1_000 * 0.05) / (0.67 * 0.7) + keep = 1 - share(year) + gross = (10_000 * standard + 1_000 * 0.05) / (coverage or FACTOR) + assert amount(sim, "vat", year) == pytest.approx(gross * keep, abs=0.01) + baseline_gross = (10_000 * 0.2 + 1_000 * 0.05) / FACTOR + assert amount(sim, "baseline_vat", year) == pytest.approx( + baseline_gross * keep, abs=0.01 + ) + # The fees bear the standard rate from 2025; each head responds to its + # own base, so a standard-rate change moves the fees' VAT once. + school = 0 if year < 2025 else 10_000 * standard + baseline_school = 0 if year < 2025 else 10_000 * 0.2 + assert amount(sim, "private_school_vat", year) == pytest.approx(school) change = amount(sim, "vat_change", year) + amount( sim, "private_school_vat_change", year ) - assert change == pytest.approx(expected - baseline_total, abs=0.01) + expected = (gross - baseline_gross) * keep + school - baseline_school + assert change == pytest.approx(expected, abs=0.01) @pytest.mark.parametrize( @@ -69,41 +134,189 @@ def test_combined_vat_preserves_receipt_calibration(year, standard, coverage): ({EXEMPT: True}, 0, -2_000), ({CONTRIB: 0.2}, 2_000, 0), ({CONTRIB: 0.25}, 2_500, 500), - ({STANDARD: 0.25, EXEMPT: True}, 0, -2_500), + ({STANDARD: 0.25, EXEMPT: True}, 0, -2_000), ], ) -def test_school_specific_reform_changes_combined_receipts_once(reforms, school, change): +def test_school_specific_reform_changes_only_the_school_head(reforms, school, change): sim = simulation(reforms=reforms) standard = reforms.get(STANDARD, 0.2) - gross = (10_000 * standard + 1_000 * 0.05) / (0.67 * 0.7) + gross = (10_000 * standard + 1_000 * 0.05) / FACTOR assert amount(sim, "private_school_vat") == pytest.approx(school) - assert amount(sim, "vat") + school == pytest.approx(gross + change, abs=0.01) + assert amount(sim, "private_school_vat_change") == pytest.approx(change) + assert amount(sim, "vat") == pytest.approx(gross * (1 - share(2025)), abs=0.01) -def test_sparse_calculator_inputs_do_not_create_extra_receipts(): - sim = simulation(full=0, reduced=0) - assert amount(sim, "private_school_vat") == 2_000 - assert amount(sim, "vat") == -2_000 - assert amount(sim, "baseline_vat") == -2_000 +@pytest.mark.parametrize("exempt", [False, True]) +def test_a_pupil_with_no_recorded_consumption_has_no_negative_vat(exempt): + # This household's general VAT was -2,000 when its own school charge was + # subtracted from its own VAT; restoring the exemption left it there. + sim = simulation(full=0, reduced=0, reforms={EXEMPT: exempt}) + assert amount(sim, "vat") == 0 + assert amount(sim, "baseline_vat") == 0 assert amount(sim, "vat_change") == 0 + assert amount(sim, "private_school_vat") == (0 if exempt else 2_000) + assert amount(sim, "vat") + amount(sim, "private_school_vat") >= 0 -def test_fractional_fees_do_not_create_a_baseline_vat_change(): - sim = simulation( - full=0, - reduced=0, - reforms={"gov.simulation.private_school_vat.private_school_fees": 5_000.10}, - ) - # P0 is float32 1000.02001953125; multiplying by .2 then dividing - # would produce 1000.0200805664062 and a spurious change at baseline. - assert amount(sim, "vat_change") == 0 - assert amount(sim, "vat") + amount(sim, "private_school_vat") == 0 +def test_vat_reads_neither_attendance_nor_fees(): + without = simulation(pupils=0) + for pupils, fees in ((1, 10_000), (3, 5_000.10)): + sim = simulation(pupils=pupils, reforms={FEES: fees}) + assert amount(sim, "vat") == amount(without, "vat") + assert amount(sim, "vat_change") == 0 def test_published_microcosm_receipts_check_is_unchanged(): # Rounded aggregate inputs published in docs/engineering/vat-coverage.md. + # Model year 2024 has no school charge and no share to reallocate. sim = simulation(year=2024, full=396.15e9, reduced=25.02e9) combined = amount(sim, "vat", 2024) + amount(sim, "private_school_vat", 2024) - expected = (0.2 * 396.15e9 + 0.05 * 25.02e9) / (0.67 * 0.7) + expected = (0.2 * 396.15e9 + 0.05 * 25.02e9) / FACTOR assert combined == pytest.approx(expected) assert combined / 170.994e9 == pytest.approx(1, rel=0.004) + + +def population_simulation(households, year=2025, reforms=None, fees=10_000.0): + """One simulation of weighted households: weight, pupils, full and reduced.""" + people, benunits, hhs = {}, {}, {} + for i, h in enumerate(households): + members = [f"adult_{i}"] + people[f"adult_{i}"] = {"age": {year: 45}} + for j in range(h["pupils"]): + people[f"pupil_{i}_{j}"] = { + "age": {year: 7 + j}, + "attends_private_school": {year: True}, + } + members.append(f"pupil_{i}_{j}") + benunits[f"benunit_{i}"] = {"members": members} + hhs[f"household_{i}"] = { + "members": members, + "household_weight": {year: h["weight"]}, + "full_rate_vat_consumption": {year: h["full"]}, + "reduced_rate_vat_consumption": {year: h.get("reduced", 0.0)}, + } + return Simulation( + situation={"people": people, "benunits": benunits, "households": hhs}, + reform={ + FEES: {ALWAYS: fees}, + BASIS: {ALWAYS: 1}, + **{key: {ALWAYS: value} for key, value in (reforms or {}).items()}, + }, + ) + + +def values(sim, year=2025): + return { + variable: np.asarray(sim.calculate(variable, year), dtype=float) + for variable in ( + "household_weight", + "vat", + "baseline_vat", + "vat_change", + "private_school_vat", + "baseline_private_school_vat", + ) + } + + +def gross_vat(households): + return ( + np.array([h["full"] * 0.2 + h.get("reduced", 0.0) * 0.05 for h in households]) + / FACTOR + ) + + +def weighted(weight, *amounts): + return float(np.sum(weight * sum(amounts))) + + +def test_national_identity_on_a_small_weighted_population(): + s = share(2025) + population = [ + {"weight": 1_000.0, "pupils": 0, "full": 100_000.0}, + {"weight": None, "pupils": 1, "full": 0.0}, + {"weight": 20.0, "pupils": 2, "full": 20_000.0, "reduced": 4_000.0}, + ] + # Weight the pupil household without consumption so that the school + # charge, 2,000 a pupil, is exactly the official share of the + # population's gross VAT. + others = gross_vat([population[0], population[2]]) + population[1]["weight"] = ( + s * (1_000 * others[0] + 20 * others[1]) - 20 * 2 * 2_000 + ) / 2_000 + weight = np.array([h["weight"] for h in population]) + gross = gross_vat(population) + school = np.array([h["pupils"] for h in population]) * 2_000.0 + assert weighted(weight, school) == pytest.approx(s * weighted(weight, gross)) + + baseline = values(population_simulation(population)) + np.testing.assert_allclose(baseline["vat"], gross * (1 - s), rtol=RTOL) + np.testing.assert_array_equal(baseline["vat_change"], 0) + combined = weighted(weight, baseline["vat"], baseline["private_school_vat"]) + # Current-law receipts are unchanged when the school charge is the + # official share of gross VAT. + assert combined == pytest.approx(weighted(weight, gross), rel=RTOL) + + # Restoring the exemption lowers receipts by the weighted baseline charge + # and leaves general VAT alone. + repeal = values(population_simulation(population, reforms={EXEMPT: True})) + np.testing.assert_array_equal(repeal["vat"], baseline["vat"]) + np.testing.assert_array_equal(repeal["private_school_vat"], 0) + repeal_combined = weighted(weight, repeal["vat"], repeal["private_school_vat"]) + assert combined - repeal_combined == pytest.approx( + weighted(weight, school), rel=RTOL + ) + + +household = st.fixed_dictionaries( + { + "weight": st.floats(1, 5_000), + "pupils": st.integers(0, 3), + "full": st.one_of(st.just(0.0), st.floats(1_000, 150_000)), + "reduced": st.one_of(st.just(0.0), st.floats(100, 30_000)), + } +) + + +@settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) +@given( + households=st.lists(household, min_size=1, max_size=4), + year=st.integers(2024, 2030), + fees=st.floats(1_000, 50_000), +) +@example( + households=[{"weight": 1.0, "pupils": 1, "full": 0.0, "reduced": 0.0}], + year=2025, + fees=10_000.0, +) +def test_vat_is_never_negative_and_receipts_are_counted_once(households, year, fees): + results = { + scenario: values(population_simulation(households, year, reforms, fees), year) + for scenario, reforms in (("baseline", {}), ("repeal", {EXEMPT: True})) + } + for scenario, result in results.items(): + assert (result["vat"] >= 0).all(), scenario + assert (result["vat"] + result["private_school_vat"] >= 0).all(), scenario + baseline, repeal = results["baseline"], results["repeal"] + # Differential: G x (1 - s) from the inputs and the published shares. + gross = gross_vat(households) + np.testing.assert_allclose( + baseline["vat"], gross * (1 - OFFICIAL_SHARE[year]), rtol=1e-5, atol=0.01 + ) + np.testing.assert_array_equal(baseline["vat_change"], 0) + np.testing.assert_array_equal(repeal["vat"], baseline["vat"]) + np.testing.assert_array_equal(repeal["private_school_vat"], 0) + weight = baseline["household_weight"] + school = baseline["baseline_private_school_vat"] + combined = weighted(weight, baseline["vat"], baseline["private_school_vat"]) + expected = weighted(weight, gross) * (1 - share(year)) + weighted(weight, school) + assert combined == pytest.approx(expected, rel=1e-5, abs=0.01) + repeal_combined = weighted(weight, repeal["vat"], repeal["private_school_vat"]) + assert combined - repeal_combined == pytest.approx( + weighted(weight, school), rel=1e-5, abs=0.01 + ) diff --git a/policyengine_uk/tests/test_vat_grossing_legacy.py b/policyengine_uk/tests/test_vat_grossing_legacy.py index 32c98a28c7..1863aa2f5c 100644 --- a/policyengine_uk/tests/test_vat_grossing_legacy.py +++ b/policyengine_uk/tests/test_vat_grossing_legacy.py @@ -19,6 +19,9 @@ }, } RAW_VAT = 10_000 * 0.2 + 1_000 * 0.05 +# A situation's VAT excludes private school fees' official share of 2025-26 +# VAT receipts (gov.simulation.vat.private_school_share_of_receipts). +KEEP = 1 - 0.008353 def test_legacy_default_is_the_product_of_the_two_factors(): @@ -34,13 +37,13 @@ def test_legacy_default_is_the_product_of_the_two_factors(): def test_reform_dict_to_legacy_path_changes_vat(): reform = Reform.from_dict({LEGACY: {"2010-01-01.2100-12-31": 0.5}}, "uk") sim = Simulation(situation=SITUATION, reform=reform) - assert sim.calculate("vat", 2025)[0] == pytest.approx(RAW_VAT / 0.5) + assert sim.calculate("vat", 2025)[0] == pytest.approx(RAW_VAT / 0.5 * KEEP) def test_scenario_to_legacy_path_changes_vat(): scenario = Scenario(parameter_changes={LEGACY: {"year:2025:1": 0.5}}) sim = Simulation(situation=SITUATION, scenario=scenario) - assert sim.calculate("vat", 2025)[0] == pytest.approx(RAW_VAT / 0.5) + assert sim.calculate("vat", 2025)[0] == pytest.approx(RAW_VAT / 0.5 * KEEP) def test_untouched_legacy_path_uses_the_two_factors(): @@ -53,4 +56,4 @@ def test_untouched_legacy_path_uses_the_two_factors(): "uk", ) sim = Simulation(situation=SITUATION, reform=reform) - assert sim.calculate("vat", 2025)[0] == pytest.approx(RAW_VAT / 0.7) + assert sim.calculate("vat", 2025)[0] == pytest.approx(RAW_VAT / 0.7 * KEEP) diff --git a/policyengine_uk/variables/gov/hmrc/baseline_vat.py b/policyengine_uk/variables/gov/hmrc/baseline_vat.py index 786792e2b5..0716f4aed7 100644 --- a/policyengine_uk/variables/gov/hmrc/baseline_vat.py +++ b/policyengine_uk/variables/gov/hmrc/baseline_vat.py @@ -19,8 +19,10 @@ def formula(household, period, parameters): ) # Survey consumption under-records spending, and households bear only # part of VAT liabilities, so household VAT is grossed up to receipts. - coverage = vat_grossing_factor(p.simulation) - # Match vat's receipt allocation under baseline policy. Using this - # simulation's attendance and fee assumptions makes assumptions-only - # reforms leave both VAT change measures at zero. - return raw_vat / coverage - household("baseline_private_school_vat", period) + gross_vat = raw_vat / vat_grossing_factor(p.simulation) + # vat's allocation of private school fees' share of receipts. The + # share is read from this simulation's parameters, as + # baseline_private_school_vat reads its assumptions: the baseline tree + # reads 1 January, before each fiscal year's value takes effect. + share = parameters(period).gov.simulation.vat.private_school_share_of_receipts + return gross_vat * (1 - share) diff --git a/policyengine_uk/variables/gov/hmrc/vat.py b/policyengine_uk/variables/gov/hmrc/vat.py index ee78430d0a..e00a2c7fb5 100644 --- a/policyengine_uk/variables/gov/hmrc/vat.py +++ b/policyengine_uk/variables/gov/hmrc/vat.py @@ -5,10 +5,8 @@ class vat(Variable): label = "VAT excluding private school fees" documentation = ( - "Receipt-calibrated VAT less the baseline school-fee tax base at the " - "current standard rate. Private school VAT is counted separately. " - "The residual can be negative when recorded consumption is too low " - "to cover the school-fee allocation." + "Receipt-calibrated VAT less private school fees' share of VAT " + "receipts, which private_school_vat counts separately." ) entity = Household definition_period = YEAR @@ -25,16 +23,9 @@ def formula(household, period, parameters): ) # Survey consumption under-records spending, and households bear only # part of VAT liabilities, so household VAT is grossed up to receipts. - coverage = vat_grossing_factor(p.simulation) - # The receipts used to fit coverage already include school-fee VAT. - # Reclassify its baseline tax base, at the current standard rate, so - # neither current law nor a standard-rate reform counts it twice. - # School-specific rate/exemption reforms change the separate head. - baseline_school_vat = household("baseline_private_school_vat", period) - baseline_standard_rate = parameters(period).baseline.gov.hmrc.vat.standard_rate - # Form the scalar ratio first: it is exactly one at baseline. A - # float32 multiply then divide can otherwise create a tiny vat_change. - school_vat_in_receipts = baseline_school_vat * ( - p.hmrc.vat.standard_rate / baseline_standard_rate - ) - return raw_vat / coverage - school_vat_in_receipts + gross_vat = raw_vat / vat_grossing_factor(p.simulation) + # Those receipts include VAT on private school fees, which + # private_school_vat counts. Every household's VAT gives up the fees' + # share of receipts, so the allocation never makes it negative and + # school-specific reforms change only private_school_vat. + return gross_vat * (1 - p.simulation.vat.private_school_share_of_receipts) From d4a4ccf2f02c9b0e156b909f48b96ce16ba50ba7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 23 Oct 2026 04:31:28 -0400 Subject: [PATCH 09/12] Pin the OBR receipts cells behind the school-fee VAT share The share's denominators are OBR March 2026 receipts sheet 3.8, row 13, E13:I13; record the cells and the workbook's SHA-256 so the values can be reproduced. Also correct three passages that still said the two VAT heads sum exactly to the gross amount: they match it to within the share's accuracy (at most 0.07% below on Enhanced FRS 1.58.1). Co-Authored-By: Claude Opus 5.5 --- docs/engineering/vat-coverage.md | 10 +++++++--- .../vat/private_school_share_of_receipts.yaml | 6 +++++- .../simulation/vat/survey_consumption_coverage.yaml | 5 +++-- .../tests/test_private_school_vat_receipts.py | 5 +++-- 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/docs/engineering/vat-coverage.md b/docs/engineering/vat-coverage.md index 45ddbab831..f2dbe859c3 100644 --- a/docs/engineering/vat-coverage.md +++ b/docs/engineering/vat-coverage.md @@ -71,8 +71,12 @@ survey coverage = product / household share (0.7) ``` Dividing each household's statutory VAT by the product gives the gross -amount. At current law, total `vat + private_school_vat` equals this amount, -and equals receipts when the product matches the dataset. +amount. At current law, total `vat + private_school_vat` is close to this +amount but not equal to it: combined receipts are +`(1 - s) * sum(w * G) + sum(w * P0)` (see the receipt-partition derivation +below), so they differ from the gross amount by `sum(w * P0) - s * sum(w * G)`. +On the certified Enhanced FRS (policyengine-uk-data 1.58.1) that difference +is -£36m to -£173m a year from 2025-26 to 2031-32, at most 0.07% of VAT. ### Results @@ -91,7 +95,7 @@ and equals receipts when the product matches the dataset. The parameter takes the Microcosm 2024-25 value, 0.67 (product 0.469), the latest outturn year. -With that product, total current-law `vat + private_school_vat` against receipts is: +With that product, the gross amount against receipts is as below. Current-law `vat + private_school_vat` matches it to within 0.07% (see above): | Dataset | 2024-25 | 2025-26 | 2026-27 | |---|---|---|---| diff --git a/policyengine_uk/parameters/gov/simulation/vat/private_school_share_of_receipts.yaml b/policyengine_uk/parameters/gov/simulation/vat/private_school_share_of_receipts.yaml index a378f1ee3b..0894bf2e8f 100644 --- a/policyengine_uk/parameters/gov/simulation/vat/private_school_share_of_receipts.yaml +++ b/policyengine_uk/parameters/gov/simulation/vat/private_school_share_of_receipts.yaml @@ -16,10 +16,14 @@ metadata: amount includes this share. vat and baseline_vat both read it from the simulation's own parameters, so that the baseline reads each fiscal year's value; docs/engineering/vat-coverage.md sets out the allocation. + The receipts were read from the OBR workbook with SHA-256 + f65b6cb7f96931d4abd85940794eb60f6f65afca8bbf16c74a4548bf92766a28: + 180.16893868541794, 187.70436725810845, 195.922624491872, + 204.59126694885538 and 213.11818629804077 (£bn). reference: - title: HMRC, Applying VAT to private school fees (updated 15 November 2024), Exchequer impact (Autumn Budget 2024 Table 5.1, certified by the OBR) href: https://www.gov.uk/government/publications/vat-on-private-school-fees/applying-vat-to-private-school-fees - - title: OBR, Economic and fiscal outlook March 2026, detailed forecast tables, receipts, table 3.8 (Value added tax, cash basis) + - title: OBR, Economic and fiscal outlook March 2026, detailed forecast tables, receipts (efo-march-2026-detailed-forecast-tables-receipts.xlsx), sheet 3.8, row 13 (Value added tax, cash basis), cells E13:I13 for 2025-26 to 2029-30 (£bn) href: https://obr.uk/efo/economic-and-fiscal-outlook-march-2026/ values: 2010-01-01: 0 diff --git a/policyengine_uk/parameters/gov/simulation/vat/survey_consumption_coverage.yaml b/policyengine_uk/parameters/gov/simulation/vat/survey_consumption_coverage.yaml index cfaddeb8b3..82aa24a8ac 100644 --- a/policyengine_uk/parameters/gov/simulation/vat/survey_consumption_coverage.yaml +++ b/policyengine_uk/parameters/gov/simulation/vat/survey_consumption_coverage.yaml @@ -23,8 +23,9 @@ metadata: already include private school VAT. The vat formula therefore removes school-fee VAT's share of the grossed-up amount before gov_tax adds private_school_vat. On data, at current law, the two heads sum to the - original receipt-calibrated VAT in aggregate, so no extra school receipts - are added to this denominator. See the receipt-partition derivation in + receipt-calibrated VAT to within the share's accuracy (at most 0.07% below + it on Enhanced FRS 2024-25, policyengine-uk-data 1.58.1), so no extra + school receipts are added to this denominator. See the receipt-partition derivation in that document. reference: - title: OBR, Economic and fiscal outlook March 2026, detailed forecast tables, receipts, table 3.8 diff --git a/policyengine_uk/tests/test_private_school_vat_receipts.py b/policyengine_uk/tests/test_private_school_vat_receipts.py index 99dd780e8c..4a389b4451 100644 --- a/policyengine_uk/tests/test_private_school_vat_receipts.py +++ b/policyengine_uk/tests/test_private_school_vat_receipts.py @@ -37,8 +37,9 @@ BASIS = "gov.simulation.private_school_vat.private_school_vat_basis" FACTOR = 0.67 * 0.7 # HMRC's OBR-certified costing of VAT on private school fees (Autumn Budget -# 2024 Table 5.1, £m) and OBR cash-basis VAT receipts (March 2026 EFO table -# 3.8, £m), by the model year of each fiscal year. +# 2024 Table 5.1, £m) and OBR cash-basis VAT receipts (March 2026 EFO detailed +# forecast tables, receipts, sheet 3.8, row 13, cells E13:I13, £m), by the +# model year of each fiscal year. COSTING = {2025: 1_505, 2026: 1_560, 2027: 1_610, 2028: 1_665, 2029: 1_725} RECEIPTS = { 2025: 180_168.94, From 5f583eceffcb24a34b0f2970fd9a4af336479ee2 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 23 Oct 2026 04:48:48 -0400 Subject: [PATCH 10/12] Say which dataset the 0.07% VAT bound was measured on Co-Authored-By: Claude Opus 5.5 --- docs/engineering/vat-coverage.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/engineering/vat-coverage.md b/docs/engineering/vat-coverage.md index f2dbe859c3..7507b25329 100644 --- a/docs/engineering/vat-coverage.md +++ b/docs/engineering/vat-coverage.md @@ -95,7 +95,7 @@ is -£36m to -£173m a year from 2025-26 to 2031-32, at most 0.07% of VAT. The parameter takes the Microcosm 2024-25 value, 0.67 (product 0.469), the latest outturn year. -With that product, the gross amount against receipts is as below. Current-law `vat + private_school_vat` matches it to within 0.07% (see above): +With that product, the gross amount against receipts is as below. On the certified Enhanced FRS (policyengine-uk-data 1.58.1), current-law `vat + private_school_vat` matches it to within 0.07% (see above); the other rows weren't checked against that bound: | Dataset | 2024-25 | 2025-26 | 2026-27 | |---|---|---|---| From c1882c2a98aaf5b2d1c855fa68f2dc15f5ef4661 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 24 Oct 2026 16:30:23 -0400 Subject: [PATCH 11/12] Address review r4: energy-bill property test, restated passages with E - Add test_school_share_never_touches_energy_bill_vat: households with electricity and gas bills across GB and Northern Ireland, 2024-2030. It checks vat = G x (1 - s) + E with E from the statutory rates, that restoring the exemption leaves vat unchanged, that zero-rating electricity moves vat by exactly the change in domestic_energy_vat and leaves private_school_vat unchanged, and the national identity with E. - Tighten the published receipts check to 0.1%. - Restate the doc, the share and coverage parameter metadata, the fiscal partition note, the vat documentation and the test docstrings with E, and replace pre-split figures with CI run 40116746457 at 4f3522828. Co-Authored-By: Claude Opus 5.5 --- docs/engineering/vat-coverage.md | 66 ++++---- policyengine_uk/data/fiscal_partition.yaml | 10 +- .../vat/private_school_share_of_receipts.yaml | 20 ++- .../vat/survey_consumption_coverage.yaml | 17 +-- .../test_private_school_vat_properties.py | 5 +- .../tests/test_private_school_vat_receipts.py | 141 +++++++++++++++++- policyengine_uk/variables/gov/hmrc/vat.py | 3 +- 7 files changed, 204 insertions(+), 58 deletions(-) diff --git a/docs/engineering/vat-coverage.md b/docs/engineering/vat-coverage.md index 7b123fad11..bf7b935777 100644 --- a/docs/engineering/vat-coverage.md +++ b/docs/engineering/vat-coverage.md @@ -73,13 +73,16 @@ survey coverage = product / household share (0.7) ``` Dividing each household's statutory VAT by the product gives the gross -amount. At current law, total `vat + private_school_vat` is close to this -amount but not equal to it: combined receipts are -`(1 - s) * sum(w * G) + sum(w * P0)` (see the receipt-partition derivation -below), so they differ from the gross amount by `sum(w * P0) - s * sum(w * G)`. -On the certified Enhanced FRS (policyengine-uk-data 1.58.1), measured -before energy bills were split out, that difference was -£36m to -£173m a -year from 2025-26 to 2031-32, at most 0.07% of VAT. +amount, `G`; VAT on energy bills, `E`, is added to it without grossing (see +[Energy bills are not grossed up](#energy-bills-are-not-grossed-up)). At +current law, total `vat + private_school_vat` is close to `G + E` but not +equal to it: combined receipts are +`(1 - s) * sum(w * G) + sum(w * E) + sum(w * P0)` (see the receipt-partition +derivation below), so they differ from `G + E` by +`sum(w * P0) - s * sum(w * G)`. On the certified Enhanced FRS +(policyengine-uk-data 1.58.1) that difference is -£5.5m to -£142.5m a year from +2025-26 to 2031-32, at most 0.06% of VAT (Aggregate impact run +[40116746457](https://github.com/PolicyEngine/policyengine-uk/actions/runs/40116746457)). ### Results @@ -98,11 +101,11 @@ year from 2025-26 to 2031-32, at most 0.07% of VAT. The parameter takes the Microcosm 2024-25 value, 0.67 (product 0.469), the latest outturn year. -With that product, the gross amount against receipts is as below, before +With that product, the gross amount against receipts was as below, before energy bills were split out. On the certified Enhanced FRS -(policyengine-uk-data 1.58.1), current-law `vat + private_school_vat` -matched it to within 0.07% then (see above); the other rows weren't checked -against that bound: +(policyengine-uk-data 1.58.1), current-law `vat + private_school_vat` is +within 0.06% of the receipt-calibrated `G + E` (see above); the other rows +weren't checked against that bound: | Dataset | 2024-25 | 2025-26 | 2026-27 | |---|---|---|---| @@ -267,30 +270,27 @@ Properties of the split: `P` to zero and leaves `vat` unchanged, so receipts fall by the weighted school charge, `sum(w * P0)`, with no second grossing factor. A contributed rate moves only `P`. -- A standard-rate change moves each head on its own base: `G * (1 - s)` with - the rate on recorded consumption, `E` with the rates on energy bills, and - `P` with the rate on the fees. +- A standard-rate change moves `G * (1 - s)` and `P`, each on its own base: + recorded consumption and the fees. `E` moves only with the domestic + electricity and reduced rates. - With household weights `w`, current-law combined receipts are `sum(w * G) - s * sum(w * G) + sum(w * E) + sum(w * P0)`. They equal the receipt-calibrated total `sum(w * G) + sum(w * E)` when the population's school charge is the share `s` of its grossed-up VAT, and otherwise differ from it by `sum(w * P0) - s * sum(w * G)`. - `s` is a share of total receipts, but `G` is calibrated to receipts net of - energy-bill VAT. On the calibration dataset, then, `s * sum(w * G)` is the - costing less `s` times energy-bill VAT: on `f9d1922c` in 2025-26, 0.8353% - of £2.01bn, about £17m (arithmetic on the energy table above, not a run). - -The figures in the rest of this section were measured before energy bills -were split out (PolicyEngine/policyengine-uk#2189), on the formula without -`E`; they have not been re-measured since. + energy-bill VAT. Where `G` matches receipts net of energy-bill VAT exactly, + `s * sum(w * G)` falls short of the costing by `s` times energy-bill VAT: + on `f9d1922c`'s 2025-26 energy-bill VAT, 0.8353% of £2.01bn, about £17m + (arithmetic on the energy table above, not a run). The earlier design subtracted each household's own school charge from its own gross VAT. Combined receipts then equalled `G` household by household, but a pupil with little recorded consumption had negative general VAT, which a repeal left in place. On the certified Enhanced FRS (policyengine-uk-data -1.58.1) that made general VAT negative for 549 households with non-negative -gross VAT in 2025-26 (306,750 weighted households, -£0.58bn), rising to 572 -in 2030-31. +1.58.1), before energy bills were split out, that made general VAT negative +for 549 households with non-negative gross VAT in 2025-26 (306,750 weighted +households, -£0.58bn), rising to 572 in 2030-31. A share measured on each simulation's own population, `sum(w * P0) / sum(w * G)`, would preserve combined receipts exactly, and was @@ -305,20 +305,22 @@ that coincidence likelier. A household situation would also measure its own school charge against its own VAT. The official share keeps each household's VAT a function of its own consumption. -Measured on the certified Enhanced FRS, the official share changes -current-law combined receipts by -£0.17bn in 2025-26, -£0.16bn in 2026-27, --£0.13bn in 2027-28, -£0.11bn in 2028-29 and -£0.04bn in 2029-30, at most -0.07% of that dataset's VAT. Two overstatements nearly offset there: gross -VAT is 41% above OBR receipts (£253.7bn against £180.2bn in 2025-26), and -the modelled school charge is £1.95bn, against HMRC's £1.505bn, because +Measured on the certified Enhanced FRS (Aggregate impact run +[40116746457](https://github.com/PolicyEngine/policyengine-uk/actions/runs/40116746457)), +the official share changes current-law combined receipts by -£0.14bn in +2025-26, -£0.13bn in 2026-27, -£0.10bn in 2027-28, -£0.08bn in 2028-29 and +-£0.01bn in 2029-30, at most 0.06% of that dataset's VAT. Two overstatements +nearly offset there: VAT is 40% above OBR receipts (£252.5bn against +£180.2bn in 2025-26), and the modelled school charge is £1.95bn, against +HMRC's £1.505bn, because attendance is imputed for 751,000 weighted pupils (policyengine-uk-data issue 568). The difference belongs to those two calibrations, not to the split. The receipts check for the coverage factor is unchanged. Model year 2024 has no school charge and a zero share, so, using the rounded 2024-25 Microcosm inputs in the energy table above, `79.23 / (0.67 * 0.7) + 1.99 = £170.924bn`, -compared with OBR's £170.994bn: about 0.04% below receipts. The existing differences from -receipts, including Enhanced FRS's larger overstatement, still require +compared with OBR's £170.994bn: about 0.04% below receipts. The existing +differences from receipts, including Enhanced FRS's larger overstatement, still require dataset-specific calibration. Aggregate impact CI compares the combined VAT report group against the PR's base. diff --git a/policyengine_uk/data/fiscal_partition.yaml b/policyengine_uk/data/fiscal_partition.yaml index 833aaffa26..2d1aff19f8 100644 --- a/policyengine_uk/data/fiscal_partition.yaml +++ b/policyengine_uk/data/fiscal_partition.yaml @@ -738,10 +738,12 @@ overlap_guards: kind: overlapping_base heads: [vat, private_school_vat] property: >- - Grossed-up consumption VAT is fitted to receipts including school-fee - VAT. vat keeps gross VAT less the fees' official share of receipts - (gov.simulation.vat.private_school_share_of_receipts), then gov_tax - adds private_school_vat. vat reads neither attendance nor fees, so + Grossed-up consumption VAT is fitted to receipts net of energy-bill + VAT, which include school-fee VAT. vat keeps gross VAT less the fees' + official share of receipts + (gov.simulation.vat.private_school_share_of_receipts), plus VAT on + energy bills, which bears no share; then gov_tax adds + private_school_vat. vat reads neither attendance nor fees, so school-specific exemption and contributed rate reforms move only the school head, and a standard-rate change moves each head on its own base. At current law combined receipts change by the weighted school diff --git a/policyengine_uk/parameters/gov/simulation/vat/private_school_share_of_receipts.yaml b/policyengine_uk/parameters/gov/simulation/vat/private_school_share_of_receipts.yaml index 0894bf2e8f..fe25c4c119 100644 --- a/policyengine_uk/parameters/gov/simulation/vat/private_school_share_of_receipts.yaml +++ b/policyengine_uk/parameters/gov/simulation/vat/private_school_share_of_receipts.yaml @@ -1,4 +1,4 @@ -description: VAT on private school fees as a share of total VAT receipts. Household VAT grossed up to total receipts is reduced by this share, because private school VAT is counted separately. +description: VAT on private school fees as a share of total VAT receipts. Household VAT grossed up to receipts (net of VAT on energy bills) is reduced by this share, because private school VAT is counted separately. metadata: unit: /1 period: year @@ -8,13 +8,17 @@ metadata: HMRC's OBR-certified costing of VAT on private school fees divided by OBR cash-basis VAT receipts for the same fiscal year: 1,505 / 180,169 in 2025-26, 1,560 / 187,704 in 2026-27, 1,610 / 195,923 in 2027-28, 1,665 / - 204,591 in 2028-29 and 1,725 / 213,118 in 2029-30 (£m). Each value starts - on 1 April, so a model year reads its fiscal year's share. The 2024-25 - part-year receipt (£460m from 1 January 2025, 0.27% of £170,994m) is left - in general VAT, as private_school_vat is zero in model year 2024. The - survey consumption coverage is fitted to total receipts, so the grossed-up - amount includes this share. vat and baseline_vat both read it from the - simulation's own parameters, so that the baseline reads each fiscal + 204,591 in 2028-29 and 1,725 / 213,118 in 2029-30 (£m). Each value starts on + 1 April, so a model year reads its fiscal year's share. The 2024-25 + part-year receipt (£460m from 1 January 2025, 0.27% of £170,994m) is left in + general VAT, as private_school_vat is zero in model year 2024. The survey + consumption coverage is fitted to receipts net of VAT on energy bills, which + still include school-fee VAT, so the grossed-up amount includes it. The + share is taken of total receipts and applied to the grossed-up amount only, + so where that amount matches receipts net of energy-bill VAT exactly it + removes the costing less the share times energy-bill VAT (about £17m on + Microcosm f9d1922c in 2025-26). vat and baseline_vat both read the share + from the simulation's own parameters, so that the baseline reads each fiscal year's value; docs/engineering/vat-coverage.md sets out the allocation. The receipts were read from the OBR workbook with SHA-256 f65b6cb7f96931d4abd85940794eb60f6f65afca8bbf16c74a4548bf92766a28: diff --git a/policyengine_uk/parameters/gov/simulation/vat/survey_consumption_coverage.yaml b/policyengine_uk/parameters/gov/simulation/vat/survey_consumption_coverage.yaml index c2d3678330..121d970d74 100644 --- a/policyengine_uk/parameters/gov/simulation/vat/survey_consumption_coverage.yaml +++ b/policyengine_uk/parameters/gov/simulation/vat/survey_consumption_coverage.yaml @@ -22,15 +22,14 @@ metadata: so this value puts total VAT on that dataset 38% or 44% above 2024-25 receipts. Re-derive when the dataset it should match changes, with docs/engineering/vat-coverage.md, which lists the inputs, file hashes and - per-dataset results. These receipts - already include private school VAT. The vat formula therefore removes - school-fee VAT's share of receipts from the grossed-up amount (not from - energy-bill VAT) before gov_tax adds private_school_vat. Before energy - bills were split out, at current law on Enhanced FRS 2024-25 - (policyengine-uk-data 1.58.1), the two heads summed to the - receipt-calibrated VAT to within the share's accuracy (at most 0.07% below - it), so no extra school receipts are added to this denominator. See the - receipt-partition derivation in that document. + per-dataset results. These receipts already include private school VAT. + The vat formula therefore removes school-fee VAT's share of receipts from + the grossed-up amount (not from energy-bill VAT) before gov_tax adds + private_school_vat. At current law on Enhanced FRS 2024-25 + (policyengine-uk-data 1.58.1), the two heads sum to the receipt-calibrated + VAT to within the share's accuracy (at most 0.06% below it), so no extra + school receipts are added to this denominator. See the receipt-partition + derivation in that document. reference: - title: OBR, Economic and fiscal outlook March 2026, detailed forecast tables, receipts, table 3.8 href: https://obr.uk/efo/economic-and-fiscal-outlook-march-2026/ diff --git a/policyengine_uk/tests/test_private_school_vat_properties.py b/policyengine_uk/tests/test_private_school_vat_properties.py index a47f9a4883..3a9498fdc2 100644 --- a/policyengine_uk/tests/test_private_school_vat_properties.py +++ b/policyengine_uk/tests/test_private_school_vat_properties.py @@ -21,8 +21,9 @@ standard rate rises; - receipt partition: school-specific rate and exemption reforms leave the general vat head unchanged, and private_school_vat is independent of - recorded consumption. General VAT is gross VAT less school-fee VAT's - share of receipts (test_private_school_vat_receipts.py); + recorded consumption. General VAT is grossed-up VAT less school-fee VAT's + share of receipts, plus VAT on energy bills, which bears no share + (test_private_school_vat_receipts.py); - level and change: private_school_vat_change equals private_school_vat minus baseline_private_school_vat; baseline_private_school_vat equals private_school_vat under baseline policy with this simulation's mean fee diff --git a/policyengine_uk/tests/test_private_school_vat_receipts.py b/policyengine_uk/tests/test_private_school_vat_receipts.py index 3ab509272c..c29f5b3d24 100644 --- a/policyengine_uk/tests/test_private_school_vat_receipts.py +++ b/policyengine_uk/tests/test_private_school_vat_receipts.py @@ -12,7 +12,7 @@ - non-negative: as 0 <= s < 1, the allocation never makes general VAT negative, so vat and vat + private_school_vat are non-negative wherever - recorded consumption is; + recorded consumption and energy bills are; - national identity: weighted vat + private_school_vat is weighted G, less s times weighted G, plus weighted E and weighted P0. It equals weighted G + E when the population's school charge is s of its gross VAT, and restoring @@ -198,7 +198,7 @@ def test_published_microcosm_receipts_check_is_unchanged(): combined = amount(sim, "vat", 2024) + amount(sim, "private_school_vat", 2024) expected = 0.2 * 396.15e9 / FACTOR + 1.99e9 assert combined == pytest.approx(expected) - assert combined / 170.994e9 == pytest.approx(1, rel=0.004) + assert combined / 170.994e9 == pytest.approx(1, rel=0.001) def population_simulation(households, year=2025, reforms=None, fees=10_000.0): @@ -345,3 +345,140 @@ def test_vat_is_never_negative_and_receipts_are_counted_once(households, year, f assert combined - repeal_combined == pytest.approx( weighted(weight, school), rel=1e-5, abs=0.01 ) + + +GB_ELECTRICITY = "gov.hmrc.vat.domestic_electricity_rate.great_britain" +NI_ELECTRICITY = "gov.hmrc.vat.domestic_electricity_rate.northern_ireland" + + +def electricity_rate(region, year): + # SI 2026/987 zero-rates domestic electricity in Great Britain from + # 1 October 2026 to 31 March 2027: 183 of model year 2026's 365 days are + # at 5%. Northern Ireland and every other year stay at 5%. + if region != "NORTHERN_IRELAND" and year == 2026: + return 0.05 * 183 / 365 + return 0.05 + + +energy_households = st.fixed_dictionaries( + { + "weight": st.floats(1, 5_000), + "pupils": st.integers(0, 3), + "full": st.one_of(st.just(0.0), st.floats(1_000, 150_000)), + "electricity": st.one_of(st.just(0.0), st.floats(100, 5_000)), + "gas": st.one_of(st.just(0.0), st.floats(100, 5_000)), + "region": st.sampled_from(["LONDON", "SCOTLAND", "NORTHERN_IRELAND"]), + } +) + + +def energy_population(households, year, fees, reforms=None): + """Weighted households with energy bills, as one simulation.""" + people, benunits, hhs = {}, {}, {} + for i, h in enumerate(households): + members = [f"adult_{i}"] + people[f"adult_{i}"] = {"age": {year: 45}} + for j in range(h["pupils"]): + people[f"pupil_{i}_{j}"] = { + "age": {year: 7 + j}, + "attends_private_school": {year: True}, + } + members.append(f"pupil_{i}_{j}") + benunits[f"benunit_{i}"] = {"members": members} + hhs[f"household_{i}"] = { + "members": members, + "region": {year: h["region"]}, + "household_weight": {year: h["weight"]}, + "full_rate_vat_consumption": {year: h["full"]}, + "reduced_rate_vat_consumption": {year: 0.0}, + "electricity_consumption": {year: h["electricity"]}, + "gas_consumption": {year: h["gas"]}, + } + sim = Simulation( + situation={"people": people, "benunits": benunits, "households": hhs}, + reform={ + FEES: {ALWAYS: fees}, + BASIS: {ALWAYS: 1}, + **{key: {ALWAYS: value} for key, value in (reforms or {}).items()}, + }, + ) + return { + variable: np.asarray(sim.calculate(variable, year), dtype=float) + for variable in ( + "household_weight", + "vat", + "vat_change", + "domestic_energy_vat", + "baseline_domestic_energy_vat", + "private_school_vat", + "baseline_private_school_vat", + ) + } + + +@settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) +@given( + households=st.lists(energy_households, min_size=1, max_size=4), + year=st.integers(2024, 2030), + fees=st.floats(1_000, 50_000), +) +@example( + households=[ + { + "weight": 1.0, + "pupils": 1, + "full": 0.0, + "electricity": 1_050.0, + "gas": 1_050.0, + "region": "LONDON", + } + ], + year=2027, + fees=10_000.0, +) +def test_school_share_never_touches_energy_bill_vat(households, year, fees): + # vat = G x (1 - s) + E with E from the statute (the bill net of the 5% + # price basis, at the electricity and gas rates). Restoring the exemption + # leaves vat unchanged, zero-rating electricity moves vat by exactly the + # change in energy-bill VAT, and the national identity holds with E. + s = OFFICIAL_SHARE[year] + gross = np.array([h["full"] * 0.2 for h in households]) / FACTOR + electricity = np.array( + [ + h["electricity"] / 1.05 * electricity_rate(h["region"], year) + for h in households + ] + ) + energy = electricity + np.array([h["gas"] / 1.05 * 0.05 for h in households]) + + base = energy_population(households, year, fees) + np.testing.assert_allclose( + base["domestic_energy_vat"], energy, rtol=1e-5, atol=0.01 + ) + np.testing.assert_allclose( + base["vat"], gross * (1 - s) + energy, rtol=1e-5, atol=0.01 + ) + np.testing.assert_allclose(base["vat_change"], 0, atol=0.01) + + repeal = energy_population(households, year, fees, {EXEMPT: True}) + np.testing.assert_array_equal(repeal["vat"], base["vat"]) + + zero = energy_population( + households, year, fees, {GB_ELECTRICITY: 0.0, NI_ELECTRICITY: 0.0} + ) + energy_change = zero["domestic_energy_vat"] - zero["baseline_domestic_energy_vat"] + np.testing.assert_allclose(energy_change, -electricity, rtol=1e-5, atol=0.01) + np.testing.assert_allclose(zero["vat_change"], energy_change, rtol=1e-5, atol=0.01) + np.testing.assert_array_equal( + zero["private_school_vat"], base["private_school_vat"] + ) + + w = base["household_weight"] + combined = weighted(w, base["vat"], base["private_school_vat"]) + expected = weighted(w, gross * (1 - s), energy, base["baseline_private_school_vat"]) + assert combined == pytest.approx(expected, rel=1e-5, abs=0.01) diff --git a/policyengine_uk/variables/gov/hmrc/vat.py b/policyengine_uk/variables/gov/hmrc/vat.py index 7a4e92a53f..4d36918523 100644 --- a/policyengine_uk/variables/gov/hmrc/vat.py +++ b/policyengine_uk/variables/gov/hmrc/vat.py @@ -6,7 +6,8 @@ class vat(Variable): label = "VAT excluding private school fees" documentation = ( "Receipt-calibrated VAT less private school fees' share of VAT " - "receipts, which private_school_vat counts separately." + "receipts, which private_school_vat counts separately, plus VAT on " + "the household's energy bills, which is not grossed up." ) entity = Household definition_period = YEAR From 8f205015db2445f29848b0d5792f7c8435a02e32 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 24 Oct 2026 16:35:06 -0400 Subject: [PATCH 12/12] Restate the vat head's negative-household note on 1.58.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review r4's N6: the data_note measured 76 negative households on policyengine-uk-data 1.58.0 (-£72m in 2026). CI's Aggregate impact run 40116746457 on 1.58.1 finds 76 in every year from 2025 to 2031, -£55.7m weighted in 2026. The 1.58.0 cause breakdown is kept and labelled. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/data/fiscal_partition.yaml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/policyengine_uk/data/fiscal_partition.yaml b/policyengine_uk/data/fiscal_partition.yaml index 2d1aff19f8..7d2b5c5b7d 100644 --- a/policyengine_uk/data/fiscal_partition.yaml +++ b/policyengine_uk/data/fiscal_partition.yaml @@ -252,11 +252,12 @@ tax_heads: official share of VAT receipts, which the separate school head counts, plus VAT on energy bills. The share is below one, so the allocation keeps the sign of the grossed-up amount. Negative for 76 households on - policyengine-uk-data 1.58.0 (enhanced_frs_2024_25) in every year from - 2025 to 2030, -£72m weighted in 2026: full_rate_vat_consumption is - negative there because the dataset's consumption (44 households) or - full_rate_vat_expenditure_rate (32 households) is negative. - policyengine-uk-data issue 565. Measured 2026-10-11. + policyengine-uk-data 1.58.1 (enhanced_frs_2024_25) in every year from + 2025 to 2031, -£55.7m weighted in 2026 (Aggregate impact run + 40116746457, 2026-10-24). On 1.58.0 (measured 2026-10-11) it was also + 76: full_rate_vat_consumption is negative there because the dataset's + consumption (44 households) or full_rate_vat_expenditure_rate (32 + households) is negative. policyengine-uk-data issue 565. - id: vat_change label: Change in VAT from its baseline variable: vat_change