diff --git a/changelog.d/2318.fixed.md b/changelog.d/2318.fixed.md new file mode 100644 index 0000000000..0646412b40 --- /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), 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 84da2e60fd..d390430e1f 100644 --- a/policyengine_uk/data/fiscal_partition.yaml +++ b/policyengine_uk/data/fiscal_partition.yaml @@ -43,9 +43,15 @@ # 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[]: 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. +# 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: @@ -74,9 +80,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 @@ -183,6 +189,13 @@ tax_heads: payer: consumer incidence totals: [gov_tax] sign: nonnegative + data_note: >- + 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, as low as -1.02) is negative. policyengine-uk-data + issue 565. Measured 2026-10-11. - id: vat_change label: Change in VAT from its baseline variable: vat_change @@ -198,6 +211,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 @@ -206,12 +224,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 @@ -222,6 +236,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 @@ -509,6 +547,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} @@ -554,18 +593,44 @@ 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: overlapping_base 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. + 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] + 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 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/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..7a462f7713 --- /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: + 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 + 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..2f04cbe4b2 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/private_school_vat.yaml @@ -0,0 +1,400 @@ +# 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. + 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: + # 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_fiscal_partition.py b/policyengine_uk/tests/test_fiscal_partition.py index 8e7b0da100..be3d2a461f 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. @@ -41,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 @@ -114,16 +115,23 @@ def tolerance(*arrays) -> np.ndarray: def test_partition_schema(): p = partition() assert p["schema_version"] == 1 - totals = set(p["totals"]) - assert totals == set(MODEL_LISTS) + # The tax totals are this file's subject; the spending totals and + # spending_heads (the aggregate impact report's) share the file and must + # not borrow tax heads or variables. + assert set(MODEL_LISTS) <= set(p["totals"]) + spending_heads = p.get("spending_heads", []) + spending_totals = {t for head in spending_heads for t in head["totals"]} + assert not spending_totals & set(MODEL_LISTS) ids = [head["id"] for head in heads()] variables = [head["variable"] for head in heads()] assert len(ids) == len(set(ids)), "duplicate head id" assert len(variables) == len(set(variables)), "a variable is in two heads" + assert not set(variables) & {h["variable"] for h in spending_heads} + assert not set(ids) & {h["id"] for h in spending_heads} for head in heads(): variable = system.variables[head["variable"]] assert head["entity"] == variable.entity.key, head["id"] - assert set(head["totals"]) <= totals, head["id"] + assert set(head["totals"]) <= set(MODEL_LISTS), head["id"] assert head["sign"] in {"nonnegative", "any"}, head["id"] assert head.get("zero_at_baseline", "expected") == "expected", head["id"] assert head["obr_line"], head["id"] @@ -144,8 +152,8 @@ def test_partition_schema(): assert set(x["spans"]) <= set(ids), x else: assert x["head"] in ids, x - for total, spec in p["totals"].items(): - for switch, dropped in spec.get("dropped_when", {}).items(): + for total in MODEL_LISTS: + for switch, dropped in p["totals"][total].get("dropped_when", {}).items(): system.parameters.get_child(switch) assert set(dropped) <= {h["variable"] for h in heads_of(total)} for guard in p["overlap_guards"]: @@ -435,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 @@ -451,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"} @@ -461,19 +474,44 @@ 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} +# 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, @@ -484,11 +522,13 @@ 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) values = {h["id"]: household_values(sim, h["variable"], year) for h in heads()} - for total, spec in partition()["totals"].items(): + for total in MODEL_LISTS: + spec = partition()["totals"][total] dropped = set() for switch, names in spec.get("dropped_when", {}).items(): if reform and switch in reform: @@ -520,6 +560,22 @@ 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 == "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 new file mode 100644 index 0000000000..78b69b8b16 --- /dev/null +++ b/policyengine_uk/tests/test_private_school_vat_properties.py @@ -0,0 +1,275 @@ +"""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; +- 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); +- 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 + 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 + +from policyengine_uk import Simulation + +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"] + # VATA 1994 s.2(1): 20 per cent since 4 January 2011. + return 0.2 + + +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_read_separate_inputs(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) + # 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( + assumptions_only.calculate("private_school_vat", p["year"]), dtype=float + ), + ) + 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/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..edf16a99d6 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/baseline_private_school_vat.py @@ -0,0 +1,30 @@ +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 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 + 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, + parameters(period).baseline.gov, + parameters(period).gov.simulation.private_school_vat, + ) 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..c1d28dceb3 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/private_school_vat.py @@ -0,0 +1,64 @@ +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, 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. + + 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 ( + pupils + * assumptions.private_school_fees + * assumptions.private_school_vat_basis + * private_school_vat_rate(policy) + ) + + +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): + gov = parameters(period).gov + return private_school_vat_liability( + household, period, gov, gov.simulation.private_school_vat + ) 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"]