diff --git a/changelog.d/2318.fixed.md b/changelog.d/2318.fixed.md new file mode 100644 index 0000000000..10710c8f7d --- /dev/null +++ b/changelog.d/2318.fixed.md @@ -0,0 +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: 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 c8685dd240..bf7b935777 100644 --- a/docs/engineering/vat-coverage.md +++ b/docs/engineering/vat-coverage.md @@ -3,7 +3,11 @@ `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, plus VAT on households' energy bills (see +[Energy bills are not grossed up](#energy-bills-are-not-grossed-up)), 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 +72,17 @@ 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, `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 @@ -88,7 +101,11 @@ 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, 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` 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 | |---|---|---|---| @@ -148,6 +165,7 @@ For Enhanced FRS, download `enhanced_frs_2024_25.h5` from the model repository ## Energy bills are not grossed up + VAT on domestic electricity and gas is charged on each household's own bills (`domestic_energy_vat`, PolicyEngine/policyengine-uk#2166), and the ONS domestic fuel and power share of spending comes out of the generic reduced-rate share. @@ -197,3 +215,116 @@ it is 38% (1.56.16) or 44% (1.58.0) above 2024-25 receipts. To reproduce, run the snippet above on this version and subtract `sim.calculate("domestic_energy_vat", year).sum()` from the receipts before dividing. + +## 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) +(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 grossed-up VAT on recorded consumption +other than energy bills (the statutory liability divided by the two factors), +`E` be `domestic_energy_vat`, `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 +vat = G * (1 - s) + E +baseline_vat = G0 * (1 - s) + E0 +combined VAT = vat + P +``` + +The share comes out of `G` only. School fees are not energy bills, and `E` +is not grossed up, so a reform to the VAT on energy bills changes `vat` by +exactly the change in `E`. + +`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` and `E` does not depend on `s`, the allocation never + makes a household's VAT negative where `G` and `E` are non-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 `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. 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), 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 +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 (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 +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. diff --git a/policyengine_uk/data/fiscal_partition.yaml b/policyengine_uk/data/fiscal_partition.yaml index af116b23f7..7d2b5c5b7d 100644 --- a/policyengine_uk/data/fiscal_partition.yaml +++ b/policyengine_uk/data/fiscal_partition.yaml @@ -60,7 +60,8 @@ # measure for. With the heads' obr_line values they account for every # tax row of the table. # overlap_guards[]: the places double counting could occur, and the -# property the tests check for each. The aggregate-impact report also +# property the tests check for each. overlapping_base records a receipt +# allocation between heads sharing an imputed base. The report also # checks the geographic_exclusivity guards on the certified dataset. # spending_heads[]: as tax_heads, without obr_line, obr_table_3_1 and # payer, plus caseload (default true): whether reports count recipients. @@ -119,9 +120,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 @@ -237,7 +238,7 @@ tax_heads: # Consumption - id: vat - label: VAT + label: VAT excluding private school fees variable: vat entity: household obr_line: Value added tax @@ -247,12 +248,16 @@ tax_heads: 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) is negative. policyengine-uk-data issue 565. Measured - 2026-10-11. + Grossed-up VAT on recorded consumption less private school fees' + 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.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 @@ -268,6 +273,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 @@ -276,12 +286,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 @@ -292,6 +298,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 @@ -645,6 +675,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} @@ -697,14 +728,30 @@ 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. + 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 + 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] @@ -729,11 +776,15 @@ overlap_guards: here as well. - 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). uncovered_receipts: # Rows of Table A.5 with no household measure in the model. 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/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..fe25c4c119 --- /dev/null +++ b/policyengine_uk/parameters/gov/simulation/vat/private_school_share_of_receipts.yaml @@ -0,0 +1,38 @@ +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 + 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 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: + 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 (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 + 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 0cad4f8287..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,7 +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. + 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/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/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_fiscal_partition.py b/policyengine_uk/tests/test_fiscal_partition.py index bff2c5f909..83dd08039c 100644 --- a/policyengine_uk/tests/test_fiscal_partition.py +++ b/policyengine_uk/tests/test_fiscal_partition.py @@ -26,9 +26,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. @@ -44,7 +45,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 @@ -508,6 +509,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 @@ -524,6 +528,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"} @@ -534,19 +540,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, @@ -557,6 +588,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) @@ -594,6 +626,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..3a9498fdc2 --- /dev/null +++ b/policyengine_uk/tests/test_private_school_vat_properties.py @@ -0,0 +1,274 @@ +"""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; +- 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 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 + 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_school_specific_policy_leaves_residual_vat_unchanged(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/tests/test_private_school_vat_receipts.py b/policyengine_uk/tests/test_private_school_vat_receipts.py new file mode 100644 index 0000000000..c29f5b3d24 --- /dev/null +++ b/policyengine_uk/tests/test_private_school_vat_receipts.py @@ -0,0 +1,484 @@ +"""School VAT is a share of receipt-calibrated VAT, not extra receipts. + +Let G be household VAT on recorded consumption other than energy bills, +grossed up to VAT receipts net of energy-bill VAT, E VAT on energy bills +(domestic_energy_vat, not grossed up), 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) + E; 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 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 + 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 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, + 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, pupils=1, energy_vat=None +): + changes = { + 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}, + } + household = { + "members": list(people), + "full_rate_vat_consumption": {year: full}, + "reduced_rate_vat_consumption": {year: reduced}, + } + if energy_vat is not None: + household["domestic_energy_vat"] = {year: energy_vat} + household["baseline_domestic_energy_vat"] = {year: energy_vat} + return Simulation( + situation={ + "people": people, + "benunits": {"family": {"members": list(people)}}, + "households": {"household": household}, + }, + 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_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) + 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 + ) + expected = (gross - baseline_gross) * keep + school - baseline_school + assert change == pytest.approx(expected, 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_000), + ], +) +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) / FACTOR + assert amount(sim, "private_school_vat") == pytest.approx(school) + assert amount(sim, "private_school_vat_change") == pytest.approx(change) + assert amount(sim, "vat") == pytest.approx(gross * (1 - share(2025)), abs=0.01) + + +@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 + + +@pytest.mark.parametrize("year", [2024, 2025, 2031]) +@pytest.mark.parametrize("exempt", [False, True]) +def test_energy_bill_vat_bears_no_school_share(year, exempt): + # VAT on energy bills is added after grossing and is not a share of + # receipts, so the school allocation comes out of the grossed-up amount + # only, and a school reform leaves vat unchanged with energy VAT present. + sim = simulation(year, reforms={EXEMPT: exempt}, energy_vat=300) + keep = 1 - share(year) + gross = (10_000 * 0.2 + 1_000 * 0.05) / FACTOR + assert amount(sim, "vat", year) == pytest.approx(gross * keep + 300, abs=0.01) + assert amount(sim, "baseline_vat", year) == pytest.approx( + gross * keep + 300, abs=0.01 + ) + assert amount(sim, "vat_change", year) == pytest.approx(0, abs=0.01) + + +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, + # with energy bills split out: the standard-rated base and the energy-bill + # VAT, which is added after grossing. Model year 2024 has no school charge + # and no share to reallocate. + sim = simulation(year=2024, full=396.15e9, reduced=0, energy_vat=1.99e9) + 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.001) + + +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 + ) + + +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/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/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/baseline_vat.py b/policyengine_uk/variables/gov/hmrc/baseline_vat.py index 164f372021..ff333607d4 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 @@ -19,9 +19,15 @@ def formula(household, period, parameters): ) # Survey consumption under-records spending, and households bear only # part of VAT liabilities, so household VAT on recorded consumption is - # grossed up to receipts. VAT on the household's own energy bills is - # not grossed up: the bills are calibrated to national energy use and + # grossed up to receipts net of VAT on energy bills. + 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 + # VAT on the household's own energy bills is not grossed up and bears + # no school fees: the bills are calibrated to national energy use and # households bear that VAT in full. - coverage = vat_grossing_factor(p.simulation) energy_vat = household("baseline_domestic_energy_vat", period) - return raw_vat / coverage + energy_vat + return gross_vat * (1 - share) + energy_vat diff --git a/policyengine_uk/variables/gov/hmrc/household_tax.py b/policyengine_uk/variables/gov/hmrc/household_tax.py index 3794ac08b5..e730169381 100644 --- a/policyengine_uk/variables/gov/hmrc/household_tax.py +++ b/policyengine_uk/variables/gov/hmrc/household_tax.py @@ -24,7 +24,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"] diff --git a/policyengine_uk/variables/gov/hmrc/vat.py b/policyengine_uk/variables/gov/hmrc/vat.py index 8a913749dd..4d36918523 100644 --- a/policyengine_uk/variables/gov/hmrc/vat.py +++ b/policyengine_uk/variables/gov/hmrc/vat.py @@ -3,7 +3,12 @@ class vat(Variable): - label = "VAT" + label = "VAT excluding private school fees" + documentation = ( + "Receipt-calibrated VAT less private school fees' share of VAT " + "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 value_type = float @@ -19,9 +24,17 @@ def formula(household, period, parameters): ) # Survey consumption under-records spending, and households bear only # part of VAT liabilities, so household VAT on recorded consumption is - # grossed up to receipts. VAT on the household's own energy bills is - # not grossed up: the bills are calibrated to national energy use and + # grossed up to receipts net of VAT on energy bills. + 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 grossed-up VAT gives up + # the fees' share of receipts, so the allocation never makes it + # negative and school-specific reforms change only private_school_vat. + general_vat = gross_vat * ( + 1 - p.simulation.vat.private_school_share_of_receipts + ) + # VAT on the household's own energy bills is not grossed up and bears + # no school fees: the bills are calibrated to national energy use and # households bear that VAT in full. - coverage = vat_grossing_factor(p.simulation) energy_vat = household("domestic_energy_vat", period) - return raw_vat / coverage + energy_vat + return general_vat + energy_vat