diff --git a/changelog.d/ab26-carers-allowance-taper.changed.md b/changelog.d/ab26-carers-allowance-taper.changed.md new file mode 100644 index 0000000000..c1fd54f4cd --- /dev/null +++ b/changelog.d/ab26-carers-allowance-taper.changed.md @@ -0,0 +1 @@ +Replace the cliff edge at the Carer's Allowance earnings limit with the 100% taper that Budget 2026 announced, in England and Wales: the weekly allowance falls by £1 for each £1 of weekly earnings above the limit. The taper starts within 2030-31; until DWP confirms the date the model starts it on 6 October 2030, so 2030-31 carries 182 of its 365 days. In that year the weeks before the taper and the weeks under it each get their own couple restriction and overlapping-benefit reduction, so State Pension reduces the allowance only in the weeks the allowance pays for. A couple caring for the same person elect which of them has the allowance, and the model gives it to the partner with the larger award (before, it preferred the benefit-unit head), so the taper never leaves a couple with less than the cliff edge did, and one partner has the award for the year: where the two have the same award for some weeks, the couple elect the one with the larger award over the year. An amount before the couple restriction or the overlap rule that an input supplies, or that a reform neutralizes, calculates with a formula of its own or otherwise changes, reaches the payment with the whole year's treatment for the stages after it, even where it equals the model's. The payment ignores the stage before the couple restriction wherever an input supplies, or a reform calculates, the amount before overlap. A mask that takes away a fraction of a penny counts as a change, since the couple restriction chooses only among partners with an award. A payment of less than half a penny for the year is nil. diff --git a/changelog.d/ab26-electricity-vat.changed.md b/changelog.d/ab26-electricity-vat.changed.md new file mode 100644 index 0000000000..cc02fa798b --- /dev/null +++ b/changelog.d/ab26-electricity-vat.changed.md @@ -0,0 +1 @@ +Extend the temporary zero rate of VAT on domestic electricity to 30 June 2027, and apply it in Northern Ireland from 1 April 2027, as announced at Budget 2026. diff --git a/changelog.d/ab26-fuel-duty.changed.md b/changelog.d/ab26-fuel-duty.changed.md new file mode 100644 index 0000000000..b7065867c7 --- /dev/null +++ b/changelog.d/ab26-fuel-duty.changed.md @@ -0,0 +1 @@ +Keep the 5p fuel duty cut, and the proportionate cut for LPG and natural gas, to 1 July 2027, as announced at Budget 2026. On that date petrol and diesel rise to the OBR's forecast of 60.59p (57.95p uprated by RPI growth to 2027 Q1), and LPG and natural gas return to their full rates with the same uprating, at the rates HM Treasury publishes (natural gas 25.82p a kilogram); all three then rise by RPI each 1 April from 2028. diff --git a/changelog.d/ab26-household-energy-payment.added.md b/changelog.d/ab26-household-energy-payment.added.md new file mode 100644 index 0000000000..0b6095b0be --- /dev/null +++ b/changelog.d/ab26-household-energy-payment.added.md @@ -0,0 +1 @@ +Add the Household Energy Payment announced at Budget 2026: £100 in 2026-27 for households in council tax bands A to D in England, counted in household benefits, government spending and HBAI income (as HBAI counted the 2022 Council Tax Rebate). diff --git a/changelog.d/ab26-pension-tax-allowance.added.md b/changelog.d/ab26-pension-tax-allowance.added.md new file mode 100644 index 0000000000..a9b9d06ae4 --- /dev/null +++ b/changelog.d/ab26-pension-tax-allowance.added.md @@ -0,0 +1 @@ +Add the Pension Tax Allowance announced at Budget 2026: from 6 April 2027 to 5 April 2030, people over State Pension age (or reaching it within the tax year) get a non-refundable Income Tax reduction of 20% of the full new State Pension above the Personal Allowance, rounded up to the pound (£94 in 2027-28), tapered by £1 for every £3 of total income above the full new State Pension (52 weeks at the weekly rate, so it follows any change to that rate). Universal Credit's deduction for tax on earnings sees it. diff --git a/changelog.d/ab26-savings-property-rates.changed.md b/changelog.d/ab26-savings-property-rates.changed.md new file mode 100644 index 0000000000..0e141c7896 --- /dev/null +++ b/changelog.d/ab26-savings-property-rates.changed.md @@ -0,0 +1 @@ +Raise the savings and property income rates to 24%, 44% and 49% from 6 April 2027 (Budget 2026). diff --git a/changelog.d/ab26-trading-allowance.changed.md b/changelog.d/ab26-trading-allowance.changed.md new file mode 100644 index 0000000000..dfe6a2a935 --- /dev/null +++ b/changelog.d/ab26-trading-allowance.changed.md @@ -0,0 +1 @@ +Raise the trading allowance to £3,000 from 6 April 2027 (Budget 2026). diff --git a/docs/book/assumptions/carer-support-payment-earnings.md b/docs/book/assumptions/carer-support-payment-earnings.md index 432d00ca75..9b03c6edf0 100644 --- a/docs/book/assumptions/carer-support-payment-earnings.md +++ b/docs/book/assumptions/carer-support-payment-earnings.md @@ -88,16 +88,65 @@ second candidate entitlement before reducing either payment for overlapping benefits. A selected carer whose overlapping benefit removes the payment still retains the underlying entitlement. -The law provides election or decision rules; the model does not observe that -choice. Among otherwise eligible partners, it prefers a reported recipient, -then an eligible benefit-unit head, then the eldest eligible partner. Member -order breaks an age tie. This is a modelling convention, not statutory -priority. An ineligible reported recipient or head cannot displace an eligible -partner. The caring-hours, earnings, age and take-up conditions are unchanged. - -The convention does not maximise the couple's payment. It can select a partner -whose overlapping benefits reduce CA or CSP to zero, while suppressing a -payment that the other otherwise-eligible partner could receive if selected. +The couple jointly elect which of them has the entitlement; the Secretary of +State decides only where they don't. The data don't record the election, so +the model takes the one that gives the larger award: of the otherwise eligible +partners, the one whose candidate award is larger has the entitlement. The +model's own awards differ only under the Budget 2026 Carer's Allowance +earnings taper; everywhere else each eligible partner has the same rate. An +award that an input supplies can differ in any year, and the election then +follows it. Where the awards are equal, the model prefers a reported +recipient, then an eligible benefit-unit head, then the eldest eligible +partner, and member order breaks an age tie. That order is a modelling +convention, not statutory priority. An ineligible reported recipient or head +cannot displace an eligible partner. The caring-hours, earnings, age and +take-up conditions stay as they are. + +In 2030-31, the year the taper starts, the model applies the election +separately to the weeks before the taper and the weeks under it, since the law +restricts entitlement day by day. Where the two partners have the same award +for those weeks, the couple elect the one with the larger award over the year, +and then the order above applies. A partner below the earnings limit has the +full rate in both parts and the larger award over the year, so the couple +elect them for the whole year, even where the other partner's award under the +taper equals the full rate, as it does at a taper rate of zero. Where both +partners earn over the limit, neither has an award before the taper, and the +couple elect the one with the larger award under it. Either way one partner +has the award for the year, at any taper rate of zero or more. A +`carers_allowance_pre_couple_limit` that an input supplies carries no dates, +so the restriction keeps one partner's award for the whole year. +The parts of the year reach the payment as shares of the year: +`carers_allowance_pre_overlap` is the amount from +`carers_allowance_pre_couple_limit` that the restriction keeps, and +overlapping benefits come off for the share of the year in which the +restriction keeps it. + +The shares describe an amount only where the model's own formulas calculate +it from the parts of the year and, for every member of the benefit unit, it +is the amount the parts give: within half a penny, and an award, however +small, exactly where the parts give one, since the couple restriction chooses +only among partners with an award. An amount that an input supplies, or that +a reform neutralizes or calculates with a formula of its own, carries no +dates, even where it equals the model's: a value cannot show where it came +from. So does an amount that a reform changes while keeping the formula, for +example by adding `defined_for`. A `carers_allowance_pre_couple_limit` of that +kind gets the couple restriction for the whole year, and the whole year's +overlapping benefits come off what the restriction keeps. A +`carers_allowance_pre_overlap` of that kind comes after the couple +restriction, so only the whole year's overlapping benefits come off it. +Either way, the payment starts from that value, and where the +`carers_allowance_pre_overlap` is of that kind, the payment doesn't read the +stage before it. A reform that changes only a variable's label, say, keeps +the parts of the year. The test of the formula covers the whole variable, so +a reform that wraps the formula to change one person's amount moves every +amount to the whole-year treatment. The model treats less than half a penny +for the year, which in practice is floating-point residue where an +overlapping benefit equals the award, as nil, and pays a penny. + +The election compares awards before overlapping benefits, so it doesn't always +maximise what the couple receive: it can choose a partner whose overlapping +benefits reduce CA or CSP to zero, where choosing the other partner would pay +something. The existing same-person default remains: two qualifying reported awards, or awards that caring hours and take-up cannot explain, indicate different care diff --git a/docs/book/programs/gov/treasury/cost-of-living-payments.md b/docs/book/programs/gov/treasury/cost-of-living-payments.md index b160d11e46..6b20bafc34 100644 --- a/docs/book/programs/gov/treasury/cost-of-living-payments.md +++ b/docs/book/programs/gov/treasury/cost-of-living-payments.md @@ -78,6 +78,37 @@ calendar year in the same way; it counts in household net income and government spending but not in HBAI income. The model keeps one timing for both schemes for now. +## Household Energy Payment (2026-27) + +Budget 2026 (paragraph 6.24) announced a one-off £100 Household Energy +Payment for households in council tax bands A to D in England, which local +authorities pay into bank accounts from January 2027. PolicyEngine models it as +`household_energy_payment` (Household, YEAR), with parameters at +`parameters/gov/treasury/household_energy_payment/`: + +- `amount.yaml`: £100 in model year 2026 (6 April 2026 to 5 April 2027), + zero otherwise +- `bands.yaml`: qualifying bands (A to D) +- `countries.yaml`: qualifying countries (England) + +It counts in `household_benefits` and `gov_spending`, as the Energy Bills +Rebate does, and in `hbai_household_net_income`: HBAI counted the 2022 +Council Tax Rebate and the Energy Bills Support Scheme as miscellaneous +income ([HBAI quality and methodology report FYE 2023](https://www.gov.uk/government/statistics/households-below-average-income-for-financial-years-ending-1995-to-2023/households-below-average-income-series-quality-and-methodology-information-report-fye-2023)). +The devolved governments receive a Barnett share instead, which the model +does not allocate, and the model leaves out the £100 million of further +local-authority funding for targeted support. + +The payment goes to the householder [liable for council tax on their main +home](https://www.gov.uk/government/news/what-the-autumn-budget-2026-means-for-your-energy-bills), +including most households exempt from council tax; full criteria are to +follow. The model pays every household in a qualifying band. Survey data +hold only main homes, so the second homes and empty properties the scheme +excludes do not arise. The model can't tell which households have a +landlord liable for council tax (council tax in the rent), so it pays them, +which slightly overstates recipients. `council_tax_band` defaults to band D, +so a household whose survey record has no band qualifies. + ## Cost-of-Living Payments (2022-23 and 2023-24) Two successive packages, both modelled through the variable diff --git a/docs/engineering/excise-duties.md b/docs/engineering/excise-duties.md index 2e353c04a9..ada614d612 100644 --- a/docs/engineering/excise-duties.md +++ b/docs/engineering/excise-duties.md @@ -18,11 +18,11 @@ parameter history starts on 1 August 2023; the preceding alcohol regime and a complete 2023/24 calculation are outside this implementation. Tobacco rates start on 22 November 2023. The new alcohol, tobacco and car formulas return their default zero before 2024; that is an unmodelled period, not a statutory exemption. -Gas duty covers 2022/23 onwards and includes the 1 January 2027 and 1 March 2027 -increases. SI 2026/555 removed the September 2026 step announced at Budget 2025 -and moved the December 2026 step to 1 January 2027. Rates after the last dated -entry stay constant; the gas parameters do not forecast future inflation -increases. +Gas duty covers 2022/23 onwards. SI 2026/555 removed the September 2026 step +that Budget 2025 announced and moved the December 2026 step to 1 January 2027. +Budget 2026 then kept the proportionate cut to 1 July 2027, when the gas rates +return to their full levels with RPI growth on top, and rise by RPI each 1 April +from 2028, as petrol and diesel do (see below). For alcohol and tobacco, a model year such as `2026` represents 6 April 2026 to 5 April 2027. Quantities and cigarette retail prices are constant across that @@ -154,35 +154,45 @@ matching the legislation. No universal litre-to-kilogram density is assumed. The existing `fuel_duty` output includes these charges. Rural fuel duty relief continues to apply only to petrol and diesel. -From 1 March 2027 onwards, the gas parameters hold LPG at £0.3161/kg and natural -gas at £0.2470/kg. This implementation stops at the last numerical rates in the -HMRC 2026-to-2027 schedule and does not extrapolate gas rates using an inflation -forecast. This is a modelling limitation: HMRC states that RPI uprating resumes -from 2027/28. The petrol and diesel rate includes forecast RPI uprating each -1 April from 2027, so its rates rise while the gas rates stay flat. Later-year gas duty is therefore lower than it would be -under the same uprating assumption. Analyses beyond March 2027 that require -consistent fuel-duty forecasts must supply an uprated gas-rate path explicitly. +Budget 2026's costing says that on 1 July 2027 "fuel duty rates will increase +by 5ppl and will be uprated in line with the Retail Prices Index (RPI)", and +applies the cut to other fuels in proportion. So on 1 July 2027 the gas rates +return to their full rates before the 2022 cut (LPG £0.3161/kg, natural gas +£0.2470/kg), with the same RPI growth as petrol and diesel. HM Treasury +publishes the rates from 1 July 2027 in Annex A of Budget 2026's Overview +of Tax Legislation and Rates: £0.3305/kg and £0.2582/kg (the uprating gives +£0.2583/kg for natural gas, and the model uses HM Treasury's rate). Each +1 April from 2028 they rise by the previous calendar +year's RPI growth. `calculate_fuel_duty_rates.py` writes these steps to +1 April 2031, as it does for petrol and diesel, and the parameters' uprating +metadata continues them. That is policy, not law. Sources: [HMRC fuel duty rates 2026 to 2027](https://www.gov.uk/government/publications/fuel-duty-rates-for-2026-to-2027/fuel-duty-rates-2026-to-2027) (RPI uprating from 2027/28), [HMRC amended fuel duty rates 2026 to 2027](https://www.gov.uk/government/publications/amended-fuel-duty-rates-for-2026-to-2027/amended-fuel-duty-rates-2026-to-2027) -and [SI 2026/164 as amended by SI 2026/555](https://www.legislation.gov.uk/uksi/2026/164). +[SI 2026/164 as amended by SI 2026/555](https://www.legislation.gov.uk/uksi/2026/164) +and [Budget 2026 policy costings, page 13](https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=13). ## Petrol and diesel rate The main rate on unleaded petrol and diesel is keyed to each statutory change: -52.95p a litre from 23 March 2022, continued by annual orders to 31 December 2026; -55.95p from 1 January 2027 (SI 2026/164 as amended by SI 2026/555); and the Hydrocarbon Oil -Duties Act 1979 rate of 57.95p from 1 March 2027. Budget 2025 states that rates -will be uprated by RPI from April 2027, and HM Treasury's Budget 2025 costing -dates this from 1 April 2027. That uprating is policy, not law: HMRC's amended -note of 22 May 2026 says final rates will be confirmed at Budget 2026. No -official source names the RPI measure. The model applies the previous calendar +52.95p a litre from 23 March 2022, continued by annual orders to 31 December 2026 +(SI 2026/164 as amended by SI 2026/555). Those orders then stepped to 55.95p on +1 January 2027 and to the Hydrocarbon Oil Duties Act 1979 rate of 57.95p on +1 March 2027. Budget 2026 (paragraph 6.211) cancelled both steps and kept the +cut to 1 July 2027. Its costing (page 13) says: "On 1 July 2027, fuel duty rates +will increase by 5ppl and will be uprated in line with the Retail Prices Index +(RPI). From 1 April 2028, fuel duty rates will be uprated annually in line with +RPI." That is policy, not law. The OBR's October 2026 EFO (paragraph 3.19) +puts the rate at 60.59p from 1 July 2027: RPI growth in the year to 2027 Q1 +(4.5565%, economy Table 1.7) on the 57.95p pre-cut rate. The model +uses that figure, and `calculate_fuel_duty_rates.py` refuses to write any +other July rate. From 1 April 2028, each step applies the previous calendar year's RPI growth from the OBR forecast in -`gov.economic_assumptions.yoy_growth.obr.rpi` on each 1 April, rounded to the -nearest hundredth of a penny. The steps to 1 April 2031 are written out by -`calculate_fuel_duty_rates.py`, and a test fails when they fall out of date -with the RPI forecast. Later steps come from the parameter's uprating metadata. +`gov.economic_assumptions.yoy_growth.obr.rpi`, rounded to the nearest +hundredth of a penny. The script writes the steps to 1 April 2031, and a test +fails when they fall out of date with the RPI forecast. After a new EFO, set +the script's July rate and RPI growth to its forecast and rerun it. Later steps come from the parameter's uprating metadata. The RPI index ends in 2039, so the last step is 1 April 2040. An annual reform names a model year and replaces the rate for all of it. For diff --git a/policyengine_uk/data/fiscal_partition.yaml b/policyengine_uk/data/fiscal_partition.yaml index bbd3605909..b5f39a095c 100644 --- a/policyengine_uk/data/fiscal_partition.yaml +++ b/policyengine_uk/data/fiscal_partition.yaml @@ -722,6 +722,7 @@ exclusions: - {variable: married_couples_allowance_deduction, kind: component, head: income_tax, note: "Inside capped_mcad."} - {variable: other_tax_credits, kind: component, head: income_tax, note: "A tax reduction, subtracted inside income_tax."} - {variable: property_finance_cost_relief, kind: component, head: income_tax, note: "A tax reduction, subtracted inside income_tax."} + - {variable: pension_tax_allowance, kind: component, head: income_tax, note: "A tax reduction, subtracted inside income_tax."} - {variable: basic_rate_earned_income_tax, kind: assessment, head: income_tax, note: "Earned income tax by band; earned_income_tax does not read it."} - {variable: higher_rate_earned_income_tax, kind: assessment, head: income_tax, note: "As above."} - {variable: add_rate_earned_income_tax, kind: assessment, head: income_tax, note: "As above."} @@ -1260,7 +1261,7 @@ spending_heads: totals: [gov_spending, household_benefits] sign: nonnegative caseload: false - # Cost-of-living and energy support (none in force at current law) + # Cost-of-living and energy support - id: cost_of_living_support_payment label: Cost-of-living support payment variable: cost_of_living_support_payment @@ -1277,6 +1278,14 @@ spending_heads: totals: [gov_spending, household_benefits] sign: nonnegative zero_at_baseline: expected + # Paid in 2026-27 only (Budget 2026), so not zero at current law. + - id: household_energy_payment + label: Household Energy Payment + variable: household_energy_payment + entity: household + report_group: cost_of_living_support + totals: [gov_spending, household_benefits] + sign: nonnegative - id: epg_subsidy label: Energy Price Guarantee subsidy variable: epg_subsidy diff --git a/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_taper/applies.yaml b/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_taper/applies.yaml new file mode 100644 index 0000000000..e6e55bc9ea --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_taper/applies.yaml @@ -0,0 +1,14 @@ +description: Share of the fiscal year in which Carer's Allowance tapers on earnings above the limit instead of stopping in full. +values: + # The taper "will begin within the 2030-31 financial year, with a precise + # start date confirmed in due course" (policy costings, page 16). Until DWP + # confirms the date, the model starts it on 6 October 2030, the middle of + # 2030-31. fiscal_year_blend day-weights the year, so 2030-31 carries 182 + # of its 365 days. Change the date here when DWP confirms it. + 0001-01-01: 0 + 2030-10-06: 1 +metadata: + unit: /1 + period: year + fiscal_year_blend: true + label: Carer's Allowance earnings taper share of year diff --git a/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_taper/countries.yaml b/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_taper/countries.yaml new file mode 100644 index 0000000000..8dbb706c6d --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_taper/countries.yaml @@ -0,0 +1,12 @@ +description: Countries in which Carer's Allowance tapers on earnings above the limit. Scotland pays Carer Support Payment instead, and Northern Ireland keeps the withdrawal in full. +values: + 0001-01-01: + - ENGLAND + - WALES +metadata: + unit: list + period: year + label: Carer's Allowance earnings taper countries + reference: + - title: Budget 2026, paragraph 6.13 (the taper will "apply in England and Wales") + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=116 diff --git a/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_taper/index.yaml b/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_taper/index.yaml new file mode 100644 index 0000000000..c6ab63bdbc --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_taper/index.yaml @@ -0,0 +1,10 @@ +description: Budget 2026 replaces the withdrawal of Carer's Allowance in full above the weekly earnings limit with a taper on the earnings above it, in England and Wales, from a date within 2030-31. It is announced policy, not yet law, and DWP will confirm the start date. +metadata: + label: Carer's Allowance earnings taper + reference: + - title: Budget 2026, paragraph 6.13 + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=116 + - title: Budget 2026 policy costings, Carer's Allowance taper (page 16) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=16 + - title: Budget 2026, Table 6.1, item 7 + href: https://assets.publishing.service.gov.uk/media/6ae187a2779378370fd80a9a/Table_6.1_and_6.2_Budget_2026_Policy_Decisions.xlsx diff --git a/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_taper/rate.yaml b/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_taper/rate.yaml new file mode 100644 index 0000000000..36cd2be504 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/carers_allowance/earnings_taper/rate.yaml @@ -0,0 +1,10 @@ +description: Carer's Allowance withdrawn for each pound of weekly earnings above the earnings limit, where the earnings taper applies. +values: + 0001-01-01: 1 +metadata: + unit: /1 + period: year + label: Carer's Allowance earnings taper rate + reference: + - title: Budget 2026, paragraph 6.13 (a 100% taper on earnings in excess of the limit) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=116 diff --git a/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml b/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml index de65a776f8..3df69dd73d 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml +++ b/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml @@ -387,12 +387,12 @@ obr: # 2027 (57.95p raised by Q1 2027 RPI growth, OBR economy Table 1.7), # then 63.21p, 65.13p, 67.07p and 68.61p from 1 April 2028 to 2031, # each raised by the previous calendar year's RPI growth in obr.rpi. - # That is the schedule PolicyEngine/policyengine-uk#2719 puts into - # gov.hmrc.fuel_duty.petrol_and_diesel, so once it is law the model's - # duty on these volumes reproduces the OBR's road fuel receipts. Until - # then current law there is the Budget 2025 schedule (60.03p from April - # 2027). The derivation is policyengine-uk-data's - # sources/road_fuel_volume.py. + # That is the schedule in gov.hmrc.fuel_duty.petrol_and_diesel + # (PolicyEngine/policyengine-uk#2719). The OBR's receipts year starts on + # 1 April and the model's on 6 April, so a model year takes in five days + # of the next April's rate, and the model's duty on these volumes comes + # close to the OBR's road fuel receipts rather than matching them. The + # derivation is policyengine-uk-data's sources/road_fuel_volume.py. 2026-01-01: -0.02315665811165113 2027-01-01: -0.030554808261277322 2028-01-01: -0.0397847818644439 diff --git a/policyengine_uk/parameters/gov/hmrc/fuel_duty/calculate_fuel_duty_rates.py b/policyengine_uk/parameters/gov/hmrc/fuel_duty/calculate_fuel_duty_rates.py index f0ec7ffa27..6a499a8bbc 100644 --- a/policyengine_uk/parameters/gov/hmrc/fuel_duty/calculate_fuel_duty_rates.py +++ b/policyengine_uk/parameters/gov/hmrc/fuel_duty/calculate_fuel_duty_rates.py @@ -1,13 +1,33 @@ """ -Generates the forecast RPI uprating steps in petrol_and_diesel.yaml. - -The statutory schedule ends with the Hydrocarbon Oil Duties Act 1979 rate of -57.95p from 1 March 2027. Budget 2025 states that rates will then be uprated -by RPI from April 2027. This script applies each previous calendar year's RPI -growth from economic_assumptions/yoy_growth.yaml on 1 April (the date in HM -Treasury's Budget 2025 costing), rounding to the nearest hundredth of a penny, and writes the results between the BEGIN and END -markers in petrol_and_diesel.yaml. Run it with --update whenever the RPI -forecast changes; test_fuel_duty_schedule.py fails until you do. +Generates the forecast RPI uprating steps in petrol_and_diesel.yaml, lpg.yaml +and natural_gas.yaml. + +Budget 2026 keeps the 5p cut to 1 July 2027. Its policy costing (page 13) +says that "On 1 July 2027, fuel duty rates will increase by 5ppl and will be +uprated in line with the Retail Prices Index (RPI). From 1 April 2028, fuel +duty rates will be uprated annually in line with RPI." The OBR forecasts +the 1 July 2027 petrol and diesel rate (CUT_END_RATE: 60.59p in the October +2026 EFO) by applying RPI growth in the year to 2027 Q1 (CUT_END_RPI_GROWTH) +to the 57.95p pre-cut rate (Finance Act 2011 s. 19). LPG and natural gas +take the proportionate cut to the same date, so their full rates get the +same uprating then. HM Treasury publishes the rates from 1 July 2027 in +Budget 2026's Overview of Tax Legislation and Rates, Annex A +(PUBLISHED_RATES), and the script writes those. Each must lie within a +hundredth of a penny of the RPI uprating, and petrol and diesel's must +equal both the uprating and CUT_END_RATE; the script stops if one doesn't. Natural gas is the one that differs: 24.7p x 1.045565 rounds half +up to 25.83p, and HM Treasury publishes 25.82p. From 1 April 2028 the +script applies each previous calendar year's RPI growth, using +economic_assumptions/yoy_growth.yaml. It rounds to the nearest hundredth of +a penny and writes the results between the BEGIN and END markers in each +file. After a new EFO, set CUT_END_RATE and CUT_END_RPI_GROWTH to its +forecast, and PUBLISHED_RATES to any rates HM Treasury publishes in their +place, and run it with --update whenever they or the RPI forecast change; +test_fuel_duty_schedule.py fails until you do. If HM Treasury publishes no +new rates and the new uprating moves one of the old ones by more than a +hundredth of a penny, the script stops: decide which to follow, and to +follow the OBR, set PUBLISHED_RATES to the uprating and change the notes and +references, which credit HM Treasury. The script checks every fuel before it +writes any file, so a stop leaves them all as they were. Usage: uv run python calculate_fuel_duty_rates.py # print the steps @@ -21,18 +41,39 @@ import yaml FUEL_DUTY_DIR = Path(__file__).resolve().parent -PARAMETER_FILE = FUEL_DUTY_DIR / "petrol_and_diesel.yaml" YOY_GROWTH_FILE = FUEL_DUTY_DIR.parents[1] / "economic_assumptions" / "yoy_growth.yaml" -# The last statutory rate, and the first and last 1 April steps written out. -# Later steps come from the parameter's uprating metadata. -STATUTORY_DATE = "2027-03-01" -FIRST_UPRATING_YEAR = 2027 +# Each parameter file and the date of its full (pre-cut) rate. +FUELS = { + "petrol_and_diesel.yaml": "2011-03-23", + "lpg.yaml": "2021-01-01", + "natural_gas.yaml": "2021-01-01", +} +# The date the cut ends, and the first and last 1 April steps written out. +# Later steps come from each parameter's uprating metadata. +CUT_END_DATE = "2027-07-01" +# OBR EFO October 2026, paragraph 3.19: "from 52.95 pence per litre to 60.59 +# pence per litre" on 1 July 2027. It is 57.95p times one plus RPI growth in +# 2027 Q1 on a year earlier (4.5565%, economy Table 1.7). +CUT_END_RATE = Decimal("0.6059") +CUT_END_RPI_GROWTH = Decimal("0.045565") +# HM Treasury, Budget 2026 Overview of Tax Legislation and Rates, Annex A, +# fuel duty table: the rates from 1 July 2027. +ANNEX_A = "https://www.gov.uk/government/publications/budget-2026-overview-of-tax-legislation-and-rates-ootlar/annex-a-rates-and-allowances" +PUBLISHED_RATES = { + "petrol_and_diesel.yaml": Decimal("0.6059"), + "lpg.yaml": Decimal("0.3305"), + "natural_gas.yaml": Decimal("0.2582"), +} +EFO = "https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/" +FIRST_APRIL_YEAR = 2028 LAST_UPRATING_YEAR = 2031 BEGIN = " # BEGIN RPI forecast generated by calculate_fuel_duty_rates.py\n" END = " # END RPI forecast generated by calculate_fuel_duty_rates.py\n" PENNY_HUNDREDTH = Decimal("0.0001") +BUDGET_2026 = "https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138" +COSTING_2026 = "https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=13" def rpi_series() -> dict: @@ -46,55 +87,105 @@ def rpi_growth_by_year() -> dict: return {int(str(date)[:4]): Decimal(str(value)) for date, value in values.items()} -def statutory_rate() -> Decimal: - with open(PARAMETER_FILE) as f: +def base_rate(fuel: str) -> Decimal: + with open(FUEL_DUTY_DIR / fuel) as f: values = yaml.safe_load(f)["values"] - entry = {str(date): value for date, value in values.items()}[STATUTORY_DATE] + entry = {str(date): value for date, value in values.items()}[FUELS[fuel]] return Decimal(str(entry["value"] if isinstance(entry, dict) else entry)) -def forecast_steps() -> list: - """(date, rate, previous rate, RPI growth) for each forecast 1 April step.""" +def uprate(rate: Decimal, growth: Decimal) -> Decimal: + return (rate * (1 + growth)).quantize(PENNY_HUNDREDTH, rounding=ROUND_HALF_UP) + + +def forecast_steps(fuel: str) -> list: + """(date, rate, previous rate, RPI growth) for each forecast step. The + 1 July 2027 step is HM Treasury's published rate, which the OBR's RPI + growth to 2027 Q1 gives to within a hundredth of a penny, so it has no + calendar-year growth of its own.""" growth = rpi_growth_by_year() - rate = statutory_rate() - steps = [] - for year in range(FIRST_UPRATING_YEAR, LAST_UPRATING_YEAR + 1): - previous = rate - rate = (previous * (1 + growth[year - 1])).quantize( - PENNY_HUNDREDTH, rounding=ROUND_HALF_UP + base = base_rate(fuel) + rate = PUBLISHED_RATES[fuel] + if abs(rate - uprate(base, CUT_END_RPI_GROWTH)) > PENNY_HUNDREDTH: + raise ValueError( + f"{fuel}: HM Treasury's {rate} on {CUT_END_DATE} is more than a " + f"hundredth of a penny from {base} x (1 + {CUT_END_RPI_GROWTH})" ) + steps = [(CUT_END_DATE, rate, base, None)] + for year in range(FIRST_APRIL_YEAR, LAST_UPRATING_YEAR + 1): + previous = rate + rate = uprate(previous, growth[year - 1]) steps.append((f"{year}-04-01", rate, previous, growth[year - 1])) return steps -def render(steps: list) -> str: +def render(fuel: str, steps: list) -> str: # Cite the forecast that yoy_growth.yaml itself cites for RPI. forecast = rpi_series()["metadata"]["reference"][0] lines = [] for date, rate, previous, growth in steps: + if growth is None: + pct = f"{CUT_END_RPI_GROWTH * 100:.2f}%" + uprated = uprate(previous, CUT_END_RPI_GROWTH) + how = ( + "the same" + if uprated == rate + else f"{uprated} to the nearest hundredth of a penny; HM " + "Treasury's rate falls a hundredth of a penny short" + ) + if fuel == "petrol_and_diesel.yaml": + note = ( + f"HM Treasury's rate from 1 July 2027. The OBR's forecast, " + f"RPI growth in the year to 2027 Q1 ({pct}) on {previous}, " + f"gives {how}." + ) + title = "OBR EFO October 2026, paragraph 3.19 (60.59p on 1 July 2027)" + else: + note = ( + f"HM Treasury's rate from 1 July 2027. RPI growth in the " + f"year to 2027 Q1 ({pct}) on {previous}, as the OBR " + f"uprates petrol and diesel, gives {how}." + ) + title = ( + "OBR EFO October 2026, paragraph 3.19 (RPI uprating on 1 July 2027)" + ) + source = [ + " - title: HM Treasury, Budget 2026 Overview of Tax Legislation and Rates, Annex A (fuel duty rates from 1 July 2027)", + f" href: {ANNEX_A}", + f" - title: {title}", + f" href: {EFO}", + ] + else: + note = ( + f"Forecast. {int(date[:4]) - 1} RPI growth of " + f"{growth * 100:.1f}% on {previous}." + ) + source = [ + f" - title: {forecast['title']} (RPI forecast)", + f" href: {forecast['href']}", + ] lines += [ f" {date}:", f" value: {rate}", " metadata:", - f" note: Forecast. {previous} uprated by {int(date[:4]) - 1} RPI " - f"growth of {growth * 100:.1f}%.", + f" note: {note}", " reference:", - " - title: Budget 2025 (fuel duty uprated by RPI from April 2027)", - " href: https://www.gov.uk/government/publications/budget-2025-document/budget-2025-html", - " - title: HM Treasury, Budget 2025 policy costings, page 14 (from 1 April 2027)", - " href: https://assets.publishing.service.gov.uk/media/692872fd2a37784b16ecf676/Budget_2025-Policy_Costings.pdf#page=14", - f" - title: {forecast['title']} (RPI forecast)", - f" href: {forecast['href']}", + " - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027)", + f" href: {BUDGET_2026}", + " - title: HM Treasury, Budget 2026 policy costings, page 13 (RPI on 1 July 2027, then each 1 April from 2028)", + f" href: {COSTING_2026}", + *source, ] return "\n".join(lines) + "\n" -def update(steps: list) -> None: - content = PARAMETER_FILE.read_text() +def update(fuel: str, steps: list) -> None: + path = FUEL_DUTY_DIR / fuel + content = path.read_text() start = content.index(BEGIN) + len(BEGIN) stop = content.index(END) - PARAMETER_FILE.write_text(content[:start] + render(steps) + content[stop:]) - print(f"Updated {PARAMETER_FILE}") + path.write_text(content[:start] + render(fuel, steps) + content[stop:]) + print(f"Updated {path}") def main(): @@ -102,15 +193,36 @@ def main(): parser.add_argument( "--update", action="store_true", - help="Rewrite the forecast block in petrol_and_diesel.yaml", + help="Rewrite the forecast block in each fuel duty parameter file", ) args = parser.parse_args() - steps = forecast_steps() - print(f"{STATUTORY_DATE}: {statutory_rate()} (statutory)") - for date, rate, previous, growth in steps: - print(f"{date}: {rate} ({previous} x (1 + {growth}))") + petrol = "petrol_and_diesel.yaml" + uprated = uprate(base_rate(petrol), CUT_END_RPI_GROWTH) + if uprated != CUT_END_RATE: + raise ValueError( + f"CUT_END_RPI_GROWTH gives {uprated} on {CUT_END_DATE}, " + f"not the OBR's {CUT_END_RATE}" + ) + if PUBLISHED_RATES[petrol] != CUT_END_RATE: + raise ValueError( + f"PUBLISHED_RATES gives {PUBLISHED_RATES[petrol]} on " + f"{CUT_END_DATE}, not the OBR's {CUT_END_RATE}" + ) + schedules = {fuel: forecast_steps(fuel) for fuel in FUELS} + for fuel, steps in schedules.items(): + print(f"{fuel}: {FUELS[fuel]} {base_rate(fuel)} (full rate)") + for date, rate, previous, growth in steps: + if growth is None: + uprated = uprate(previous, CUT_END_RPI_GROWTH) + how = ( + f"HM Treasury; {previous} x (1 + {CUT_END_RPI_GROWTH}) = {uprated}" + ) + else: + how = f"{previous} x (1 + {growth})" + print(f" {date}: {rate} ({how})") if args.update: - update(steps) + for fuel, steps in schedules.items(): + update(fuel, steps) if __name__ == "__main__": diff --git a/policyengine_uk/parameters/gov/hmrc/fuel_duty/lpg.yaml b/policyengine_uk/parameters/gov/hmrc/fuel_duty/lpg.yaml index ff8911779c..3839056d64 100644 --- a/policyengine_uk/parameters/gov/hmrc/fuel_duty/lpg.yaml +++ b/policyengine_uk/parameters/gov/hmrc/fuel_duty/lpg.yaml @@ -1,9 +1,72 @@ description: The UK levies road fuel duty on LPG at this rate per kilogram. values: - '2021-01-01': 0.3161 - '2022-03-23': 0.2888 - '2027-01-01': 0.3052 - '2027-03-01': 0.3161 + 2021-01-01: 0.3161 + 2022-03-23: 0.2888 + # Budget 2026 keeps the proportionate cut to 1 July 2027 (paragraph 6.211; + # policy costings page 13). The full rate then returns with RPI + # growth in the year to 2027 Q1 on top, as the OBR uprates petrol and + # diesel, and rises by the previous calendar year's RPI growth each 1 April + # from 2028. That is policy, not law. After the last entry, the uprating + # metadata below continues the same April cadence. + # BEGIN RPI forecast generated by calculate_fuel_duty_rates.py + 2027-07-01: + value: 0.3305 + metadata: + note: HM Treasury's rate from 1 July 2027. RPI growth in the year to 2027 Q1 (4.56%) on 0.3161, as the OBR uprates petrol and diesel, gives the same. + reference: + - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 + - title: HM Treasury, Budget 2026 policy costings, page 13 (RPI on 1 July 2027, then each 1 April from 2028) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=13 + - title: HM Treasury, Budget 2026 Overview of Tax Legislation and Rates, Annex A (fuel duty rates from 1 July 2027) + href: https://www.gov.uk/government/publications/budget-2026-overview-of-tax-legislation-and-rates-ootlar/annex-a-rates-and-allowances + - title: OBR EFO October 2026, paragraph 3.19 (RPI uprating on 1 July 2027) + href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ + 2028-04-01: + value: 0.3448 + metadata: + note: Forecast. 2027 RPI growth of 4.3% on 0.3305. + reference: + - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 + - title: HM Treasury, Budget 2026 policy costings, page 13 (RPI on 1 July 2027, then each 1 April from 2028) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=13 + - title: OBR EFO October 2026 (detailed forecast tables, economy, Table 1.7) (RPI forecast) + href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ + 2029-04-01: + value: 0.3552 + metadata: + note: Forecast. 2028 RPI growth of 3.0% on 0.3448. + reference: + - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 + - title: HM Treasury, Budget 2026 policy costings, page 13 (RPI on 1 July 2027, then each 1 April from 2028) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=13 + - title: OBR EFO October 2026 (detailed forecast tables, economy, Table 1.7) (RPI forecast) + href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ + 2030-04-01: + value: 0.3658 + metadata: + note: Forecast. 2029 RPI growth of 3.0% on 0.3552. + reference: + - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 + - title: HM Treasury, Budget 2026 policy costings, page 13 (RPI on 1 July 2027, then each 1 April from 2028) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=13 + - title: OBR EFO October 2026 (detailed forecast tables, economy, Table 1.7) (RPI forecast) + href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ + 2031-04-01: + value: 0.3742 + metadata: + note: Forecast. 2030 RPI growth of 2.3% on 0.3658. + reference: + - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 + - title: HM Treasury, Budget 2026 policy costings, page 13 (RPI on 1 July 2027, then each 1 April from 2028) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=13 + - title: OBR EFO October 2026 (detailed forecast tables, economy, Table 1.7) (RPI forecast) + href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ + # END RPI forecast generated by calculate_fuel_duty_rates.py metadata: unit: currency-GBP period: year @@ -11,8 +74,21 @@ metadata: reference: - title: Hydrocarbon Oil Duties Act 1979, section 8 href: https://www.legislation.gov.uk/ukpga/1979/5/section/8 - - title: SI 2026/164, article 8, as amended by SI 2026/555 (1 January to 28 February 2027) + - title: SI 2026/164, as amended by SI 2026/555 (cut continued to 31 December 2026) href: https://www.legislation.gov.uk/uksi/2026/164 + - title: Budget 2026, paragraph 6.211, and policy costings page 13 (proportionate cut to other fuels to 1 July 2027; not yet law) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=13 - title: HMRC, Amended Fuel Duty rates 2026 to 2027 (22 May 2026) href: https://www.gov.uk/government/publications/amended-fuel-duty-rates-for-2026-to-2027/amended-fuel-duty-rates-2026-to-2027 fiscal_year_blend: true + # After 2031-04-01, each 1 April applies the previous calendar year's RPI + # growth. + uprating: + parameter: gov.economic_assumptions.indices.obr.rpi + at_defined_interval: + start: "0001-01-01" + end: "0002-01-01" + enactment: "0003-04-01" + rounding: + type: nearest + interval: 0.0001 diff --git a/policyengine_uk/parameters/gov/hmrc/fuel_duty/natural_gas.yaml b/policyengine_uk/parameters/gov/hmrc/fuel_duty/natural_gas.yaml index e36135665e..ca31a4fb55 100644 --- a/policyengine_uk/parameters/gov/hmrc/fuel_duty/natural_gas.yaml +++ b/policyengine_uk/parameters/gov/hmrc/fuel_duty/natural_gas.yaml @@ -1,9 +1,73 @@ description: The UK levies road fuel duty on natural gas at this rate per kilogram. values: - '2021-01-01': 0.247 - '2022-03-23': 0.2257 - '2027-01-01': 0.2385 - '2027-03-01': 0.247 + 2021-01-01: 0.247 + 2022-03-23: 0.2257 + # Budget 2026 keeps the proportionate cut to 1 July 2027 (paragraph 6.211; + # policy costings page 13). The full rate then returns with RPI + # growth in the year to 2027 Q1 on top, as the OBR uprates petrol and diesel + # (HM Treasury publishes 25.82p, a hundredth of a penny short of the + # uprating's 25.83p), and rises by the previous calendar year's RPI growth + # each 1 April from 2028. That is policy, not law. After the last entry, the + # uprating metadata below continues the same April cadence. + # BEGIN RPI forecast generated by calculate_fuel_duty_rates.py + 2027-07-01: + value: 0.2582 + metadata: + note: HM Treasury's rate from 1 July 2027. RPI growth in the year to 2027 Q1 (4.56%) on 0.247, as the OBR uprates petrol and diesel, gives 0.2583 to the nearest hundredth of a penny; HM Treasury's rate falls a hundredth of a penny short. + reference: + - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 + - title: HM Treasury, Budget 2026 policy costings, page 13 (RPI on 1 July 2027, then each 1 April from 2028) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=13 + - title: HM Treasury, Budget 2026 Overview of Tax Legislation and Rates, Annex A (fuel duty rates from 1 July 2027) + href: https://www.gov.uk/government/publications/budget-2026-overview-of-tax-legislation-and-rates-ootlar/annex-a-rates-and-allowances + - title: OBR EFO October 2026, paragraph 3.19 (RPI uprating on 1 July 2027) + href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ + 2028-04-01: + value: 0.2694 + metadata: + note: Forecast. 2027 RPI growth of 4.3% on 0.2582. + reference: + - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 + - title: HM Treasury, Budget 2026 policy costings, page 13 (RPI on 1 July 2027, then each 1 April from 2028) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=13 + - title: OBR EFO October 2026 (detailed forecast tables, economy, Table 1.7) (RPI forecast) + href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ + 2029-04-01: + value: 0.2776 + metadata: + note: Forecast. 2028 RPI growth of 3.0% on 0.2694. + reference: + - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 + - title: HM Treasury, Budget 2026 policy costings, page 13 (RPI on 1 July 2027, then each 1 April from 2028) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=13 + - title: OBR EFO October 2026 (detailed forecast tables, economy, Table 1.7) (RPI forecast) + href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ + 2030-04-01: + value: 0.2859 + metadata: + note: Forecast. 2029 RPI growth of 3.0% on 0.2776. + reference: + - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 + - title: HM Treasury, Budget 2026 policy costings, page 13 (RPI on 1 July 2027, then each 1 April from 2028) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=13 + - title: OBR EFO October 2026 (detailed forecast tables, economy, Table 1.7) (RPI forecast) + href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ + 2031-04-01: + value: 0.2924 + metadata: + note: Forecast. 2030 RPI growth of 2.3% on 0.2859. + reference: + - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 + - title: HM Treasury, Budget 2026 policy costings, page 13 (RPI on 1 July 2027, then each 1 April from 2028) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=13 + - title: OBR EFO October 2026 (detailed forecast tables, economy, Table 1.7) (RPI forecast) + href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ + # END RPI forecast generated by calculate_fuel_duty_rates.py metadata: unit: currency-GBP period: year @@ -11,8 +75,21 @@ metadata: reference: - title: Hydrocarbon Oil Duties Act 1979, section 8 href: https://www.legislation.gov.uk/ukpga/1979/5/section/8 - - title: SI 2026/164, article 8, as amended by SI 2026/555 (1 January to 28 February 2027) + - title: SI 2026/164, as amended by SI 2026/555 (cut continued to 31 December 2026) href: https://www.legislation.gov.uk/uksi/2026/164 + - title: Budget 2026, paragraph 6.211, and policy costings page 13 (proportionate cut to other fuels to 1 July 2027; not yet law) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=13 - title: HMRC, Amended Fuel Duty rates 2026 to 2027 (22 May 2026) href: https://www.gov.uk/government/publications/amended-fuel-duty-rates-for-2026-to-2027/amended-fuel-duty-rates-2026-to-2027 fiscal_year_blend: true + # After 2031-04-01, each 1 April applies the previous calendar year's RPI + # growth. + uprating: + parameter: gov.economic_assumptions.indices.obr.rpi + at_defined_interval: + start: "0001-01-01" + end: "0002-01-01" + enactment: "0003-04-01" + rounding: + type: nearest + interval: 0.0001 diff --git a/policyengine_uk/parameters/gov/hmrc/fuel_duty/petrol_and_diesel.yaml b/policyengine_uk/parameters/gov/hmrc/fuel_duty/petrol_and_diesel.yaml index 267d1bce1d..a47d8db7dc 100644 --- a/policyengine_uk/parameters/gov/hmrc/fuel_duty/petrol_and_diesel.yaml +++ b/policyengine_uk/parameters/gov/hmrc/fuel_duty/petrol_and_diesel.yaml @@ -68,7 +68,8 @@ values: href: https://www.legislation.gov.uk/uksi/2025/228/made # Budget 2025 scheduled steps on 1 September 2026, 1 December 2026 and # 1 March 2027. SI 2026/555 removed the September step and moved the - # December step to 1 January 2027. + # December step to 1 January 2027. Budget 2026 then cancelled the January + # and March 2027 steps, keeping the 5p cut to 1 July 2027. 2026-03-23: value: 0.5295 metadata: @@ -80,83 +81,83 @@ values: - title: HMRC, Amended Fuel Duty rates 2026 to 2027 (22 May 2026) href: https://www.gov.uk/government/publications/amended-fuel-duty-rates-for-2026-to-2027/amended-fuel-duty-rates-2026-to-2027 2027-01-01: - value: 0.5595 - metadata: - reference: - - title: SI 2026/164, article 8, as amended by SI 2026/555 (1 January to 28 February 2027) - href: https://www.legislation.gov.uk/uksi/2026/164 - - title: HMRC, Amended Fuel Duty rates 2026 to 2027 (22 May 2026) - href: https://www.gov.uk/government/publications/amended-fuel-duty-rates-for-2026-to-2027/amended-fuel-duty-rates-2026-to-2027 - 2027-03-01: - value: 0.5795 + value: 0.5295 metadata: - reference: - - title: Hydrocarbon Oil Duties Act 1979, section 6(1A) (no reduction order applies after 28 February 2027) - href: https://www.legislation.gov.uk/ukpga/1979/5/section/6 - - title: HMRC, Amended Fuel Duty rates 2026 to 2027 (22 May 2026) - href: https://www.gov.uk/government/publications/amended-fuel-duty-rates-for-2026-to-2027/amended-fuel-duty-rates-2026-to-2027 - # Forecast from April 2027. Budget 2025 states that rates will be uprated by - # RPI from April 2027, and HM Treasury's costing dates this from 1 April - # 2027. That is policy, not law: HMRC's amended note of 22 May 2026 says - # final rates will be confirmed at Budget 2026. No official source names the - # RPI measure. Each 1 April applies the previous calendar year's RPI growth - # from gov.economic_assumptions.yoy_growth.obr.rpi, rounding to the nearest + note: Budget 2026 policy, not yet law. The 5p cut runs to 1 July 2027 in place of SI 2026/164's steps to 55.95p on 1 January 2027 and 57.95p on 1 March 2027. + reference: + - title: Budget 2026, paragraph 6.211 (cut extended to 1 July 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 + - title: Budget 2026 policy costings, page 13 + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=13 + - title: Budget 2026, Table 6.1, item 3 + href: https://assets.publishing.service.gov.uk/media/6ae187a2779378370fd80a9a/Table_6.1_and_6.2_Budget_2026_Policy_Decisions.xlsx + # Forecast from 1 July 2027. Budget 2026's costing: "On 1 July 2027, fuel + # duty rates will increase by 5ppl and will be uprated in line with the + # Retail Prices Index (RPI). From 1 April 2028, fuel duty rates will be + # uprated annually in line with RPI." That is policy, not law. The 1 July + # 2027 step is the OBR's forecast (EFO October 2026, paragraph 3.19): RPI + # growth in the year to 2027 Q1 on the 57.95p pre-cut rate (Finance Act 2011 + # s. 19), 60.59p. Each 1 April from 2028 applies the previous + # calendar year's RPI growth from + # gov.economic_assumptions.yoy_growth.obr.rpi, rounding to the nearest # hundredth of a penny. After the last entry, the uprating metadata below # continues the same April cadence. # BEGIN RPI forecast generated by calculate_fuel_duty_rates.py - 2027-04-01: - value: 0.6003 - metadata: - note: Forecast. 0.5795 uprated by 2026 RPI growth of 3.6%. - reference: - - title: Budget 2025 (fuel duty uprated by RPI from April 2027) - href: https://www.gov.uk/government/publications/budget-2025-document/budget-2025-html - - title: HM Treasury, Budget 2025 policy costings, page 14 (from 1 April 2027) - href: https://assets.publishing.service.gov.uk/media/692872fd2a37784b16ecf676/Budget_2025-Policy_Costings.pdf#page=14 - - title: OBR EFO October 2026 (detailed forecast tables, economy, Table 1.7) (RPI forecast) + 2027-07-01: + value: 0.6059 + metadata: + note: HM Treasury's rate from 1 July 2027. The OBR's forecast, RPI growth in the year to 2027 Q1 (4.56%) on 0.5795, gives the same. + reference: + - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 + - title: HM Treasury, Budget 2026 policy costings, page 13 (RPI on 1 July 2027, then each 1 April from 2028) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=13 + - title: HM Treasury, Budget 2026 Overview of Tax Legislation and Rates, Annex A (fuel duty rates from 1 July 2027) + href: https://www.gov.uk/government/publications/budget-2026-overview-of-tax-legislation-and-rates-ootlar/annex-a-rates-and-allowances + - title: OBR EFO October 2026, paragraph 3.19 (60.59p on 1 July 2027) href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ 2028-04-01: - value: 0.6262 + value: 0.6321 metadata: - note: Forecast. 0.6003 uprated by 2027 RPI growth of 4.3%. + note: Forecast. 2027 RPI growth of 4.3% on 0.6059. reference: - - title: Budget 2025 (fuel duty uprated by RPI from April 2027) - href: https://www.gov.uk/government/publications/budget-2025-document/budget-2025-html - - title: HM Treasury, Budget 2025 policy costings, page 14 (from 1 April 2027) - href: https://assets.publishing.service.gov.uk/media/692872fd2a37784b16ecf676/Budget_2025-Policy_Costings.pdf#page=14 + - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 + - title: HM Treasury, Budget 2026 policy costings, page 13 (RPI on 1 July 2027, then each 1 April from 2028) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=13 - title: OBR EFO October 2026 (detailed forecast tables, economy, Table 1.7) (RPI forecast) href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ 2029-04-01: - value: 0.6452 + value: 0.6513 metadata: - note: Forecast. 0.6262 uprated by 2028 RPI growth of 3.0%. + note: Forecast. 2028 RPI growth of 3.0% on 0.6321. reference: - - title: Budget 2025 (fuel duty uprated by RPI from April 2027) - href: https://www.gov.uk/government/publications/budget-2025-document/budget-2025-html - - title: HM Treasury, Budget 2025 policy costings, page 14 (from 1 April 2027) - href: https://assets.publishing.service.gov.uk/media/692872fd2a37784b16ecf676/Budget_2025-Policy_Costings.pdf#page=14 + - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 + - title: HM Treasury, Budget 2026 policy costings, page 13 (RPI on 1 July 2027, then each 1 April from 2028) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=13 - title: OBR EFO October 2026 (detailed forecast tables, economy, Table 1.7) (RPI forecast) href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ 2030-04-01: - value: 0.6644 + value: 0.6707 metadata: - note: Forecast. 0.6452 uprated by 2029 RPI growth of 3.0%. + note: Forecast. 2029 RPI growth of 3.0% on 0.6513. reference: - - title: Budget 2025 (fuel duty uprated by RPI from April 2027) - href: https://www.gov.uk/government/publications/budget-2025-document/budget-2025-html - - title: HM Treasury, Budget 2025 policy costings, page 14 (from 1 April 2027) - href: https://assets.publishing.service.gov.uk/media/692872fd2a37784b16ecf676/Budget_2025-Policy_Costings.pdf#page=14 + - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 + - title: HM Treasury, Budget 2026 policy costings, page 13 (RPI on 1 July 2027, then each 1 April from 2028) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=13 - title: OBR EFO October 2026 (detailed forecast tables, economy, Table 1.7) (RPI forecast) href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ 2031-04-01: - value: 0.6796 + value: 0.6861 metadata: - note: Forecast. 0.6644 uprated by 2030 RPI growth of 2.3%. + note: Forecast. 2030 RPI growth of 2.3% on 0.6707. reference: - - title: Budget 2025 (fuel duty uprated by RPI from April 2027) - href: https://www.gov.uk/government/publications/budget-2025-document/budget-2025-html - - title: HM Treasury, Budget 2025 policy costings, page 14 (from 1 April 2027) - href: https://assets.publishing.service.gov.uk/media/692872fd2a37784b16ecf676/Budget_2025-Policy_Costings.pdf#page=14 + - title: Budget 2026, paragraph 6.211 (5p cut ends and RPI uprating from 1 July 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 + - title: HM Treasury, Budget 2026 policy costings, page 13 (RPI on 1 July 2027, then each 1 April from 2028) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=13 - title: OBR EFO October 2026 (detailed forecast tables, economy, Table 1.7) (RPI forecast) href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ # END RPI forecast generated by calculate_fuel_duty_rates.py diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/trading_allowance.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/trading_allowance.yaml index bbaa6d997b..d92597f6e4 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/trading_allowance.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/trading_allowance.yaml @@ -12,6 +12,19 @@ values: href: https://www.legislation.gov.uk/ukpga/2005/5/section/783AD - title: Finance (No. 2) Act 2017 Sch. 3 href: https://www.legislation.gov.uk/ukpga/2017/32/schedule/3 + # Budget 2026 raises the allowance to £3,000 from 6 April 2027, to be + # legislated in Finance Bill 2026-27. The s. 783AD amount also relieves + # miscellaneous income (s. 783AB); the model applies it to trading income + # and to Chapter 8 miscellaneous income (income_not_otherwise_charged). + 2027-04-06: + value: 3_000 + reference: + - title: Budget 2026, paragraph 6.254 (Increase Trading Allowance) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=142 + - title: Budget 2026 policy costings, page 23 (from 6 April 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=23 + - title: Budget 2026, Table 6.1, item 15 + href: https://assets.publishing.service.gov.uk/media/6ae187a2779378370fd80a9a/Table_6.1_and_6.2_Budget_2026_Policy_Decisions.xlsx metadata: label: Trading allowance period: year diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/rates/property.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/rates/property.yaml index d383f5421d..9f81aaac55 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/rates/property.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/rates/property.yaml @@ -1,10 +1,12 @@ -description: Property income tax rates. From April 2027, property income is taxed at rates 2pp higher than the standard UK rates. +description: Property income tax rates. From April 2027, property income tax rates run 4pp higher than the standard UK rates (2pp from Budget 2025 and 2pp from Budget 2026), in England, Wales and Northern Ireland. The Budget 2026 rise is announced policy, not yet law. metadata: label: Property Income Tax rates unit: /1 reference: - title: OBR Economic and Fiscal Outlook November 2025 href: https://obr.uk/efo/economic-and-fiscal-outlook-november-2025/ + - title: Budget 2026, paragraph 6.246 + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=142 basic: description: The basic rate of tax on property income. metadata: @@ -12,11 +14,19 @@ basic: unit: /1 values: 2015-04-06: 0.20 + # Budget 2025 set 22% from 6 April 2027; Budget 2026 adds a further + # 2 percentage points from the same date, applying across England, Wales and Northern Ireland. 2027-04-06: - value: 0.22 + value: 0.24 metadata: reference: - - title: OBR Economic and Fiscal Outlook November 2025 + - title: Budget 2026, paragraph 6.246 + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=142 + - title: Budget 2026 policy costings, page 74 (from 6 April 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=74 + - title: Budget 2026, Table 6.1, item 61 + href: https://assets.publishing.service.gov.uk/media/6ae187a2779378370fd80a9a/Table_6.1_and_6.2_Budget_2026_Policy_Decisions.xlsx + - title: OBR Economic and Fiscal Outlook November 2025 (Budget 2025's first 2 points) href: https://obr.uk/efo/economic-and-fiscal-outlook-november-2025/ higher: description: The higher rate of tax on property income. @@ -25,11 +35,19 @@ higher: unit: /1 values: 2015-04-06: 0.40 + # Budget 2025 set 42% from 6 April 2027; Budget 2026 adds a further + # 2 percentage points from the same date, applying across England, Wales and Northern Ireland. 2027-04-06: - value: 0.42 + value: 0.44 metadata: reference: - - title: OBR Economic and Fiscal Outlook November 2025 + - title: Budget 2026, paragraph 6.246 + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=142 + - title: Budget 2026 policy costings, page 74 (from 6 April 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=74 + - title: Budget 2026, Table 6.1, item 61 + href: https://assets.publishing.service.gov.uk/media/6ae187a2779378370fd80a9a/Table_6.1_and_6.2_Budget_2026_Policy_Decisions.xlsx + - title: OBR Economic and Fiscal Outlook November 2025 (Budget 2025's first 2 points) href: https://obr.uk/efo/economic-and-fiscal-outlook-november-2025/ additional: description: The additional rate of tax on property income. @@ -38,9 +56,17 @@ additional: unit: /1 values: 2015-04-06: 0.45 + # Budget 2025 set 47% from 6 April 2027; Budget 2026 adds a further + # 2 percentage points from the same date, applying across England, Wales and Northern Ireland. 2027-04-06: - value: 0.47 + value: 0.49 metadata: reference: - - title: OBR Economic and Fiscal Outlook November 2025 + - title: Budget 2026, paragraph 6.246 + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=142 + - title: Budget 2026 policy costings, page 74 (from 6 April 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=74 + - title: Budget 2026, Table 6.1, item 61 + href: https://assets.publishing.service.gov.uk/media/6ae187a2779378370fd80a9a/Table_6.1_and_6.2_Budget_2026_Policy_Decisions.xlsx + - title: OBR Economic and Fiscal Outlook November 2025 (Budget 2025's first 2 points) href: https://obr.uk/efo/economic-and-fiscal-outlook-november-2025/ diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/rates/savings.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/rates/savings.yaml index 1f160ef4ab..e3ddca03ae 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/rates/savings.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/rates/savings.yaml @@ -1,10 +1,12 @@ -description: Savings income tax rates. From April 2027, savings income is taxed at rates 2pp higher than the standard UK rates. +description: Savings income tax rates. From April 2027, savings income tax rates run 4pp higher than the standard UK rates (2pp from Budget 2025 and 2pp from Budget 2026), across the UK. The Budget 2026 rise is announced policy, not yet law. metadata: label: Savings Income Tax rates unit: /1 reference: - title: OBR Economic and Fiscal Outlook November 2025 href: https://obr.uk/efo/economic-and-fiscal-outlook-november-2025/ + - title: Budget 2026, paragraph 6.247 + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=142 basic: description: The basic rate of tax on savings income. metadata: @@ -12,11 +14,19 @@ basic: unit: /1 values: 2015-04-06: 0.20 + # Budget 2025 set 22% from 6 April 2027; Budget 2026 adds a further + # 2 percentage points from the same date, applying across the UK. 2027-04-06: - value: 0.22 + value: 0.24 metadata: reference: - - title: OBR Economic and Fiscal Outlook November 2025 + - title: Budget 2026, paragraph 6.247 + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=142 + - title: Budget 2026 policy costings, page 75 (from 6 April 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=75 + - title: Budget 2026, Table 6.1, item 62 + href: https://assets.publishing.service.gov.uk/media/6ae187a2779378370fd80a9a/Table_6.1_and_6.2_Budget_2026_Policy_Decisions.xlsx + - title: OBR Economic and Fiscal Outlook November 2025 (Budget 2025's first 2 points) href: https://obr.uk/efo/economic-and-fiscal-outlook-november-2025/ higher: description: The higher rate of tax on savings income. @@ -25,11 +35,19 @@ higher: unit: /1 values: 2015-04-06: 0.40 + # Budget 2025 set 42% from 6 April 2027; Budget 2026 adds a further + # 2 percentage points from the same date, applying across the UK. 2027-04-06: - value: 0.42 + value: 0.44 metadata: reference: - - title: OBR Economic and Fiscal Outlook November 2025 + - title: Budget 2026, paragraph 6.247 + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=142 + - title: Budget 2026 policy costings, page 75 (from 6 April 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=75 + - title: Budget 2026, Table 6.1, item 62 + href: https://assets.publishing.service.gov.uk/media/6ae187a2779378370fd80a9a/Table_6.1_and_6.2_Budget_2026_Policy_Decisions.xlsx + - title: OBR Economic and Fiscal Outlook November 2025 (Budget 2025's first 2 points) href: https://obr.uk/efo/economic-and-fiscal-outlook-november-2025/ additional: description: The additional rate of tax on savings income. @@ -38,9 +56,17 @@ additional: unit: /1 values: 2015-04-06: 0.45 + # Budget 2025 set 47% from 6 April 2027; Budget 2026 adds a further + # 2 percentage points from the same date, applying across the UK. 2027-04-06: - value: 0.47 + value: 0.49 metadata: reference: - - title: OBR Economic and Fiscal Outlook November 2025 + - title: Budget 2026, paragraph 6.247 + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=142 + - title: Budget 2026 policy costings, page 75 (from 6 April 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=75 + - title: Budget 2026, Table 6.1, item 62 + href: https://assets.publishing.service.gov.uk/media/6ae187a2779378370fd80a9a/Table_6.1_and_6.2_Budget_2026_Policy_Decisions.xlsx + - title: OBR Economic and Fiscal Outlook November 2025 (Budget 2025's first 2 points) href: https://obr.uk/efo/economic-and-fiscal-outlook-november-2025/ diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/pension_tax_allowance/in_effect.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/pension_tax_allowance/in_effect.yaml new file mode 100644 index 0000000000..6e70aa1f4e --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/pension_tax_allowance/in_effect.yaml @@ -0,0 +1,16 @@ +description: Whether the Pension Tax Allowance applies in the tax year. +values: + 0001-01-01: false + 2027-04-06: true + # "The Pension Tax Allowance will apply from April 2027 for tax years + # 2027-28, 2028-29 and 2029-30." + 2030-04-06: false +metadata: + unit: bool + period: year + label: Pension Tax Allowance in effect + reference: + - title: HM Treasury, Pension Tax Allowance explainer, FAQ (tax years 2027-28 to 2029-30) + href: https://www.gov.uk/government/publications/pension-tax-allowance-explainer/pension-tax-allowance-explainer + - title: Budget 2026, Table 6.1, item 52 (from 6 April 2027 until 5 April 2030) + href: https://assets.publishing.service.gov.uk/media/6ae187a2779378370fd80a9a/Table_6.1_and_6.2_Budget_2026_Policy_Decisions.xlsx diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/pension_tax_allowance/index.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/pension_tax_allowance/index.yaml new file mode 100644 index 0000000000..ae02ce6b65 --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/pension_tax_allowance/index.yaml @@ -0,0 +1,12 @@ +description: The Pension Tax Allowance, a non-refundable Income Tax reduction for people at or reaching State Pension age whose total income is up to, or a little above, the full new State Pension. Budget 2026 policy for 2027-28 to 2029-30, not yet law. +metadata: + label: Pension Tax Allowance + reference: + - title: Budget 2026, paragraph 6.182 (Income Tax, Pension Tax Allowance) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=135 + - title: Budget 2026 policy costings, page 63 + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=63 + - title: HM Treasury, Pension Tax Allowance explainer (28 October 2026) + href: https://www.gov.uk/government/publications/pension-tax-allowance-explainer/pension-tax-allowance-explainer + - title: Budget 2026, Table 6.1, item 52 + href: https://assets.publishing.service.gov.uk/media/6ae187a2779378370fd80a9a/Table_6.1_and_6.2_Budget_2026_Policy_Decisions.xlsx diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/pension_tax_allowance/rate.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/pension_tax_allowance/rate.yaml new file mode 100644 index 0000000000..9536980d30 --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/pension_tax_allowance/rate.yaml @@ -0,0 +1,12 @@ +description: Rate applied to the full new State Pension above the Personal Allowance to give the full Pension Tax Allowance. It is the UK basic rate for every taxpayer, Scottish taxpayers included. +values: + 2027-04-06: 0.2 +metadata: + unit: /1 + period: year + label: Pension Tax Allowance rate + reference: + - title: HM Treasury, Pension Tax Allowance explainer, FAQ (Scottish taxpayers use the 20% basic rate) + href: https://www.gov.uk/government/publications/pension-tax-allowance-explainer/pension-tax-allowance-explainer + - title: Budget 2026 policy costings, page 63 (the tax otherwise due on the new State Pension above the Personal Allowance) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=63 diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/pension_tax_allowance/taper.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/pension_tax_allowance/taper.yaml new file mode 100644 index 0000000000..1e55a615dd --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/pension_tax_allowance/taper.yaml @@ -0,0 +1,10 @@ +description: Pension Tax Allowance withdrawn for each pound of total income above the State Pension threshold. It falls by £1 for every £3. +values: + 2027-04-06: 0.333333333333 +metadata: + unit: /1 + period: year + label: Pension Tax Allowance taper rate + reference: + - title: HM Treasury, Pension Tax Allowance explainer, section 1 ("For every £3 of income above the full level of the new State Pension, your Pension Tax Allowance will reduce by £1") + href: https://www.gov.uk/government/publications/pension-tax-allowance-explainer/pension-tax-allowance-explainer diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/step_6_tax_reductions.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/step_6_tax_reductions.yaml index aaf29f578d..8e27c45473 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/step_6_tax_reductions.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/step_6_tax_reductions.yaml @@ -10,8 +10,12 @@ metadata: href: https://www.legislation.gov.uk/ukpga/2007/3/section/26 - title: Income Tax Act 2007 s. 29(2) and (3) href: https://www.legislation.gov.uk/ukpga/2007/3/section/29 + - title: Budget 2026, paragraph 6.182 (Pension Tax Allowance) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=135 values: 0000-01-01: - capped_mcad - other_tax_credits - property_finance_cost_relief + # Budget 2026, 2027-28 to 2029-30; zero in other years. + - pension_tax_allowance diff --git a/policyengine_uk/parameters/gov/hmrc/vat/domestic_electricity_rate/great_britain.yaml b/policyengine_uk/parameters/gov/hmrc/vat/domestic_electricity_rate/great_britain.yaml index 3a066700c4..bb5da8d649 100644 --- a/policyengine_uk/parameters/gov/hmrc/vat/domestic_electricity_rate/great_britain.yaml +++ b/policyengine_uk/parameters/gov/hmrc/vat/domestic_electricity_rate/great_britain.yaml @@ -1,4 +1,4 @@ -description: VAT rate on supplies of domestic electricity in England, Wales and Scotland. It is the reduced rate except while the temporary zero rate applies. +description: VAT rate on supplies of domestic electricity in England, Wales and Scotland. It is the reduced rate except while the temporary zero rate applies, from 1 October 2026 to 30 June 2027. values: 1994-04-01: 0.08 1997-09-01: 0.05 @@ -10,7 +10,18 @@ values: href: https://www.legislation.gov.uk/uksi/2026/987/contents/made - title: HMRC Revenue and Customs Brief 10 (2026) href: https://www.gov.uk/government/publications/revenue-and-customs-brief-10-2026-temporary-zero-rate-of-vat-for-domestic-electricity-in-great-britain/temporary-zero-rate-of-vat-for-domestic-electricity-in-great-britain - 2027-04-01: 0.05 + # SI 2026/987 ends the zero rate on 31 March 2027. Budget 2026 extends it + # by three months, to 1 July 2027; that is policy, not yet law. + 2027-07-01: + value: 0.05 + metadata: + reference: + - title: Budget 2026, paragraph 6.210 (zero rate extended to 1 July 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 + - title: Budget 2026 policy costings, page 12 (from 1 October 2026 until 1 July 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=12 + - title: Budget 2026, Table 6.1, item 2 + href: https://assets.publishing.service.gov.uk/media/6ae187a2779378370fd80a9a/Table_6.1_and_6.2_Budget_2026_Policy_Decisions.xlsx metadata: unit: /1 period: year @@ -22,5 +33,6 @@ metadata: href: https://www.legislation.gov.uk/uksi/2026/987/contents/made # Liability follows the date the electricity is consumed (Revenue and # Customs Brief 10 (2026)). Day-weighting the fiscal year assumes equal - # daily use: FY 2026-27 has 183 days at 5% and 182 at 0%. + # daily use: FY 2026-27 has 178 days at 5% and 187 at 0%, and FY 2027-28 + # has 86 days at 0% and 280 at 5%. fiscal_year_blend: true diff --git a/policyengine_uk/parameters/gov/hmrc/vat/domestic_electricity_rate/northern_ireland.yaml b/policyengine_uk/parameters/gov/hmrc/vat/domestic_electricity_rate/northern_ireland.yaml index cbb38988ad..364d3e1ea8 100644 --- a/policyengine_uk/parameters/gov/hmrc/vat/domestic_electricity_rate/northern_ireland.yaml +++ b/policyengine_uk/parameters/gov/hmrc/vat/domestic_electricity_rate/northern_ireland.yaml @@ -1,7 +1,23 @@ -description: VAT rate on supplies of domestic electricity in Northern Ireland. The temporary zero rate from 1 October 2026 does not apply there. +description: VAT rate on supplies of domestic electricity in Northern Ireland. The temporary zero rate from 1 October 2026 does not apply there; Budget 2026 expects the extended zero rate to apply from 1 April to 30 June 2027. values: 1994-04-01: 0.08 1997-09-01: 0.05 + # Budget 2026 expects the extended relief to apply UK-wide from April 2027. + # That is policy, not yet law, and the document says "expected". + 2027-04-01: + value: 0 + metadata: + reference: + - title: Budget 2026, paragraph 6.210 ("expected to apply UK-wide from April 2027") + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 + - title: Budget 2026, paragraph 3.4 ("Extended VAT relief is expected to apply in Northern Ireland from April 2027") + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=49 + 2027-07-01: + value: 0.05 + metadata: + reference: + - title: Budget 2026 policy costings, page 12 (until 1 July 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=12 metadata: unit: /1 period: year @@ -11,4 +27,6 @@ metadata: href: https://www.legislation.gov.uk/ukpga/1994/23/schedule/7A - title: HMRC Revenue and Customs Brief 10 (2026) href: https://www.gov.uk/government/publications/revenue-and-customs-brief-10-2026-temporary-zero-rate-of-vat-for-domestic-electricity-in-great-britain/temporary-zero-rate-of-vat-for-domestic-electricity-in-great-britain + # FY 2026-27 has 360 days at 5% and 5 at 0%; FY 2027-28 has 86 at 0% and + # 280 at 5%. fiscal_year_blend: true diff --git a/policyengine_uk/parameters/gov/treasury/household_energy_payment/amount.yaml b/policyengine_uk/parameters/gov/treasury/household_energy_payment/amount.yaml new file mode 100644 index 0000000000..6f90a5993a --- /dev/null +++ b/policyengine_uk/parameters/gov/treasury/household_energy_payment/amount.yaml @@ -0,0 +1,23 @@ +description: One-off Household Energy Payment to each eligible household in the fiscal year. +values: + 0001-01-01: 0 + # Budget 2026 announces £100 for households in council tax bands A to D in + # England, which local authorities pay from January 2027, within + # 2026-27. The model year runs from 6 April, so the payment starts and ends + # with model year 2026. It is a spending decision, not + # legislation. + 2026-04-06: + value: 100 + metadata: + reference: + - title: Budget 2026, paragraph 6.24 (Household Energy Payment) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=117 + - title: Budget 2026, paragraph 3.5 (£100 for households in bands A-D in England from January 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=49 + - title: Budget 2026, Table 6.1, item 1 + href: https://assets.publishing.service.gov.uk/media/6ae187a2779378370fd80a9a/Table_6.1_and_6.2_Budget_2026_Policy_Decisions.xlsx + 2027-04-06: 0 +metadata: + unit: currency-GBP + period: year + label: Household Energy Payment amount diff --git a/policyengine_uk/parameters/gov/treasury/household_energy_payment/bands.yaml b/policyengine_uk/parameters/gov/treasury/household_energy_payment/bands.yaml new file mode 100644 index 0000000000..8c9f2df012 --- /dev/null +++ b/policyengine_uk/parameters/gov/treasury/household_energy_payment/bands.yaml @@ -0,0 +1,15 @@ +description: Council tax bands whose households receive the Household Energy Payment. +values: + 0001-01-01: + - A + - B + - C + - D +metadata: + economy: false + unit: list + period: year + label: Household Energy Payment council tax bands + reference: + - title: Budget 2026, paragraph 6.24 (households in bands A to D) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=117 diff --git a/policyengine_uk/parameters/gov/treasury/household_energy_payment/countries.yaml b/policyengine_uk/parameters/gov/treasury/household_energy_payment/countries.yaml new file mode 100644 index 0000000000..2c1b91a3a6 --- /dev/null +++ b/policyengine_uk/parameters/gov/treasury/household_energy_payment/countries.yaml @@ -0,0 +1,12 @@ +description: Countries whose households receive the Household Energy Payment. The devolved governments receive Barnett funding instead, which the model does not allocate. +values: + 0001-01-01: + - ENGLAND +metadata: + economy: false + unit: list + period: year + label: Household Energy Payment countries + reference: + - title: Budget 2026, paragraph 3.5 (bands A-D in England; the devolved governments get their share through the Barnett formula) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=49 diff --git a/policyengine_uk/parameters/gov/treasury/household_energy_payment/index.yaml b/policyengine_uk/parameters/gov/treasury/household_energy_payment/index.yaml new file mode 100644 index 0000000000..944ebdf2a6 --- /dev/null +++ b/policyengine_uk/parameters/gov/treasury/household_energy_payment/index.yaml @@ -0,0 +1,3 @@ +description: The Household Energy Payment, a one-off £100 paid through the council tax system in 2026-27. +metadata: + label: Household Energy Payment diff --git a/policyengine_uk/programs.yaml b/policyengine_uk/programs.yaml index 3e8eab20b0..c1a8bd6c76 100644 --- a/policyengine_uk/programs.yaml +++ b/policyengine_uk/programs.yaml @@ -621,6 +621,19 @@ programs: discretionary fund, Great Britain's Alternative Fuel Payment and the alternative funding schemes. + - id: household_energy_payment + name: Household Energy Payment + full_name: Household Energy Payment (£100 council tax rebate) + category: Benefits + agency: Treasury + status: complete + coverage: England + variable: household_energy_payment + parameter_prefix: gov.treasury.household_energy_payment + verified_start_year: 2026 + verified_end_year: 2026 + notes: One-off £100 for households in council tax bands A to D in England, paid by local authorities from January 2027 (Budget 2026). The devolved governments' Barnett share and the £100 million local-authority discretionary fund are not modelled. + # --- Ofgem programs --- - id: energy_price_guarantee name: Energy Price Guarantee diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml index dd01419059..e753affdcf 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml @@ -291,10 +291,10 @@ property_rental_income: 5_000 property_income: 5_000 output: - # Property: 5,000 of receipts with no expenses, so (5,000 - 1,000) x 22% - # = 880. - property_income_tax: 880 - income_tax: 2_366 + # Property: 5,000 of receipts with no expenses, so (5,000 - 1,000) x 24% + # = 960. Earnings: (20,000 - 12,570) x 20% = 1,486. + property_income_tax: 960 + income_tax: 2_446 uc_income_tax_on_earnings: 1_486 - name: A tax reduction comes off the tax on earnings first diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_statutory_pay.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_statutory_pay.yaml index 0705307584..02747d3f18 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_statutory_pay.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_statutory_pay.yaml @@ -246,15 +246,17 @@ output: # 424.90 x 12 - 0.55 x 400, with no work allowance. universal_credit: 4_878.80 - # The award plus the pay. Without the pay the household has 5,098.80, so - # 400 of pay raises its income by 180. - household_benefits: 5_278.80 - household_net_income: 5_278.80 + # The award plus the pay, plus the £100 Household Energy Payment: band D + # (the default) in England. Without the pay the household has + # 424.90 x 12 + 100 = 5,198.80, so 400 of pay raises its income by 180. + household_benefits: 5_378.80 + household_net_income: 5_378.80 # HBAI also counts the £150 Warm Home Discount the Universal Credit - # award brings in England, as miscellaneous income. + # award brings in England, as miscellaneous income; the Household Energy + # Payment is already in household net income. warm_home_discount: 150 - hbai_household_net_income: 5_428.80 - gov_spending: 5_278.80 + hbai_household_net_income: 5_528.80 + gov_spending: 5_378.80 - name: Taxable statutory paternity pay reduces Universal Credit like wages # UC Regs 2013 reg. 55(4)(c) and (5): count SPP before deducting its tax. diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_war_pension_income.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_war_pension_income.yaml index c81788d84c..e295c5f111 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_war_pension_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_war_pension_income.yaml @@ -323,10 +323,11 @@ war_widows_pension: 30 * 52 # It joins the gross benefit totals that bsp is in, so moving FRS code 9 # from bsp to this variable leaves the totals unchanged. - household_benefits: 30 * 52 + # Plus the £100 Household Energy Payment: band D (the default) in England. + household_benefits: 30 * 52 + 100 hbai_benefits: 30 * 52 - gov_spending: 30 * 52 - pre_budget_change_household_benefits: 30 * 52 + gov_spending: 30 * 52 + 100 + pre_budget_change_household_benefits: 30 * 52 + 100 # ITEPA 2003 s.639: no income tax on war widow's pensions. social_security_income: 0 income_tax: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/widowed_parents_allowance.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/widowed_parents_allowance.yaml index 8b71b0b6af..fdf14391fe 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/widowed_parents_allowance.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/widowed_parents_allowance.yaml @@ -41,10 +41,11 @@ members: [person1] country: ENGLAND output: - household_benefits: 156.65 * 52 + # Plus the £100 Household Energy Payment: band D (the default) in England. + household_benefits: 156.65 * 52 + 100 hbai_benefits: 156.65 * 52 - gov_spending: 156.65 * 52 - pre_budget_change_household_benefits: 156.65 * 52 + gov_spending: 156.65 * 52 + 100 + pre_budget_change_household_benefits: 156.65 * 52 + 100 # 8,145.80 is below the personal allowance. income_tax: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/fuel_duty/petrol_and_diesel_schedule.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/fuel_duty/petrol_and_diesel_schedule.yaml index ac3b8e31c0..f5f761cf8b 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/fuel_duty/petrol_and_diesel_schedule.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/fuel_duty/petrol_and_diesel_schedule.yaml @@ -1,14 +1,15 @@ # Fuel duty on 1,000 litres in each model year. A model year runs from # 6 April to 5 April, and the dated rates are day-weighted across it: # 2025-26: 365 days at 52.95p. -# 2026-27: 270 days at 52.95p (6 Apr to 31 Dec 2026), 59 at 55.95p -# (Jan to Feb 2027), 31 at 57.95p (Mar 2027) and 5 at 60.03p. -# 2027-28 (366 days): 361 at 60.03p and 5 at 62.62p. -# 2028-29: 360 at 62.62p and 5 at 64.52p. -# 2029-30: 360 at 64.52p and 5 at 66.44p. -# 2030-31: 360 at 66.44p and 5 at 67.96p. -# Rates from 1 April 2027 are the RPI forecast in petrol_and_diesel.yaml, on -# the OBR's October 2026 RPI forecast. +# 2026-27: 365 days at 52.95p (Budget 2026 keeps the cut to 1 July 2027). +# 2027-28 (366 days): 86 at 52.95p (6 Apr to 30 Jun 2027), 275 at 60.59p +# (1 Jul 2027 to 31 Mar 2028) and 5 at 63.21p. +# 2028-29: 360 at 63.21p and 5 at 65.13p. +# 2029-30: 360 at 65.13p and 5 at 67.07p. +# 2030-31: 360 at 67.07p and 5 at 68.61p. +# The 1 July 2027 rate is the OBR's forecast (EFO October 2026, paragraph +# 3.19); later rates are the RPI forecast in petrol_and_diesel.yaml, on the +# OBR's October 2026 RPI forecast. - name: Fuel duty for 2025-26 is 52.95p a litre all year period: 2025 absolute_error_margin: 0.01 @@ -17,24 +18,23 @@ diesel_litres: 0 output: fuel_duty: 529.5 -- name: Fuel duty for 2026-27 carries the January and March 2027 increases +- name: Fuel duty for 2026-27 stays at 52.95p a litre all year period: 2026 absolute_error_margin: 0.01 input: petrol_litres: 1_000 diesel_litres: 0 output: - # 1000 * (0.5295 * 270 + 0.5595 * 59 + 0.5795 * 31 + 0.6003 * 5) / 365 - fuel_duty: 539.5657534246575 -- name: Fuel duty for 2027-28 is the April 2027 RPI-uprated rate + fuel_duty: 529.5 +- name: Fuel duty for 2027-28 carries the 1 July 2027 rise period: 2027 absolute_error_margin: 0.01 input: petrol_litres: 600 diesel_litres: 400 output: - # 1000 * (0.6003 * 361 + 0.6262 * 5) / 366 - fuel_duty: 600.653825136612 + # 1000 * (0.5295 * 86 + 0.6059 * 275 + 0.6321 * 5) / 366 + fuel_duty: 588.3060109289619 - name: Fuel duty for 2028-29 period: 2028 absolute_error_margin: 0.01 @@ -42,8 +42,8 @@ petrol_litres: 1_000 diesel_litres: 0 output: - # 1000 * (0.6262 * 360 + 0.6452 * 5) / 365 - fuel_duty: 626.4602739726027 + # 1000 * (0.6321 * 360 + 0.6513 * 5) / 365 + fuel_duty: 632.3630136986301 - name: Fuel duty for 2029-30 period: 2029 absolute_error_margin: 0.01 @@ -51,8 +51,8 @@ petrol_litres: 1_000 diesel_litres: 0 output: - # 1000 * (0.6452 * 360 + 0.6644 * 5) / 365 - fuel_duty: 645.4630136986301 + # 1000 * (0.6513 * 360 + 0.6707 * 5) / 365 + fuel_duty: 651.5657534246575 - name: Fuel duty for 2030-31 period: 2030 absolute_error_margin: 0.01 @@ -60,8 +60,8 @@ petrol_litres: 1_000 diesel_litres: 0 output: - # 1000 * (0.6644 * 360 + 0.6796 * 5) / 365 - fuel_duty: 664.6082191780822 + # 1000 * (0.6707 * 360 + 0.6861 * 5) / 365 + fuel_duty: 670.9109589041096 - name: Rural relief takes 5p a litre off the 2027-28 rate period: 2027 absolute_error_margin: 0.01 @@ -70,5 +70,5 @@ diesel_litres: 0 in_rural_fuel_duty_relief_area: true output: - # 600.653825136612 - 1000 * 0.05 - fuel_duty: 550.653825136612 + # 588.3060109289619 - 1000 * 0.05 + fuel_duty: 538.3060109289619 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/fuel_duty/road_fuel_gases.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/fuel_duty/road_fuel_gases.yaml index 6684b8b9b1..2b65d23710 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/fuel_duty/road_fuel_gases.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/fuel_duty/road_fuel_gases.yaml @@ -1,6 +1,12 @@ -# Gas quantities are kilograms. FY2026 weights are 270/59/36 days: the -# 5p-equivalent cut runs to 31 Dec 2026, then rates rise on 1 Jan and -# 1 Mar 2027 (SI 2026/164 as amended by SI 2026/555). +# Gas quantities are kilograms. Budget 2026 keeps the 5p-equivalent cut to +# 1 July 2027 (paragraph 6.211; policy costings page 13), so FY2026 is all at +# the cut rates. On 1 July 2027 the full rates return uprated by RPI growth in +# the year to 2027 Q1 (4.5565%): HM Treasury publishes LPG at 33.05p and +# natural gas at 25.82p a kilogram (the uprating gives 25.83p). Each 1 April +# from 2028 applies the previous calendar year's RPI growth, on the OBR's +# October 2026 forecast: LPG 34.48p and natural gas 26.94p in 2028, then +# 35.52p and 27.76p in 2029. +# FY2027 (366 days) weights are 86, 275 and 5 days. # Rural relief only reduces the petrol/diesel component. - name: No road fuel gas consumption period: 2026 @@ -33,7 +39,7 @@ in_rural_fuel_duty_relief_area: true output: fuel_duty: 994 -- name: Fiscal 2026 includes the January and March 2027 gas rate increases +- name: Fiscal 2026 gas rates stay at the cut rates all year period: 2026 absolute_error_margin: 0.01 input: @@ -42,6 +48,29 @@ petrol_litres: 0 diesel_litres: 0 output: - # 1000 * (0.2888 * 270 + 0.3052 * 59 + 0.3161 * 36) / 365 - # + 1000 * (0.2257 * 270 + 0.2385 * 59 + 0.2470 * 36) / 365 - fuel_duty: 524.0134246575342 + # 1000 * (0.2888 + 0.2257) + fuel_duty: 514.5 +- name: Fiscal 2027 gas rates return to the RPI-uprated full rates on 1 July 2027 + period: 2027 + absolute_error_margin: 0.01 + input: + lpg_kg: 1000 + natural_gas_kg: 1000 + petrol_litres: 0 + diesel_litres: 0 + output: + # 1000 * (0.2888 * 86 + 0.3305 * 275 + 0.3448 * 5) / 366 + # + 1000 * (0.2257 * 86 + 0.2582 * 275 + 0.2694 * 5) / 366 + fuel_duty: 571.6133879781421 +- name: Fiscal 2028 gas rates rise by 2027 RPI on 1 April 2028 + period: 2028 + absolute_error_margin: 0.01 + input: + lpg_kg: 1000 + natural_gas_kg: 1000 + petrol_litres: 0 + diesel_litres: 0 + output: + # 1000 * (0.3448 * 360 + 0.3552 * 5) / 365 + # + 1000 * (0.2694 * 360 + 0.2776 * 5) / 365 + fuel_duty: 614.4547945205479 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/property_allowance_deduction.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/property_allowance_deduction.yaml index e7e7daebd7..0b6da00b9f 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/property_allowance_deduction.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/property_allowance_deduction.yaml @@ -13,8 +13,8 @@ property_allowance_deduction: 0 taxable_property_income: 25_000 adjusted_net_income: 25_000 - # (25,000 - 12,570) * 22% (property basic rate from 2027-28) - income_tax: 2_734.60 + # (25,000 - 12,570) * 24% (property basic rate from 2027-28) + income_tax: 2_983.20 - name: Profit within the allowance, receipts unknown, is fully relieved period: 2027 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/trading_allowance_deduction.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/trading_allowance_deduction.yaml index bb1a8391de..7b2bef09c9 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/trading_allowance_deduction.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/trading_allowance_deduction.yaml @@ -1,10 +1,12 @@ # Trading allowance, ITTOIA 2005 Part 6A Chapter 1 (ss. 783A-783AR). # self_employment_income is profit after actual expenses. The allowance is an # alternative to those expenses (s. 783AI; HMRC BIM86050), so it may only add -# the part of the £1,000 that actual deductions have not already used. +# the part of the allowance that actual deductions have not already used. +# The allowance is £1,000 to 2026-27 and £3,000 from 2027-28 (Budget 2026, +# paragraph 6.254); the cases above the 2027-28 block use 2026-27. - name: Sole trader with £25,000 profit gets no allowance on top of expenses - period: 2027 + period: 2026 absolute_error_margin: 0.01 input: self_employment_income: 25_000 @@ -16,7 +18,7 @@ income_tax: 2_486 - name: Profit within the allowance, receipts unknown, is fully relieved - period: 2027 + period: 2026 absolute_error_margin: 0 input: self_employment_income: 600 @@ -25,7 +27,7 @@ taxable_self_employment_income: 0 - name: Profit equal to the allowance, receipts unknown, is fully relieved - period: 2027 + period: 2026 absolute_error_margin: 0 input: self_employment_income: 1_000 @@ -34,7 +36,7 @@ taxable_self_employment_income: 0 - name: Profit above the allowance, receipts unknown, is taxed as reported - period: 2027 + period: 2026 absolute_error_margin: 0 input: self_employment_income: 1_001 @@ -43,7 +45,7 @@ taxable_self_employment_income: 1_001 - name: Full relief when receipts are within the allowance (s. 783AE-783AF) - period: 2027 + period: 2026 absolute_error_margin: 0 input: self_employment_gross_receipts: 800 @@ -53,7 +55,7 @@ taxable_self_employment_income: 0 - name: Partial relief beats £100 of expenses (s. 783AI) - period: 2027 + period: 2026 absolute_error_margin: 0 input: self_employment_gross_receipts: 1_200 @@ -64,7 +66,7 @@ taxable_self_employment_income: 200 - name: Partial relief beats £500 of expenses - period: 2027 + period: 2026 absolute_error_margin: 0 input: self_employment_gross_receipts: 1_200 @@ -74,7 +76,7 @@ taxable_self_employment_income: 200 - name: Actual expenses above the allowance leave no extra relief - period: 2027 + period: 2026 absolute_error_margin: 0 input: self_employment_gross_receipts: 25_000 @@ -84,7 +86,7 @@ taxable_self_employment_income: 20_000 - name: The allowance replaces capital allowances as well as expenses - period: 2027 + period: 2026 absolute_error_margin: 0 input: self_employment_gross_receipts: 1_500 @@ -96,7 +98,7 @@ taxable_self_employment_income: 500 - name: Expenses plus capital allowances above the allowance use the profit method - period: 2027 + period: 2026 absolute_error_margin: 0 input: self_employment_gross_receipts: 1_500 @@ -117,7 +119,7 @@ taxable_self_employment_income: 600 - name: Receipts below profit are inconsistent and treated as unknown - period: 2027 + period: 2026 absolute_error_margin: 0 input: self_employment_gross_receipts: 500 @@ -125,3 +127,66 @@ output: trading_allowance_deduction: 0 taxable_self_employment_income: 1_500 + +# 2027-28: £3,000 from 6 April 2027 (Budget 2026, paragraph 6.254; policy +# costings page 23). + +- name: 2026-27 profit of £2,500, receipts unknown, is taxed as reported + period: 2026 + absolute_error_margin: 0 + input: + self_employment_income: 2_500 + output: + trading_allowance_deduction: 0 + taxable_self_employment_income: 2_500 + +- name: 2027-28 profit of £2,500, receipts unknown, is fully relieved + period: 2027 + absolute_error_margin: 0 + input: + self_employment_income: 2_500 + output: + trading_allowance_deduction: 2_500 + taxable_self_employment_income: 0 + +- name: 2027-28 profit equal to the £3,000 allowance is fully relieved + period: 2027 + absolute_error_margin: 0 + input: + self_employment_income: 3_000 + output: + trading_allowance_deduction: 3_000 + taxable_self_employment_income: 0 + +- name: 2027-28 profit above the £3,000 allowance, receipts unknown, is taxed as reported + period: 2027 + absolute_error_margin: 0 + input: + self_employment_income: 3_001 + output: + trading_allowance_deduction: 0 + taxable_self_employment_income: 3_001 + +- name: 2027-28 partial relief replaces £1,000 of expenses with £3,000 + period: 2027 + absolute_error_margin: 0.01 + input: + employment_income: 30_000 + self_employment_gross_receipts: 5_000 + self_employment_income: 4_000 + output: + trading_allowance_deduction: 2_000 + # 5,000 receipts - 3,000 allowance + taxable_self_employment_income: 2_000 + # (30,000 + 2,000 - 12,570) * 20% + income_tax: 3_886 + +- name: 2027-28 actual expenses above £3,000 leave no extra relief + period: 2027 + absolute_error_margin: 0 + input: + self_employment_gross_receipts: 6_000 + self_employment_income: 2_500 + output: + trading_allowance_deduction: 0 + taxable_self_employment_income: 2_500 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/property_income_band_base.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/property_income_band_base.yaml index 7312c6d5bd..5a7f5ad0fd 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/property_income_band_base.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/property_income_band_base.yaml @@ -94,8 +94,8 @@ members: [person] country: ENGLAND output: - # Property income at the 22% property basic rate: 27,430 x 22% = 6,034.60. - property_income_tax: 6_034.60 + # Property income at the 24% property basic rate: 27,430 x 24% = 6,583.20. + property_income_tax: 6_583.20 dividend_income_tax: 4_528.75 - name: Case 6, the annual allowance charge reads the rate on property income too. diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/property_finance_cost_relief.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/property_finance_cost_relief.yaml index b205bb7efd..9b76545f49 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/property_finance_cost_relief.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/property_finance_cost_relief.yaml @@ -26,7 +26,7 @@ # (30,000 - 12,570) x 20% + 14,000 x 20% - 1,600 income_tax: 4_686 -- name: The same landlord in 2027-28 gets relief at the 22% property basic rate +- name: The same landlord in 2027-28 gets relief at the 24% property basic rate period: 2027 absolute_error_margin: 0.01 input: @@ -35,10 +35,10 @@ property_income: 14_000 property_finance_costs: 8_000 output: - # 22% x 8,000 - property_finance_cost_relief: 1_760 - # (30,000 - 12,570) x 20% + 14,000 x 22% - 1,760 - income_tax: 4_806 + # 24% x 8,000 + property_finance_cost_relief: 1_920 + # (30,000 - 12,570) x 20% + 14,000 x 24% - 1,920 + income_tax: 4_926 - name: A Scottish taxpayer gets relief at the basic rate of 20% in 2025-26 period: 2025 @@ -52,7 +52,7 @@ pays_scottish_income_tax: true property_finance_cost_relief: 1_600 -- name: A Scottish taxpayer also gets the 22% property basic rate in 2027-28 +- name: A Scottish taxpayer also gets the 24% property basic rate in 2027-28 period: 2027 absolute_error_margin: 0.01 input: @@ -62,9 +62,9 @@ region: SCOTLAND output: pays_scottish_income_tax: true - # s. 274AA(5) gives no Scottish rate: 22% x 8,000, although the property + # s. 274AA(5) gives no Scottish rate: 24% x 8,000, although the property # profits themselves are taxed at Scottish rates. - property_finance_cost_relief: 1_760 + property_finance_cost_relief: 1_920 - name: Relief is limited to the property profits and the rest carried forward period: 2025 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/isa/isa_income.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/isa/isa_income.yaml index f7448862b2..c55488b45e 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/isa/isa_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/isa/isa_income.yaml @@ -63,7 +63,9 @@ income_tax: 0 capital_income: 100 market_income: 100 - isa_cash_interest_flat_rate_charge: 22 + # SI 2026/1018 reg. 15 (new reg. 22A(3)): the savings basic rate, 24% in + # 2027-28, on 100. + isa_cash_interest_flat_rate_charge: 24 - name: Noncash cash interest is an of-which detail of an explicitly supplied ISA total period: 2027 @@ -75,7 +77,8 @@ taxable_savings_interest_income: 0 capital_income: 500 market_income: 500 - isa_cash_interest_flat_rate_charge: 22 + # 24% savings basic rate on the 100 of cash interest. + isa_cash_interest_flat_rate_charge: 24 - name: Tax Credits and total taxable income exclude ISA returns without losing ordinary interest or dividends diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/vat/domestic_energy_vat.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/vat/domestic_energy_vat.yaml index ed9b9da39f..31fec255b9 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/vat/domestic_energy_vat.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/vat/domestic_energy_vat.yaml @@ -1,7 +1,11 @@ -# Temporary GB zero rate on domestic electricity, 1 October 2026 to -# 31 March 2027 (SI 2026/987). £1,050 of bills carries £50 of VAT at 5%. -# FY 2026-27 has 183 days at 5% and 182 at 0%: 50 * 183 / 365 = 25.07. -- name: GB electricity in FY 2026-27 is zero-rated for 182 of 365 days +# Temporary zero rate on domestic electricity: Great Britain from 1 October +# 2026 (SI 2026/987), extended by Budget 2026 to 1 July 2027 and expected to +# apply in Northern Ireland from 1 April 2027 (paragraph 6.210). £1,050 of +# bills carries £50 of VAT at 5%. +# FY 2026-27: GB 178 days at 5% and 187 at 0%: 50 * 178 / 365 = 24.38; +# NI 360 days at 5% and 5 at 0%: 50 * 360 / 365 = 49.32. +# FY 2027-28: every nation 86 days at 0% and 280 at 5%: 50 * 280 / 366 = 38.25. +- name: GB electricity in FY 2026-27 is zero-rated for 187 of 365 days period: 2026 absolute_error_margin: 0.01 input: @@ -9,9 +13,9 @@ electricity_consumption: 1_050 gas_consumption: 0 output: - domestic_energy_vat: 25.07 + domestic_energy_vat: 24.38 -- name: Northern Ireland electricity stays at 5% +- name: Northern Ireland electricity is zero-rated from 1 April 2027 period: 2026 absolute_error_margin: 0.01 input: @@ -19,7 +23,7 @@ electricity_consumption: 1_050 gas_consumption: 0 output: - domestic_energy_vat: 50 + domestic_energy_vat: 49.32 - name: Gas is not affected by the electricity zero rate period: 2026 @@ -31,9 +35,29 @@ output: domestic_energy_vat: 50 -- name: GB electricity is back at 5% in FY 2027-28 +- name: GB electricity in FY 2027-28 is zero-rated to 30 June 2027 + period: 2027 + absolute_error_margin: 0.01 + input: + region: LONDON + electricity_consumption: 1_050 + gas_consumption: 0 + output: + domestic_energy_vat: 38.25 + +- name: Northern Ireland electricity in FY 2027-28 is zero-rated to 30 June 2027 period: 2027 absolute_error_margin: 0.01 + input: + region: NORTHERN_IRELAND + electricity_consumption: 1_050 + gas_consumption: 0 + output: + domestic_energy_vat: 38.25 + +- name: Electricity is back at 5% in FY 2028-29 + period: 2028 + absolute_error_margin: 0.01 input: region: LONDON electricity_consumption: 1_050 diff --git a/policyengine_uk/tests/policy/baseline/gov/ofgem/warm_home_discount.yaml b/policyengine_uk/tests/policy/baseline/gov/ofgem/warm_home_discount.yaml index 39f563113d..56e8d78b02 100644 --- a/policyengine_uk/tests/policy/baseline/gov/ofgem/warm_home_discount.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/ofgem/warm_home_discount.yaml @@ -291,8 +291,9 @@ council_tax: 0 output: warm_home_discount: 150 - hbai_household_net_income: 1150 - hbai_household_net_income_ahc: 1150 + # Also the £100 Household Energy Payment: band D (the default) in England. + hbai_household_net_income: 1250 + hbai_household_net_income_ahc: 1250 - name: No rebate in HBAI income for a Universal Credit recipient in Scotland period: 2026 @@ -341,8 +342,9 @@ council_tax: 0 output: warm_home_discount: 0 - hbai_household_net_income: 1000 - hbai_household_net_income_ahc: 1000 + # Only the £100 Household Energy Payment: band D (the default) in England. + hbai_household_net_income: 1100 + hbai_household_net_income_ahc: 1100 - name: Universal Credit recipient in England, 2030-31, the last scheme year period: 2030 diff --git a/policyengine_uk/tests/policy/baseline/gov/treasury/household_energy_payment/household_energy_payment.yaml b/policyengine_uk/tests/policy/baseline/gov/treasury/household_energy_payment/household_energy_payment.yaml new file mode 100644 index 0000000000..490b668f9f --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/treasury/household_energy_payment/household_energy_payment.yaml @@ -0,0 +1,102 @@ +# Household Energy Payment (Budget 2026, paragraph 6.24): £100 for +# households in council tax bands A to D in England, delivered from +# January 2027, so in model year 2026 (6 April 2026 to 5 April 2027). + +- name: Band A household in England gets £100 in 2026-27 + period: 2026 + absolute_error_margin: 0 + input: + region: NORTH_EAST + council_tax_band: A + output: + household_energy_payment: 100 + +- name: Band D household in England gets £100 in 2026-27 + period: 2026 + absolute_error_margin: 0 + input: + region: LONDON + council_tax_band: D + output: + household_energy_payment: 100 + +- name: Band E household in England gets nothing + period: 2026 + absolute_error_margin: 0 + input: + region: SOUTH_EAST + council_tax_band: E + output: + household_energy_payment: 0 + +- name: Band H household in England gets nothing + period: 2026 + absolute_error_margin: 0 + input: + region: LONDON + council_tax_band: H + output: + household_energy_payment: 0 + +- name: Band B household in Wales gets nothing + period: 2026 + absolute_error_margin: 0 + input: + region: WALES + council_tax_band: B + output: + household_energy_payment: 0 + +- name: Band B household in Scotland gets nothing + period: 2026 + absolute_error_margin: 0 + input: + region: SCOTLAND + council_tax_band: B + output: + household_energy_payment: 0 + +- name: Household in Northern Ireland gets nothing + period: 2026 + absolute_error_margin: 0 + input: + region: NORTHERN_IRELAND + output: + household_energy_payment: 0 + +- name: No payment in 2025-26 + period: 2025 + absolute_error_margin: 0 + input: + region: NORTH_WEST + council_tax_band: A + output: + household_energy_payment: 0 + +- name: No payment in 2027-28 + period: 2027 + absolute_error_margin: 0 + input: + region: NORTH_WEST + council_tax_band: A + output: + household_energy_payment: 0 + +- name: The payment counts in household benefits and government spending + period: 2026 + absolute_error_margin: 0.01 + input: + people: + adult: + age: 40 + # Earnings high enough to rule out means-tested benefits. + employment_income: 100_000 + households: + household: + members: [adult] + region: EAST_MIDLANDS + council_tax_band: C + output: + household_energy_payment: 100 + household_benefits: 100 + gov_spending: 100 diff --git a/policyengine_uk/tests/policy/baseline/household/income/hbai_property_income.yaml b/policyengine_uk/tests/policy/baseline/household/income/hbai_property_income.yaml index d097928079..a977122b06 100644 --- a/policyengine_uk/tests/policy/baseline/household/income/hbai_property_income.yaml +++ b/policyengine_uk/tests/policy/baseline/household/income/hbai_property_income.yaml @@ -7,7 +7,9 @@ # income tax (ITTOIA 2005 s. 272A(4)) and are relieved by a tax reduction at # the basic rate instead (s. 274A). 2026-27: personal allowance £12,570, # basic rate 20% on the first £37,700 of taxable income, employee NI 8% -# from £12,570 to £50,270. +# from £12,570 to £50,270. Each household also gets the £100 Household Energy +# Payment (band D, the default, in England), which HBAI counts as +# miscellaneous income. - name: A landlord's mortgage interest is deducted from market and HBAI income period: 2026 @@ -26,8 +28,8 @@ income_tax: 6_686 # (40,000 - 12,570) x 8%. national_insurance: 2_194.40 - # 46,000 - 6,686 - 2,194.40. - hbai_household_net_income: 37_119.60 + # 46,000 - 6,686 - 2,194.40 + 100. + hbai_household_net_income: 37_219.60 - name: Finance costs above the profit leave the landlord with the income of a non-landlord period: 2026 @@ -59,8 +61,8 @@ # Landlord: (43,000 - 12,570) x 20% = 6,086, less 20% x min(8,000, # 3,000) = 600. Employee: (40,000 - 12,570) x 20%. income_tax: [5_486, 5_486] - # 40,000 - 5,486 - 2,194.40 for both. - hbai_household_net_income: [32_319.60, 32_319.60] + # 40,000 - 5,486 - 2,194.40 + 100 for both. + hbai_household_net_income: [32_419.60, 32_419.60] - name: Interest on the home is a housing cost and interest on a let property is not period: 2026 @@ -77,10 +79,10 @@ members: [person1] mortgage_interest_repayment: 3_000 output: - # As the first case: 46,000 - 6,686 - 2,194.40. - hbai_household_net_income: 37_119.60 + # As the first case: 46,000 - 6,686 - 2,194.40 + 100. + hbai_household_net_income: 37_219.60 # Less the 3,000 of interest on the household's own mortgage only. - hbai_household_net_income_ahc: 34_119.60 + hbai_household_net_income_ahc: 34_219.60 - name: Joint landlords each count their share of the profit after interest period: 2026 @@ -111,5 +113,5 @@ income_tax: [4_086, 4_086] # Each: (30,000 - 12,570) x 8%. national_insurance: [1_394.40, 1_394.40] - # 66,000 - 2 x 4,086 - 2 x 1,394.40. - hbai_household_net_income: 55_039.20 + # 66,000 - 2 x 4,086 - 2 x 1,394.40 + 100. + hbai_household_net_income: 55_139.20 diff --git a/policyengine_uk/tests/policy/baseline/household/income/household_benefits.yaml b/policyengine_uk/tests/policy/baseline/household/income/household_benefits.yaml index 3bc7dfce22..afdfb33bd6 100644 --- a/policyengine_uk/tests/policy/baseline/household/income/household_benefits.yaml +++ b/policyengine_uk/tests/policy/baseline/household/income/household_benefits.yaml @@ -22,8 +22,9 @@ members: [jobseeker] output: jsa_contrib: 4_000 - household_benefits: 4_000 - household_gross_income: 4_000 + # Plus the £100 Household Energy Payment: band D (the default) in England. + household_benefits: 4_100 + household_gross_income: 4_100 - name: Both partners' contributory JSA each count once period: 2026 @@ -45,8 +46,9 @@ members: [partner_1, partner_2] output: jsa_contrib: [2_000, 3_000] - household_benefits: 5_000 - household_gross_income: 5_000 + # Plus the £100 Household Energy Payment: band D (the default) in England. + household_benefits: 5_100 + household_gross_income: 5_100 - name: The benefit uprating reform uprates contributory JSA once period: 2026 @@ -65,5 +67,6 @@ household: members: [jobseeker] output: - # 4,000 plus 10% of it. - household_benefits: 4_400 + # 4,000 plus the £100 Household Energy Payment (band D, the default, in + # England), plus 10% of both: the reform uprates every general benefit. + household_benefits: 4_510 diff --git a/policyengine_uk/tests/policy/baseline/household/income/other_investment_income_in_household_income.yaml b/policyengine_uk/tests/policy/baseline/household/income/other_investment_income_in_household_income.yaml index 16ebc9e59f..1e0c707107 100644 --- a/policyengine_uk/tests/policy/baseline/household/income/other_investment_income_in_household_income.yaml +++ b/policyengine_uk/tests/policy/baseline/household/income/other_investment_income_in_household_income.yaml @@ -4,7 +4,9 @@ # interest. 2026-27: personal allowance £12,570, basic rate 20%, personal # savings allowance £1,000 for a basic rate taxpayer, starting rate for savings # £5,000 less non-savings income above the personal allowance (here nil), -# employee NI 8% from £12,570 to £50,270, TV licence £180. +# employee NI 8% from £12,570 to £50,270, TV licence £180, and the £100 +# Household Energy Payment (band D, the default, in England), which gross, +# net and HBAI income count as a benefit. - name: Other investment income counts in market, capital, net and HBAI income period: 2026 @@ -17,17 +19,18 @@ # 40,000 + 2,000. market_income: 42_000 household_market_income: 42_000 - household_gross_income: 42_000 + # Plus the 100 Household Energy Payment. + household_gross_income: 42_100 capital_income: 2_000 # (40,000 - 12,570) x 20% = 5,486, plus (2,000 - 1,000 PSA) x 20% = 200. income_tax: 5_686 # (40,000 - 12,570) x 8%. national_insurance: 2_194.40 - # 42,000 - 5,686 - 2,194.40. - hbai_household_net_income: 34_119.60 - # Less the 180 TV licence. Without the interest it is 32,139.60, so the + # 42,100 - 5,686 - 2,194.40. + hbai_household_net_income: 34_219.60 + # Less the 180 TV licence. Without the interest it is 32,239.60, so the # interest raises net income by 2,000 less its 200 of tax. - household_net_income: 33_939.60 + household_net_income: 34_039.60 - name: The same amount as bank interest gives the same incomes period: 2026 @@ -39,9 +42,9 @@ output: market_income: 42_000 household_market_income: 42_000 - household_gross_income: 42_000 + household_gross_income: 42_100 capital_income: 2_000 income_tax: 5_686 national_insurance: 2_194.40 - hbai_household_net_income: 34_119.60 - household_net_income: 33_939.60 + hbai_household_net_income: 34_219.60 + household_net_income: 34_039.60 diff --git a/policyengine_uk/tests/policy/baseline/household/income/trading_loss_household_income.yaml b/policyengine_uk/tests/policy/baseline/household/income/trading_loss_household_income.yaml index 23595bcb87..967c594367 100644 --- a/policyengine_uk/tests/policy/baseline/household/income/trading_loss_household_income.yaml +++ b/policyengine_uk/tests/policy/baseline/household/income/trading_loss_household_income.yaml @@ -37,6 +37,7 @@ members: [without_loss] output: income_tax: [3_486, 5_486] - # -10,000 of loss + 2,000 of Income Tax saved. - hbai_household_net_income: [24_320, 32_320] - household_net_income: [24_140, 32_140] + # -10,000 of loss + 2,000 of Income Tax saved. Both include the £100 + # Household Energy Payment (band D, the default, in England). + hbai_household_net_income: [24_420, 32_420] + household_net_income: [24_240, 32_240] diff --git a/policyengine_uk/tests/policy/integration/income.yaml b/policyengine_uk/tests/policy/integration/income.yaml index 798953e25f..391dcf83fb 100644 --- a/policyengine_uk/tests/policy/integration/income.yaml +++ b/policyengine_uk/tests/policy/integration/income.yaml @@ -49,10 +49,11 @@ savings_allowance: 500 basic_rate_savings_income: 0 higher_rate_savings_income: 500 - savings_income_tax: 210 + # 500 x 44% savings higher rate (2027-28) + savings_income_tax: 220 taxed_dividend_income: 500 dividend_income_tax: 178.75 - income_tax: 7_874.75 + income_tax: 7_884.75 - name: Personal allowance caps savings starter rate before dividends. period: 2027 diff --git a/policyengine_uk/tests/policy/reforms/nov_2025_budget/income_source_tax_rates.yaml b/policyengine_uk/tests/policy/reforms/nov_2025_budget/income_source_tax_rates.yaml index 31a39110fa..991f840442 100644 --- a/policyengine_uk/tests/policy/reforms/nov_2025_budget/income_source_tax_rates.yaml +++ b/policyengine_uk/tests/policy/reforms/nov_2025_budget/income_source_tax_rates.yaml @@ -16,6 +16,10 @@ # - Basic rate: 20% -> 22% (+2pp) # - Higher rate: 40% -> 42% (+2pp) # - Additional rate: 45% -> 47% (+2pp) +# +# Budget 2026 (paragraphs 6.246 and 6.247) adds a further 2pp to the savings +# and property rates from the same date, so current law from 2027-28 is +# 24%, 44% and 49%; the post-reform cases below use those rates. # ============================================================================= # DIVIDEND TAX RATE TESTS @@ -103,7 +107,7 @@ # Additional rate taxpayers get £0 savings allowance, so £2000 at 45% savings_income_tax: 2000 * 0.45 -# Test savings rates AFTER April 2027 (should be +2pp: 22/42/47) +# Test savings rates AFTER April 2027 (+4pp from Budgets 2025 and 2026: 24/44/49) - name: Savings tax at higher rate - 2028 (post-reform) period: 2028 absolute_error_margin: 1 @@ -111,8 +115,8 @@ employment_income: 60000 savings_interest_income: 2000 output: - # £1500 at NEW rate 42% - savings_income_tax: 1500 * 0.42 + # £1500 at NEW rate 44% + savings_income_tax: 1500 * 0.44 - name: Savings tax at additional rate - 2028 (post-reform) period: 2028 @@ -121,8 +125,8 @@ employment_income: 200000 savings_interest_income: 2000 output: - # £2000 at NEW rate 47% - savings_income_tax: 2000 * 0.47 + # £2000 at NEW rate 49% + savings_income_tax: 2000 * 0.49 # ============================================================================= # PROPERTY INCOME TAX RATE TESTS @@ -154,7 +158,7 @@ # has already used up basic rate band property_income_tax: 1000 * 0.40 -# Test property rates AFTER April 2027 (should be +2pp: 22/42/47) +# Test property rates AFTER April 2027 (+4pp from Budgets 2025 and 2026: 24/44/49) - name: Property tax at basic rate - 2028 (post-reform) period: 2028 absolute_error_margin: 1 @@ -163,8 +167,8 @@ property_rental_income: 2000 property_income: 2000 output: - # £1000 at NEW rate 22% - property_income_tax: 1000 * 0.22 + # £1000 at NEW rate 24% + property_income_tax: 1000 * 0.24 - name: Property tax at higher rate - 2028 (post-reform) period: 2028 @@ -174,8 +178,8 @@ property_rental_income: 2000 property_income: 2000 output: - # £1000 at NEW rate 42% - property_income_tax: 1000 * 0.42 + # £1000 at NEW rate 44% + property_income_tax: 1000 * 0.44 - name: Property tax at additional rate - 2028 (post-reform) period: 2028 @@ -185,8 +189,8 @@ property_rental_income: 2000 property_income: 2000 output: - # £1000 at NEW rate 47% - property_income_tax: 1000 * 0.47 + # £1000 at NEW rate 49% + property_income_tax: 1000 * 0.49 # ============================================================================= # INTEGRATION TESTS - verify income_tax includes source-specific taxes @@ -202,11 +206,11 @@ property_rental_income: 2000 property_income: 2000 output: - # Property allowance is £1000, so £1000 taxable at 22% - property_income_tax: 220 + # Property allowance is £1000, so £1000 taxable at 24% + property_income_tax: 240 # Total income_tax should include property_income_tax - # Employment: £0 (covered by PA), Property: £220 - income_tax: 220 + # Employment: £0 (covered by PA), Property: £240 + income_tax: 240 - name: Property tax flows into income_tax - higher rate - 2028 period: 2028 @@ -216,16 +220,16 @@ property_rental_income: 2000 property_income: 2000 output: - # £1000 at 42% - property_income_tax: 420 + # £1000 at 44% + property_income_tax: 440 # income_tax = earned_income_tax + savings_income_tax + dividend_income_tax + property_income_tax # Earned income tax on £60000: PA=£12570, taxable=£47430 # Basic rate (20%): £37700 at 20% = £7540 # Higher rate (40%): £9730 at 40% = £3892 # Total earned: £11432 - # Property: £420 - # Total: £11852 - income_tax: 11852 + # Property: £440 + # Total: £11872 + income_tax: 11872 - name: Savings tax flows into income_tax - higher rate - 2028 period: 2028 @@ -234,9 +238,9 @@ employment_income: 60000 savings_interest_income: 2000 output: - # £1500 at 42% (after £500 savings allowance) - savings_income_tax: 630 + # £1500 at 44% (after £500 savings allowance) + savings_income_tax: 660 # Earned: £11432 (same as above) - # Savings: £630 - # Total: £12062 - income_tax: 12062 + # Savings: £660 + # Total: £12092 + income_tax: 12092 diff --git a/policyengine_uk/tests/policy/reforms/oct_2026_budget/carers_allowance_earnings_taper.yaml b/policyengine_uk/tests/policy/reforms/oct_2026_budget/carers_allowance_earnings_taper.yaml new file mode 100644 index 0000000000..e80b0bab07 --- /dev/null +++ b/policyengine_uk/tests/policy/reforms/oct_2026_budget/carers_allowance_earnings_taper.yaml @@ -0,0 +1,34 @@ +# Budget 2026 Carer's Allowance earnings taper (paragraph 6.13; policy +# costings page 16), from 6 October 2030 until DWP confirms the date. The +# amounts the taper pays follow the projected weekly rate, so +# test_carers_allowance_earnings_taper.py checks them against the parameter +# tree. These cases check what holds whatever the rate. +# +# A supplied carers_allowance_pre_overlap carries no dates, so the whole +# year's overlapping benefits come off it, as before the taper, even where the +# earnings condition says the allowance is paid for part of the year or none +# of it (Overlapping Benefits Regulations 1979 regs 4 and 12). + +- name: 2030-31, Northern Ireland, a supplied award meets the whole year's State Pension. + period: 2030 + absolute_error_margin: 0.01 + input: + age: 70 + carers_allowance_pre_overlap: 4_495.40 + carers_allowance_earnings: 13_000 + state_pension: 6_000 + country: NORTHERN_IRELAND + output: + carers_allowance: 0 + +- name: 2030-31, England, a supplied award over the limit meets the whole year's State Pension. + period: 2030 + absolute_error_margin: 0.01 + input: + age: 70 + carers_allowance_pre_overlap: 4_495.40 + carers_allowance_earnings: 13_000 + state_pension: 2_000 + country: ENGLAND + output: + carers_allowance: 2_495.40 diff --git a/policyengine_uk/tests/policy/reforms/oct_2026_budget/pension_tax_allowance.yaml b/policyengine_uk/tests/policy/reforms/oct_2026_budget/pension_tax_allowance.yaml new file mode 100644 index 0000000000..11fd8f44bf --- /dev/null +++ b/policyengine_uk/tests/policy/reforms/oct_2026_budget/pension_tax_allowance.yaml @@ -0,0 +1,207 @@ +# Budget 2026 Pension Tax Allowance (Table 6.1, item 52; paragraph 6.182). +# +# A non-refundable Income Tax reduction for people over State Pension age, or +# reaching it within the tax year, from 6 April 2027 to 5 April 2030. The full +# allowance is 20% of the full new State Pension above the Personal Allowance, +# rounded up to the pound, and it tapers by 1 for every 3 of total income +# above the full new State Pension. +# +# The full new State Pension is 52 weeks at the weekly rate. The model +# projects the April 2027 rate unrounded, 241.30 x 1.039 = 250.71, so the +# threshold is 13,036.97, where HM Treasury's explainer gives 13,036.40 +# (250.70 a week). The full allowance is 94 on either: 20% x (13,036.97 - +# 12,570) = 93.39. The allowance runs out at 13,036.973 + 3 x 94 = +# 13,318.973, so 13,318.98 is the first income in whole pence with none (the +# explainer's "13,319 or more"). +# +# The first five cases are the worked examples in HM Treasury's Pension Tax +# Allowance explainer, on its inputs. Jonathan's allowance is 19p more than +# the explainer's 44 because the threshold is 57p higher. + +- name: Tom, below the Personal Allowance, has no tax to remove + period: 2027 + absolute_error_margin: 0.01 + input: + age: 75 + state_pension: 10_300 + output: + pension_tax_allowance: 0 + income_tax: 0 + +- name: Steph, on the full new State Pension, has her 93.28 of tax removed + period: 2027 + absolute_error_margin: 0.01 + input: + age: 70 + state_pension: 13_036.40 + output: + # (13,036.40 - 12,570) x 20% + income_tax_pre_charges: 93.28 + pension_tax_allowance: 93.28 + income_tax: 0 + +- name: Anne, on a partial State Pension and a private pension, has her 80 removed + period: 2027 + absolute_error_margin: 0.01 + input: + age: 72 + state_pension: 9_000 + private_pension_income: 3_970 + output: + # (12,970 - 12,570) x 20%; the 94 allowance is capped at the tax due + income_tax_pre_charges: 80 + pension_tax_allowance: 80 + income_tax: 0 + +- name: Jonathan, deferring his pension with income in the taper, gets 44.19 + period: 2027 + absolute_error_margin: 0.01 + input: + age: 68 + employment_income: 12_000 + property_income: 1_186.40 + output: + total_income: 13_186.40 + # 94 - (13,186.40 - 13,036.97) / 3; the explainer, on 13,036.40, gives 44 + pension_tax_allowance: 44.19 + # The Personal Allowance covers the salary and 570 of the property + # profit; 616.40 x 24% (the Budget 2026 property basic rate) = 147.94 + income_tax_pre_charges: 147.94 + income_tax: 103.75 + +- name: Nadia, above 13,319, gets no allowance + period: 2027 + absolute_error_margin: 0.01 + input: + age: 71 + # The explainer's Nadia also has some savings income. The 166 it quotes + # is 20% of everything above the Personal Allowance, so this case puts + # all 13,400 in her private pension: savings inside the starting rate for + # savings would bring her tax below 166. + private_pension_income: 13_400 + output: + pension_tax_allowance: 0 + # (13,400 - 12,570) x 20% + income_tax: 166 + +- name: The allowance runs out at three times the full allowance above the threshold + period: 2027 + absolute_error_margin: 0.01 + input: + age: 70 + # Just above 13,036.97 + 3 x 94 + state_pension: 13_318.98 + output: + pension_tax_allowance: 0 + # 748.98 x 20% + income_tax: 149.80 + +- name: A pound before the end of the taper leaves a third of a pound + period: 2027 + absolute_error_margin: 0.01 + input: + age: 70 + state_pension: 13_317.98 + output: + # 94 - (13,317.98 - 13,036.97) / 3 + pension_tax_allowance: 0.33 + # 747.98 x 20% = 149.60, less the allowance + income_tax: 149.27 + +- name: Scottish taxpayers get the allowance at 20%, capped at their starter-rate tax + period: 2027 + absolute_error_margin: 0.01 + input: + age: 70 + state_pension: 13_036.40 + region: SCOTLAND + output: + pays_scottish_income_tax: true + # 466.40 x 19% starter rate + income_tax_pre_charges: 88.62 + pension_tax_allowance: 88.62 + income_tax: 0 + +- name: In the taper, a Scottish taxpayer's allowance is still worked out at 20% + period: 2027 + absolute_error_margin: 0.01 + input: + age: 70 + private_pension_income: 13_186.40 + region: SCOTLAND + output: + # 94 - 149.43 / 3; at the 19% starter rate the full allowance would have + # been 89, leaving 39.19 + pension_tax_allowance: 44.19 + # 616.40 x 19% = 117.12 + income_tax: 72.93 + +- name: Someone under State Pension age with the same income gets nothing + period: 2027 + absolute_error_margin: 0.01 + input: + age: 60 + employment_income: 13_036.40 + output: + pension_tax_allowance: 0 + income_tax_pre_charges: 93.28 + +- name: Reaching State Pension age on the last day of the tax year qualifies + period: 2027 + absolute_error_margin: 0.01 + input: + # State Pension age is 67 for people born after 5 March 1961, so this + # person reaches it on 5 April 2028 + age: 66 + date_of_birth: 19610405 + state_pension: 13_036.40 + output: + is_SP_age: false + pension_tax_allowance: 93.28 + income_tax: 0 + +- name: Reaching State Pension age on the first day of the next tax year does not + period: 2027 + absolute_error_margin: 0.01 + input: + # Reaches 67 on 6 April 2028, in 2028-29 + age: 66 + date_of_birth: 19610406 + state_pension: 13_036.40 + output: + pension_tax_allowance: 0 + income_tax: 93.28 + +- name: There is no allowance in 2026-27 + period: 2026 + absolute_error_margin: 0.01 + input: + age: 70 + state_pension: 13_036.40 + output: + pension_tax_allowance: 0 + income_tax: 93.28 + +- name: There is no allowance in 2030-31, after it ends on 5 April 2030 + period: 2030 + absolute_error_margin: 0.01 + input: + age: 70 + private_pension_income: 13_000 + output: + pension_tax_allowance: 0 + income_tax: 86 + +- name: In 2028-29 the threshold follows the new State Pension and the allowance is recomputed + period: 2028 + absolute_error_margin: 0.01 + input: + age: 70 + # The threshold is 52 weeks of the April 2028 new State Pension: 13,480.26 + # on the October 2026 forecast, so 20% x (13,480.26 - 12,570) = 182.05 and + # the full allowance is 183. 300 above the threshold takes 100 off it. + private_pension_income: 13_780.26 + output: + pension_tax_allowance: 83 + # 1,210.26 x 20% = 242.05, less the allowance + income_tax: 159.05 diff --git a/policyengine_uk/tests/policy/reforms/oct_2026_budget/savings_property_income_rates.yaml b/policyengine_uk/tests/policy/reforms/oct_2026_budget/savings_property_income_rates.yaml new file mode 100644 index 0000000000..9c08e7ed34 --- /dev/null +++ b/policyengine_uk/tests/policy/reforms/oct_2026_budget/savings_property_income_rates.yaml @@ -0,0 +1,135 @@ +# Budget 2026 savings and property income rates (Table 6.1, items 61 and 62). +# +# Budget 2025 (Finance Act 2026) set the savings and property rates at 22%, +# 42% and 47% from 6 April 2027. Budget 2026 adds a further 2 percentage +# points from the same date (paragraphs 6.246 and 6.247): +# - property: 24%, 44% and 49% across England, Wales and Northern Ireland; +# Scottish taxpayers' property income stays on the Scottish rates; +# - savings: 24%, 44% and 49% across the UK; +# - finance cost relief stays at the property basic rate (policy costings, +# page 74), so it rises to 24%. + +- name: 2026-27 savings rates are unchanged at 40% for a higher-rate taxpayer + period: 2026 + absolute_error_margin: 0.01 + input: + employment_income: 60_000 + savings_interest_income: 2_000 + output: + # 500 Personal Savings Allowance, then 1,500 x 40% + savings_income_tax: 600 + +- name: 2027-28 savings basic rate is 24% + period: 2027 + absolute_error_margin: 0.01 + input: + employment_income: 20_000 + savings_interest_income: 3_000 + output: + # Non-savings income above the starter-rate limit, 1,000 Personal Savings + # Allowance, then 2,000 x 24% + savings_income_tax: 480 + +- name: 2027-28 savings higher rate is 44% + period: 2027 + absolute_error_margin: 0.01 + input: + employment_income: 60_000 + savings_interest_income: 2_000 + output: + # 500 Personal Savings Allowance, then 1,500 x 44% + savings_income_tax: 660 + +- name: 2027-28 savings additional rate is 49% + period: 2027 + absolute_error_margin: 0.01 + input: + employment_income: 200_000 + savings_interest_income: 2_000 + output: + # No Personal Savings Allowance for additional-rate taxpayers: 2,000 x 49% + savings_income_tax: 980 + +- name: 2027-28 savings rates apply to Scottish taxpayers too + period: 2027 + absolute_error_margin: 0.01 + input: + employment_income: 30_000 + savings_interest_income: 2_000 + region: SCOTLAND + output: + pays_scottish_income_tax: true + # Savings bands follow the UK thresholds: 1,000 Personal Savings + # Allowance, then 1,000 x 24% + savings_income_tax: 240 + +- name: 2027-28 property basic rate is 24% in England + period: 2027 + absolute_error_margin: 0.01 + input: + employment_income: 20_000 + property_rental_income: 5_000 + property_income: 5_000 + region: LONDON + output: + # (5,000 - 1,000 property allowance) x 24% + property_income_tax: 960 + +- name: 2027-28 property basic rate is 24% in Wales + period: 2027 + absolute_error_margin: 0.01 + input: + employment_income: 20_000 + property_rental_income: 5_000 + property_income: 5_000 + region: WALES + output: + property_income_tax: 960 + +- name: 2027-28 property basic rate is 24% in Northern Ireland + period: 2027 + absolute_error_margin: 0.01 + input: + employment_income: 20_000 + property_rental_income: 5_000 + property_income: 5_000 + region: NORTHERN_IRELAND + output: + property_income_tax: 960 + +- name: 2027-28 property higher and additional rates are 44% and 49% + period: 2027 + absolute_error_margin: 0.01 + input: + employment_income: 200_000 + property_rental_income: 2_000 + property_income: 2_000 + output: + # Earnings already fill the additional-rate band: 1,000 x 49% + property_income_tax: 490 + +- name: 2027-28 property income straddling the higher-rate threshold + period: 2027 + absolute_error_margin: 0.01 + input: + employment_income: 45_000 + property_income: 10_000 + output: + # Earnings use 32,430 of the 37,700 basic-rate band, leaving 5,270. + # 10,000 profit with receipts unknown: no property allowance on top. + # 5,270 x 24% + 4,730 x 44% = 1,264.80 + 2,081.20 + property_income_tax: 3_346 + +- name: 2027-28 finance cost relief rises with the property basic rate + period: 2027 + absolute_error_margin: 0.01 + input: + employment_income: 30_000 + property_rental_income: 20_000 + property_income: 14_000 + property_finance_costs: 8_000 + output: + # 24% x 8,000 + property_finance_cost_relief: 1_920 + # (30,000 - 12,570) x 20% + 14,000 x 24% - 1,920 + income_tax: 4_926 diff --git a/policyengine_uk/tests/test_budget_2026_savings_property_rates_properties.py b/policyengine_uk/tests/test_budget_2026_savings_property_rates_properties.py new file mode 100644 index 0000000000..09c62063dd --- /dev/null +++ b/policyengine_uk/tests/test_budget_2026_savings_property_rates_properties.py @@ -0,0 +1,252 @@ +"""Property tests for the Budget 2026 savings and property income rates. + +Budget 2026 adds 2 percentage points to the savings and property rates from +6 April 2027 (paragraphs 6.246 and 6.247), on top of Budget 2025's 22%, 42% +and 47%. Each test compares current law with the Budget 2025 rates for the +same generated people, and checks identities that hold for every input +whose property allowance election is the same under both sets of rates: + +- rUK property tax rises by exactly 2% of taxable property income, since every + pound of it sits in one of the three rate bands; +- Scottish taxpayers' property tax does not change, because their property + income stays on the Scottish rates; +- savings tax rises by exactly 2% of the savings income in the basic, higher + and additional bands, in every UK country; +- finance cost relief, which follows the property basic rate, never falls, + and rises by at most 2% of the costs relieved wherever the Budget 2025 + relief was its full 22%. Where the tax left after earlier reductions + capped it, the relief can rise by more: the higher rates leave more tax + for it (test_capped_finance_cost_relief_can_rise_by_more_than_two_points). + +The election itself can change. The higher relief can make it worth giving up +the property allowance to deduct expenses and claim the relief, and then +property tax and the relief both move +(test_higher_relief_can_make_giving_up_the_property_allowance_worthwhile). +The model makes that choice only where it lowers the person's income tax. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +YEAR = 2027 +RISE = 0.02 +REGIONS = ["LONDON", "WALES", "NORTHERN_IRELAND", "SCOTLAND"] +CASES = 24 +BUDGET_2025_RATES = { + f"gov.hmrc.income_tax.rates.{source}.{band}": {str(YEAR): rate} + for source in ("savings", "property") + for band, rate in (("basic", 0.22), ("higher", 0.42), ("additional", 0.47)) +} + +amount = st.one_of( + st.floats(min_value=0, max_value=60_000), + st.floats(min_value=0, max_value=300_000), + st.sampled_from([0.0, 1_000.0, 12_570.0, 37_700.0, 50_270.0, 125_140.0]), +) +case = st.tuples( + amount, # employment income + amount, # property profit + st.floats(min_value=0, max_value=1), # finance costs as a share of profit + st.one_of(st.floats(min_value=0, max_value=20_000), amount), # savings +) +cases = st.lists(case, min_size=CASES, max_size=CASES) +# Review round 2's counterexample: the fourth person (Scotland) gives up the +# property allowance at the higher rates. +ELECTION_CHANGE = [(0.0, 0.0, 0.0, 0.0)] * CASES +ELECTION_CHANGE[3] = (20_000.0, 500.0, 0.87, 0.0) + + +def situation(): + return { + "people": { + f"person_{i}": {"age": {YEAR: 45}, "employment_income": {YEAR: 0}} + for i in range(CASES) + }, + "benunits": { + f"benunit_{i}": {"members": [f"person_{i}"]} for i in range(CASES) + }, + "households": { + f"household_{i}": { + "members": [f"person_{i}"], + "region": {YEAR: REGIONS[i % len(REGIONS)]}, + } + for i in range(CASES) + }, + } + + +@pytest.fixture(scope="module") +def sims(): + return Simulation(situation=situation()), Simulation( + situation=situation(), reform=BUDGET_2025_RATES + ) + + +VARIABLES = [ + "pays_scottish_income_tax", + "uses_property_allowance", + "taxable_property_income_after_allowances", + "property_income_tax", + "savings_income_tax", + "basic_rate_savings_income", + "higher_rate_savings_income", + "add_rate_savings_income", + "property_finance_costs_relieved", + "property_finance_cost_relief", + "income_tax", +] + + +def run(sims, drawn): + employment, profit, cost_share, savings = map(np.array, zip(*drawn)) + results = [] + for sim in sims: + sim.set_input("employment_income", YEAR, employment) + sim.set_input("property_income", YEAR, profit) + sim.set_input("property_finance_costs", YEAR, profit * cost_share) + sim.set_input("savings_interest_income", YEAR, savings) + sim.reset_calculations() + results.append({v: sim.calculate(v, YEAR).astype(float) for v in VARIABLES}) + return results + + +def same_election(law, before): + """People whose property allowance election the rates leave unchanged.""" + return law["uses_property_allowance"] == before["uses_property_allowance"] + + +def tolerance(*values): + """A penny plus float32 rounding on the amounts involved.""" + return 0.01 + 1e-6 * sum(np.abs(v) for v in values) + + +SETTINGS = settings( + max_examples=25, + deadline=None, + suppress_health_check=[HealthCheck.too_slow], +) + + +@SETTINGS +@given(cases) +@example(ELECTION_CHANGE) +def test_ruk_property_tax_rises_by_two_points_of_taxable_property(sims, drawn): + law, before = run(sims, drawn) + ruk = ~law["pays_scottish_income_tax"].astype(bool) & same_election(law, before) + rise = law["property_income_tax"] - before["property_income_tax"] + expected = RISE * law["taxable_property_income_after_allowances"] + tol = tolerance(law["property_income_tax"], expected) + assert (np.abs(rise - expected)[ruk] <= tol[ruk]).all() + + +@SETTINGS +@given(cases) +@example(ELECTION_CHANGE) +def test_scottish_property_tax_ignores_the_property_rates(sims, drawn): + law, before = run(sims, drawn) + scottish = law["pays_scottish_income_tax"].astype(bool) & same_election(law, before) + rise = law["property_income_tax"] - before["property_income_tax"] + tol = tolerance(law["property_income_tax"]) + assert (np.abs(rise)[scottish] <= tol[scottish]).all() + + +@SETTINGS +@given(cases) +@example(ELECTION_CHANGE) +def test_savings_tax_rises_by_two_points_in_every_country(sims, drawn): + law, before = run(sims, drawn) + banded = ( + law["basic_rate_savings_income"] + + law["higher_rate_savings_income"] + + law["add_rate_savings_income"] + ) + rise = law["savings_income_tax"] - before["savings_income_tax"] + tol = tolerance(law["savings_income_tax"], banded) + same = same_election(law, before) + assert (np.abs(rise - RISE * banded) <= tol)[same].all() + + +@SETTINGS +@given(cases) +@example(ELECTION_CHANGE) +def test_finance_cost_relief_rises_by_at_most_two_points(sims, drawn): + law, before = run(sims, drawn) + rise = law["property_finance_cost_relief"] - before["property_finance_cost_relief"] + cap = RISE * law["property_finance_costs_relieved"] + tol = tolerance(law["property_finance_cost_relief"], cap) + assert (rise >= -tol).all() + full_before = 0.22 * before["property_finance_costs_relieved"] + uncapped = np.abs(before["property_finance_cost_relief"] - full_before) <= tol + assert (rise <= cap + tol)[uncapped & same_election(law, before)].all() + + +def test_capped_finance_cost_relief_can_rise_by_more_than_two_points(): + """£7,800 of earlier tax credits leave £86 of tax under the Budget 2025 + rates, so the relief on £1,000 of finance costs is £86, not £220. The + Budget 2026 rates raise tax before reductions by £400, to £8,286, leaving + £486: the relief is its full 24%, £240, a rise of £154.""" + situation = { + "people": { + "person": { + "age": {YEAR: 45}, + "employment_income": {YEAR: 30_000}, + "property_income": {YEAR: 20_000}, + "property_finance_costs": {YEAR: 1_000}, + "other_tax_credits": {YEAR: 7_800}, + } + }, + "benunits": {"benunit": {"members": ["person"]}}, + "households": { + "household": {"members": ["person"], "region": {YEAR: "LONDON"}} + }, + } + law = Simulation(situation=situation) + before = Simulation(situation=situation, reform=BUDGET_2025_RATES) + relief = "property_finance_cost_relief" + assert float(before.calculate(relief, YEAR)[0]) == pytest.approx(86, abs=0.01) + assert float(law.calculate(relief, YEAR)[0]) == pytest.approx(240, abs=0.01) + assert float(before.calculate("income_tax", YEAR)[0]) == pytest.approx(0, abs=0.01) + assert float(law.calculate("income_tax", YEAR)[0]) == pytest.approx(246, abs=0.01) + + +def test_higher_relief_can_make_giving_up_the_property_allowance_worthwhile(): + """A Scottish landlord with £500 of rent and £435 of finance costs. At + 22% the relief (£95.70) is worth less than the Scottish basic-rate tax + that the £1,000 property allowance saves on the rent (£100), so they use + the allowance. At 24% the relief is £104.40, so giving up the allowance + and claiming the relief lowers their income tax by £4.40, although their + property income tax rises from nothing to £100.""" + situation = { + "people": { + "person": { + "age": {YEAR: 45}, + "employment_income": {YEAR: 20_000}, + "property_income": {YEAR: 500}, + "property_finance_costs": {YEAR: 435}, + } + }, + "benunits": {"benunit": {"members": ["person"]}}, + "households": { + "household": {"members": ["person"], "region": {YEAR: "SCOTLAND"}} + }, + } + law = Simulation(situation=situation) + before = Simulation(situation=situation, reform=BUDGET_2025_RATES) + + def value(sim, variable): + return float(sim.calculate(variable, YEAR)[0]) + + assert value(before, "uses_property_allowance") == 1 + assert value(law, "uses_property_allowance") == 0 + assert value(before, "property_finance_cost_relief") == 0 + assert value(law, "property_finance_cost_relief") == pytest.approx(104.40, abs=0.01) + assert value(before, "property_income_tax") == 0 + assert value(law, "property_income_tax") == pytest.approx(100, abs=0.01) + income_tax_change = value(law, "income_tax") - value(before, "income_tax") + assert income_tax_change == pytest.approx(-4.40, abs=0.01) diff --git a/policyengine_uk/tests/test_carers_allowance_earnings_taper.py b/policyengine_uk/tests/test_carers_allowance_earnings_taper.py new file mode 100644 index 0000000000..20fc09c071 --- /dev/null +++ b/policyengine_uk/tests/test_carers_allowance_earnings_taper.py @@ -0,0 +1,1134 @@ +"""The Budget 2026 Carer's Allowance earnings taper. + +Budget 2026 (paragraph 6.13; costings page 16; Table 6.1, item 7) replaces +the withdrawal of Carer's Allowance in full above the weekly earnings limit +with a 100% taper on the earnings above it, in England and Wales, from a date +within 2030-31 (6 October 2030 until DWP confirms it). The tests read the +limit and the weekly rate from the parameter tree, so they hold whatever the +limit's projection (gov.dwp.carers_allowance.earnings_limit). Examples first, +then identities that hold for every input: + +- in England and Wales from 2031-32 the weekly allowance is the rate less the + earnings above the limit, floored at zero; +- the taper never pays less than the cliff edge it replaces; +- weekly earnings plus the allowance never fall as earnings rise: no cliff; +- Scotland (Carer Support Payment) and Northern Ireland keep their rules; +- in 2030-31 the payment follows the weekly overlap rule over the weeks the + allowance pays for, for single carers and for couples caring for the same + person; +- a couple caring for the same person elect the larger award, so the taper + never leaves a couple with less than the cliff edge, and one partner has + the award for the year, in 2030-31 too, at any taper rate of zero or more + and even where a reform masks the other's fraction of a penny; +- an award that an input supplies, or that a reform neutralizes, calculates + with a formula of its own or masks with defined_for, before the couple + limit or the overlap rule is what reaches the payment, with the whole + year's treatment, even where it equals the model's; so 2030-31 pays such + an award as 2031-32 does, while a reform that changes only a label keeps + the parts of the year; +- the payment reads the award before the couple limit only where the model + calculates the award before overlap, so a supplied pre-overlap award needs + nothing before it; +- an overlapping benefit equal to the award leaves no payment or receipt, + the model pays a penny left over, and any payment is at least half a penny. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.utils.carers_allowance_taper import matches_parts_of_year + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +TAPER = "gov.dwp.carers_allowance.earnings_taper" +FULL_YEAR = 2031 +START_YEAR = 2030 +# 6 October 2030 to 5 April 2031 is 182 of 2030-31's 365 days. +START_YEAR_SHARE = 182 / 365 +WITHOUT_TAPER = {f"{TAPER}.applies": {str(year): 0 for year in (START_YEAR, FULL_YEAR)}} +REGIONS = ["NORTH_WEST", "WALES", "NORTHERN_IRELAND", "SCOTLAND"] +TAPER_REGIONS = ["NORTH_WEST", "WALES"] + + +def carer(weekly_earnings, year, region): + return { + "people": { + "carer": { + "age": {year: 45}, + "care_hours": {year: 40}, + "carers_allowance_earnings": {year: 52 * weekly_earnings}, + } + }, + "benunits": {"benunit": {"members": ["carer"]}}, + "households": {"household": {"members": ["carer"], "region": {year: region}}}, + } + + +def weekly_allowance(weekly_earnings, year, region="NORTH_WEST", reform=None): + sim = Simulation(situation=carer(weekly_earnings, year, region), reform=reform) + return float(sim.calculate("carers_allowance_pre_couple_limit", year)[0]) / 52 + + +def ca_parameters(year): + sim = Simulation(situation=carer(0, year, "NORTH_WEST")) + ca = sim.tax_benefit_system.parameters.gov.dwp.carers_allowance + return ca.earnings_limit(f"{year}-04-30"), ca.rate(f"{year}-04-30") + + +def test_at_the_limit_the_full_rate_is_paid(): + limit, rate = ca_parameters(FULL_YEAR) + assert weekly_allowance(limit, FULL_YEAR) == pytest.approx(rate, abs=0.01) + + +@pytest.mark.parametrize("region", TAPER_REGIONS) +def test_ten_pounds_over_the_limit_takes_ten_pounds_off(region): + limit, rate = ca_parameters(FULL_YEAR) + assert weekly_allowance(limit + 10, FULL_YEAR, region) == pytest.approx( + rate - 10, abs=0.01 + ) + + +def test_earnings_a_full_rate_over_the_limit_leave_nothing(): + limit, rate = ca_parameters(FULL_YEAR) + assert weekly_allowance(np.ceil(limit + rate) + 1, FULL_YEAR) == 0 + + +@pytest.mark.parametrize("region", ["NORTHERN_IRELAND", "SCOTLAND"]) +def test_no_taper_outside_england_and_wales(region): + limit, _ = ca_parameters(FULL_YEAR) + assert weekly_allowance(limit + 10, FULL_YEAR, region) == 0 + + +def test_no_taper_before_2030_31(): + limit, _ = ca_parameters(2029) + assert weekly_allowance(limit + 10, 2029) == 0 + + +def test_2030_31_carries_the_taper_for_the_weeks_from_6_october(): + limit, rate = ca_parameters(START_YEAR) + assert weekly_allowance(limit + 10, START_YEAR) == pytest.approx( + START_YEAR_SHARE * (rate - 10), abs=0.01 + ) + + +def test_below_the_limit_2030_31_pays_the_full_rate(): + limit, rate = ca_parameters(START_YEAR) + assert weekly_allowance(limit - 1, START_YEAR) == pytest.approx(rate, abs=0.01) + + +CASES = 24 +STEP = 1.0 +weekly = st.one_of( + st.integers(min_value=0, max_value=400).map(float), + st.floats(min_value=150, max_value=360), +) +cases = st.lists(weekly, min_size=CASES, max_size=CASES) + + +def situation(): + # The second half repeats the first with STEP more weekly earnings. + n = 2 * CASES + return { + "people": { + f"person_{i}": {"age": {FULL_YEAR: 45}, "care_hours": {FULL_YEAR: 40}} + for i in range(n) + }, + "benunits": {f"benunit_{i}": {"members": [f"person_{i}"]} for i in range(n)}, + "households": { + f"household_{i}": { + "members": [f"person_{i}"], + "region": {FULL_YEAR: REGIONS[i % len(REGIONS)]}, + } + for i in range(n) + }, + } + + +@pytest.fixture(scope="module") +def sims(): + return Simulation(situation=situation()), Simulation( + situation=situation(), reform=WITHOUT_TAPER + ) + + +def run(sims, drawn): + earnings = np.tile(np.array(drawn), 2) + earnings[CASES:] += STEP + results = [] + for sim in sims: + sim.set_input("carers_allowance_earnings", FULL_YEAR, 52 * earnings) + sim.reset_calculations() + results.append( + sim.calculate("carers_allowance_pre_couple_limit", FULL_YEAR).astype(float) + / 52 + ) + regions = np.array([REGIONS[i % len(REGIONS)] for i in range(2 * CASES)]) + return earnings, regions, results[0], results[1] + + +SETTINGS = settings( + max_examples=25, + deadline=None, + suppress_health_check=[HealthCheck.too_slow], +) + + +@SETTINGS +@given(cases) +def test_the_allowance_is_the_rate_less_earnings_above_the_limit(sims, drawn): + earnings, regions, law, _ = run(sims, drawn) + limit, rate = ca_parameters(FULL_YEAR) + expected = np.maximum(0, rate - np.maximum(0, earnings - limit)) + tapers = np.isin(regions, TAPER_REGIONS) + # A penny for the disregarded fraction of a penny of weekly earnings. + assert (np.abs(law - expected)[tapers] <= 0.011).all() + + +@SETTINGS +@given(cases) +def test_the_taper_never_pays_less_than_the_cliff_edge(sims, drawn): + _, regions, law, cliff = run(sims, drawn) + assert (law >= cliff - 1e-4).all() + unchanged = ~np.isin(regions, TAPER_REGIONS) + assert (np.abs(law - cliff)[unchanged] <= 1e-4).all() + + +@SETTINGS +@given(cases) +def test_earnings_plus_the_allowance_never_fall_as_earnings_rise(sims, drawn): + earnings, regions, law, _ = run(sims, drawn) + total = earnings + law + tapers = np.isin(regions, TAPER_REGIONS)[:CASES] + rise = total[CASES:] - total[:CASES] + assert (rise[tapers] >= -0.011).all() + + +# Overlapping benefits come off only in the weeks the allowance pays for. A +# carer over the limit in 2030-31 gets it from 6 October 2030 only, so only +# that share of a weekly overlapping benefit reduces it. + + +def final_allowance(weekly_earnings, weekly_state_pension, year, region="NORTH_WEST"): + situation = carer(weekly_earnings, year, region) + person = situation["people"]["carer"] + person["age"] = {year: 70} + person["state_pension"] = {year: 52 * weekly_state_pension} + sim = Simulation(situation=situation) + return float(sim.calculate("carers_allowance", year)[0]) + + +def test_2030_31_takes_overlapping_benefits_off_only_the_taper_weeks(): + # £250 a week is £46 over the £204 limit; £40 a week of State Pension. + limit, rate = ca_parameters(START_YEAR) + excess = 250 - limit + expected = START_YEAR_SHARE * max(rate - excess - 40, 0) * 52 + assert final_allowance(250, 40, START_YEAR) == pytest.approx(expected, abs=0.05) + + +def test_full_taper_year_takes_off_the_whole_overlapping_benefit(): + limit, rate = ca_parameters(FULL_YEAR) + excess = 250 - limit + expected = max(rate - excess - 40, 0) * 52 + assert final_allowance(250, 40, FULL_YEAR) == pytest.approx(expected, abs=0.05) + + +def test_below_the_limit_2030_31_takes_off_the_whole_overlapping_benefit(): + limit, rate = ca_parameters(START_YEAR) + expected = max(rate - 40, 0) * 52 + assert final_allowance(limit - 1, 40, START_YEAR) == pytest.approx( + expected, abs=0.05 + ) + + +overlap_cases = st.lists( + st.tuples(weekly, st.floats(min_value=0, max_value=150)), + min_size=CASES, + max_size=CASES, +) + + +def overlap_situation(): + return { + "people": { + f"person_{i}": {"age": {START_YEAR: 70}, "care_hours": {START_YEAR: 40}} + for i in range(CASES) + }, + "benunits": { + f"benunit_{i}": {"members": [f"person_{i}"]} for i in range(CASES) + }, + "households": { + f"household_{i}": { + "members": [f"person_{i}"], + "region": {START_YEAR: REGIONS[i % len(REGIONS)]}, + } + for i in range(CASES) + }, + } + + +@pytest.fixture(scope="module") +def overlap_sim(): + return Simulation(situation=overlap_situation()) + + +@SETTINGS +@given(overlap_cases) +def test_2030_31_allowance_is_the_weekly_overlap_rule_over_the_paid_weeks( + overlap_sim, drawn +): + earnings = np.array([e for e, _ in drawn]) + pension = np.array([p for _, p in drawn]) + overlap_sim.set_input("carers_allowance_earnings", START_YEAR, 52 * earnings) + overlap_sim.set_input("state_pension", START_YEAR, 52 * pension) + overlap_sim.reset_calculations() + paid = overlap_sim.calculate("carers_allowance", START_YEAR).astype(float) + above = overlap_sim.calculate( + "carers_allowance_weekly_earnings_above_limit", START_YEAR + ).astype(float) + limit, rate = ca_parameters(START_YEAR) + regions = np.array([REGIONS[i % len(REGIONS)] for i in range(CASES)]) + tapers = np.isin(regions, TAPER_REGIONS) + # Weeks paid: all of them at or below the limit; above it, the taper + # weeks in England and Wales and none elsewhere. + share = np.where(above > 0, np.where(tapers, START_YEAR_SHARE, 0), 1) + weekly = np.where(above > 0, np.maximum(0, rate - above), rate) + expected = share * 52 * np.maximum(0, weekly - pension) + # Scotland pays Carer Support Payment instead. + england_wales_ni = regions != "SCOTLAND" + assert (np.abs(paid - expected)[england_wales_ni] <= 0.05).all() + # Anything paid is at least half a penny. + assert ((paid == 0) | (paid >= 0.005)).all() + + +# A couple caring for the same person get one award at a time, and elect +# which of them has it: the model takes the larger award, then the larger +# award over the year, then the head's. In 2030-31 the couple restriction +# works on the weeks before the taper and the weeks under it separately, and +# a partner below the limit has the larger award in both, or an equal one +# and the larger award over the year. + + +def couple(head_weekly_earnings, partner_weekly_earnings, weekly_state_pension): + def member(age, weekly_earnings): + return { + "age": {START_YEAR: age}, + "care_hours": {START_YEAR: 40}, + "carers_allowance_earnings": {START_YEAR: 52 * weekly_earnings}, + "state_pension": {START_YEAR: 52 * weekly_state_pension}, + } + + return { + "people": { + "head": member(71, head_weekly_earnings), + "partner": member(70, partner_weekly_earnings), + }, + "benunits": { + "benunit": { + "members": ["head", "partner"], + "would_claim_uc": {START_YEAR: False}, + } + }, + "households": { + "household": { + "members": ["head", "partner"], + "country": {START_YEAR: "ENGLAND"}, + } + }, + } + + +def test_2030_31_couple_elect_the_partner_under_the_limit(): + # The head is £46 over the limit, so from 6 October the taper gives them + # the rate less £46 a week; the partner is under it and has the full rate + # all year, the larger award, so the couple elect the partner for the + # whole year and only the partner counts as entitled. + limit, rate = ca_parameters(START_YEAR) + sim = Simulation(situation=couple(limit + 46, limit - 4, 40)) + assert sim.calculate("partners_care_for_same_severely_disabled_person", START_YEAR)[ + 0 + ] + paid = sim.calculate("carers_allowance", START_YEAR).astype(float) + assert paid == pytest.approx([0, max(rate - 40, 0) * 52], abs=0.05) + entitled = sim.calculate("is_entitled_to_carer_benefit", START_YEAR) + assert list(entitled) == [False, True] + + +@pytest.mark.parametrize("head_over, partner_over", [(46, 60), (60, 46)]) +def test_2030_31_couple_over_the_limit_elect_the_larger_tapered_award( + head_over, partner_over +): + # Both earn over the limit, so neither has an award before 6 October. + # Under the taper the one with less earnings over it has the larger + # award, and the couple elect them, head or partner. + limit, rate = ca_parameters(START_YEAR) + sim = Simulation(situation=couple(limit + head_over, limit + partner_over, 40)) + paid = sim.calculate("carers_allowance", START_YEAR).astype(float) + larger = START_YEAR_SHARE * max(rate - min(head_over, partner_over) - 40, 0) * 52 + expected = [larger, 0] if head_over < partner_over else [0, larger] + assert paid == pytest.approx(expected, abs=0.05) + + +def test_a_couple_under_the_limit_gets_one_award_all_year(): + limit, rate = ca_parameters(START_YEAR) + sim = Simulation(situation=couple(limit - 4, limit - 4, 40)) + head, partner = sim.calculate("carers_allowance", START_YEAR).astype(float) + assert head == pytest.approx(max(rate - 40, 0) * 52, abs=0.05) + assert partner == 0 + + +# Under the taper, a head over the limit can have the full rate too: at a +# taper rate of zero, where an input says they earn over the limit with no +# earnings above it, or with a millionth of a pound above it, which rounds +# away when float32 takes it from the rate. The couple then elect the +# partner, who has the award all year, rather than giving the head the weeks +# under the taper. + + +def full_rate_under_the_taper(route): + limit, _ = ca_parameters(START_YEAR) + if route == "zero taper rate": + reform = {f"{TAPER}.rate": {str(START_YEAR): 0}} + return couple(limit + 50, limit - 4, 0), reform + situation = couple(limit - 4, limit - 4, 0) + head = situation["people"]["head"] + if route == "supplied earnings condition": + head["is_gainfully_employed_for_carers_allowance"] = {START_YEAR: True} + else: + head["carers_allowance_weekly_earnings_above_limit"] = {START_YEAR: 1e-6} + return situation, None + + +@pytest.mark.parametrize( + "route", ["zero taper rate", "supplied earnings condition", "millionth over"] +) +def test_a_head_on_the_full_rate_under_the_taper_leaves_the_partner_the_year(route): + limit, rate = ca_parameters(START_YEAR) + situation, reform = full_rate_under_the_taper(route) + sim = Simulation(situation=situation, reform=reform) + pre_overlap = sim.calculate("carers_allowance_pre_overlap", START_YEAR) + assert pre_overlap.astype(float) == pytest.approx([0, rate * 52], abs=0.05) + entitled = sim.calculate("is_entitled_to_carer_benefit", START_YEAR) + assert list(entitled) == [False, True] + # One carer's Pension Credit addition, as for a couple both under the + # limit. + one_carer = Simulation(situation=couple(limit - 4, limit - 4, 0)) + addition = "carer_minimum_guarantee_addition" + assert sim.calculate(addition, START_YEAR) == pytest.approx( + one_carer.calculate(addition, START_YEAR) + ) + assert sim.calculate(addition, START_YEAR) > 0 + + +# A supplied pre-overlap award carries no dates, so it meets the whole year's +# overlapping benefits, whatever the earnings condition says. + + +@pytest.mark.parametrize( + "region, supplied, pension, expected", + [ + ("NORTHERN_IRELAND", 4_495.40, 6_000, 0), + ("NORTH_WEST", 4_495.40, 2_000, 2_495.40), + ], +) +def test_a_supplied_pre_overlap_award_meets_the_whole_years_overlapping_benefits( + region, supplied, pension, expected +): + situation = carer(250, START_YEAR, region) + person = situation["people"]["carer"] + person["age"] = {START_YEAR: 70} + person["carers_allowance_pre_overlap"] = {START_YEAR: supplied} + person["state_pension"] = {START_YEAR: pension} + paid = Simulation(situation=situation).calculate("carers_allowance", START_YEAR) + assert float(paid[0]) == pytest.approx(expected, abs=0.05) + + +# The award passes through carers_allowance_pre_couple_limit and +# carers_allowance_pre_overlap to the payment, so a value supplied for either, +# or a reform that replaces either, reaches the amount paid. + + +def one_carer_couple(year): + return { + "people": { + "head": {"age": {year: 71}, "care_hours": {year: 40}}, + "partner": { + "age": {year: 70}, + "care_hours": {year: 0}, + "would_claim_carers_allowance": {year: False}, + }, + }, + "benunits": { + "benunit": { + "members": ["head", "partner"], + "would_claim_uc": {year: False}, + } + }, + "households": { + "household": { + "members": ["head", "partner"], + "country": {year: "ENGLAND"}, + } + }, + } + + +@pytest.mark.parametrize("year", [START_YEAR, FULL_YEAR]) +@pytest.mark.parametrize("supplied", [[0, 0], [1_000, 0]]) +def test_a_supplied_award_before_the_couple_limit_is_the_award_paid(year, supplied): + sim = Simulation(situation=one_carer_couple(year)) + sim.set_input("carers_allowance_pre_couple_limit", year, supplied) + sim.reset_calculations() + for name in ("carers_allowance_pre_overlap", "carers_allowance"): + assert sim.calculate(name, year).astype(float) == pytest.approx(supplied) + + +@pytest.mark.parametrize("year", [START_YEAR, FULL_YEAR]) +@pytest.mark.parametrize( + "neutralized", ["carers_allowance_pre_couple_limit", "carers_allowance_pre_overlap"] +) +def test_a_neutralized_award_is_not_paid(year, neutralized): + from policyengine_core.reforms import Reform + + class Neutralize(Reform): + def apply(self): + self.neutralize_variable(neutralized) + + sim = Simulation(situation=one_carer_couple(year), reform=Neutralize) + assert (sim.calculate("carers_allowance", year) == 0).all() + assert not sim.calculate("receives_carers_allowance", year).any() + + +def both_caring_couple(year, weekly_state_pension=0, head_weekly_excess=0): + def member(age, weekly_excess=0): + return { + "age": {year: age}, + "care_hours": {year: 40}, + "state_pension": {year: 52 * weekly_state_pension}, + "carers_allowance_weekly_earnings_above_limit": {year: weekly_excess}, + } + + situation = one_carer_couple(year) + situation["people"] = { + "head": member(71, head_weekly_excess), + "partner": member(70), + } + return situation + + +def replaced(name, values): + """A reform giving a variable's formula fixed values for the two people.""" + from policyengine_core.reforms import Reform + + base = Simulation(situation=both_caring_couple(START_YEAR)).tax_benefit_system + variable = type(base.variables[name]) + + class Replaced(variable): + def formula(person, period, parameters): + return np.array(values, dtype=float) + + Replaced.__name__ = name + + class Replace(Reform): + def apply(self): + self.update_variable(Replaced) + + return Replace + + +@pytest.mark.parametrize("year", [START_YEAR, FULL_YEAR]) +def test_a_reform_replacing_the_award_before_the_couple_limit_is_paid(year): + # Both partners are eligible, so the parts of the year would pick the + # head; the reform gives the award to the partner instead. + reform = replaced("carers_allowance_pre_couple_limit", [0, 1_000]) + sim = Simulation(situation=both_caring_couple(year), reform=reform) + for name in ("carers_allowance_pre_overlap", "carers_allowance"): + assert sim.calculate(name, year).astype(float) == pytest.approx([0, 1_000]) + + +@pytest.mark.parametrize("year", [START_YEAR, FULL_YEAR]) +def test_a_reform_replacing_the_award_before_overlap_meets_the_whole_years_pension( + year, +): + # The head is £46 a week over the earnings limit, so in 2030-31 the parts + # of the year would pay them only under the taper. The replaced £1,000 + # carries no dates, so £400 a year of State Pension comes off it in full. + reform = replaced("carers_allowance_pre_overlap", [1_000, 0]) + situation = both_caring_couple(year, 400 / 52, head_weekly_excess=46) + sim = Simulation(situation=situation, reform=reform) + paid = sim.calculate("carers_allowance", year).astype(float) + assert paid == pytest.approx([600, 0], abs=0.01) + + +def reformed(name, change): + """A reform whose formula for a variable changes the model's amounts.""" + from policyengine_core.reforms import Reform + + base = Simulation(situation=both_caring_couple(START_YEAR)).tax_benefit_system + variable = type(base.variables[name]) + model = variable.formula + + class Reformed(variable): + def formula(person, period, parameters): + return change(model(person, period, parameters)) + + Reformed.__name__ = name + + class Reform_(Reform): + def apply(self): + self.update_variable(Reformed) + + return Reform_ + + +def updated(names, attributes): + """A reform that updates variables' attributes, keeping their formulas + unless the attributes give one.""" + from policyengine_core.reforms import Reform + + base = Simulation(situation=both_caring_couple(START_YEAR)).tax_benefit_system + classes = [type(name, (type(base.variables[name]),), attributes) for name in names] + + class Update(Reform): + def apply(self): + for variable in classes: + self.update_variable(variable) + + return Update + + +def couple_with_pension(year): + # The head is £46 a week over the earnings limit and the partner £60 over + # it, so in 2030-31 neither has an award before the taper, and under it + # the head has the larger one, which the couple elect. Each has £400 a + # year of State Pension. + situation = both_caring_couple(year, 400 / 52, head_weekly_excess=46) + partner = situation["people"]["partner"] + partner["carers_allowance_weekly_earnings_above_limit"] = {year: 60} + return situation + + +@pytest.mark.parametrize("year", [START_YEAR, FULL_YEAR]) +@pytest.mark.parametrize( + "change", + [ + lambda award: award * np.array([1, 0]), + lambda award: award * np.array([1, 1.0001]), + lambda award: award + np.array([0, 0.01]), + ], + ids=["partner_nil", "partner_scaled", "partner_penny"], +) +def test_a_reform_changing_only_the_partners_award_meets_the_whole_years_pension( + year, change +): + # A reform's formula for the award before the couple limit carries no + # dates, even where it leaves the head's amount as the model's: the + # restriction keeps the head's award for the whole year, and the whole + # year's State Pension comes off it. + model = Simulation(situation=couple_with_pension(year)) + head = float(model.calculate("carers_allowance_pre_couple_limit", year)[0]) + reform = reformed("carers_allowance_pre_couple_limit", change) + sim = Simulation(situation=couple_with_pension(year), reform=reform) + pre_overlap = sim.calculate("carers_allowance_pre_overlap", year).astype(float) + assert pre_overlap == pytest.approx([head, 0]) + paid = sim.calculate("carers_allowance", year).astype(float) + assert paid == pytest.approx([head - 400, 0], abs=0.01) + + +@pytest.mark.parametrize("year", [START_YEAR, FULL_YEAR]) +def test_a_reform_setting_the_models_own_award_before_overlap_meets_the_whole_years_pension( + year, +): + # A value can't show where it came from: an award a reform sets carries + # no dates even where it equals the model's. + model = Simulation(situation=couple_with_pension(year)) + award = model.calculate("carers_allowance_pre_overlap", year).astype(float) + reform = replaced("carers_allowance_pre_overlap", award) + sim = Simulation(situation=couple_with_pension(year), reform=reform) + paid = sim.calculate("carers_allowance", year).astype(float) + assert paid == pytest.approx(np.maximum(award - 400, 0), abs=0.01) + + +@pytest.mark.parametrize("year", [START_YEAR, FULL_YEAR]) +def test_a_reform_keeping_the_models_formulas_keeps_the_parts_of_the_year(year): + model = Simulation(situation=couple_with_pension(year)) + expected = model.calculate("carers_allowance", year).astype(float) + reform = updated( + ["carers_allowance_pre_couple_limit", "carers_allowance_pre_overlap"], + {"label": "relabelled"}, + ) + sim = Simulation(situation=couple_with_pension(year), reform=reform) + assert sim.calculate("carers_allowance", year).astype(float) == pytest.approx( + expected + ) + if year == START_YEAR: + # The head has the award only under the taper, so less than the + # whole year's pension comes off it. + award = float(model.calculate("carers_allowance_pre_overlap", year)[0]) + assert expected[0] > award - 400 + 100 + + +@pytest.mark.parametrize("year", [START_YEAR, FULL_YEAR]) +def test_a_reform_masking_the_award_before_the_couple_limit_keeps_the_partners( + year, +): + # The reform keeps the model's formula but limits the award to women, so + # only the partner has one. The masked amounts aren't the ones the parts + # of the year give, so the restriction sees them for the whole year and + # leaves the partner's award with them. + reform = updated( + ["carers_allowance_pre_couple_limit"], {"defined_for": "is_female"} + ) + situation = both_caring_couple(year, 400 / 52) + situation["people"]["head"]["is_female"] = {year: False} + situation["people"]["partner"]["is_female"] = {year: True} + sim = Simulation(situation=situation, reform=reform) + award = sim.calculate("carers_allowance_pre_couple_limit", year).astype(float) + assert award[0] == 0 and award[1] > 4_000 + pre_overlap = sim.calculate("carers_allowance_pre_overlap", year).astype(float) + assert pre_overlap == pytest.approx(award) + paid = sim.calculate("carers_allowance", year).astype(float) + assert paid == pytest.approx([0, award[1] - 400], abs=0.01) + + +def tiny_award_couple(year): + # The head earns a hundredth of a penny a week short of tapering away the + # whole rate, so under the taper they keep £0.0001 a week, a fraction of + # a penny for the year; the partner is within the limit. Each has £400 a + # year of State Pension. + rate = ca_parameters(year)[1] + return both_caring_couple(year, 400 / 52, head_weekly_excess=rate - 0.0001) + + +@pytest.mark.parametrize("year", [START_YEAR, FULL_YEAR]) +def test_a_fraction_of_a_penny_doesnt_take_the_award_from_the_partner(year): + # Under the taper the head keeps a fraction of a penny, but the partner's + # full rate is the larger award, so the couple elect the partner for the + # whole year. Picking the head for the weeks under the taper, as the + # model did before, paid the partner £2,301.24 too little in 2030-31 and + # nothing in 2031-32. + sim = Simulation(situation=tiny_award_couple(year)) + candidate = sim.calculate("carers_allowance_pre_couple_limit", year) + candidate = candidate.astype(float) + assert 0 < candidate[0] < 0.01 and candidate[1] > 4_000 + pre_overlap = sim.calculate("carers_allowance_pre_overlap", year) + assert pre_overlap.astype(float) == pytest.approx([0, candidate[1]]) + paid = sim.calculate("carers_allowance", year).astype(float) + assert paid == pytest.approx([0, candidate[1] - 400], abs=0.01) + + +@pytest.mark.parametrize("year", [START_YEAR, FULL_YEAR]) +@pytest.mark.parametrize( + "masked", ["carers_allowance_pre_couple_limit", "carers_allowance_pre_overlap"] +) +def test_a_reform_masking_a_fraction_of_a_penny_leaves_the_partners_award(year, masked): + # The reform limits a stage to women, so it takes away the male head's + # fraction of a penny. That is within half a penny of the parts of the + # year, but the couple restriction chooses only among partners with an + # award, so a masked pre-couple-limit stage carries no dates. Either way + # the couple elect the partner, who keeps their award less the year's + # £400 of State Pension. + partner = float( + Simulation(situation=tiny_award_couple(year)).calculate( + "carers_allowance_pre_couple_limit", year + )[1] + ) + reform = updated([masked], {"defined_for": "is_female"}) + situation = tiny_award_couple(year) + situation["people"]["head"]["is_female"] = {year: False} + situation["people"]["partner"]["is_female"] = {year: True} + sim = Simulation(situation=situation, reform=reform) + award = sim.calculate("carers_allowance_pre_couple_limit", year).astype(float) + pre_overlap = sim.calculate("carers_allowance_pre_overlap", year).astype(float) + if masked == "carers_allowance_pre_couple_limit": + assert award == pytest.approx([0, partner]) + assert pre_overlap == pytest.approx([0, partner]) + paid = sim.calculate("carers_allowance", year).astype(float) + assert paid == pytest.approx([0, partner - 400], abs=0.01) + + +def test_a_mask_on_a_fraction_of_a_penny_doesnt_match_the_parts_of_the_year(): + # The couple restriction chooses only among partners with an award, so an + # amount matches what the parts of the year give only where it is + # positive exactly where they are: within half a penny isn't enough. The + # election gives a head's fraction of a penny nothing beside the + # partner's full rate, so no payment shows this check; this tests it + # directly. + sim = Simulation(situation=tiny_award_couple(START_YEAR)) + person = sim.populations["person"] + parts = np.array([0.0026, 2_000], dtype=np.float32) + assert matches_parts_of_year(person, parts, parts).all() + nearby = np.array([0.0066, 2_000.004], dtype=np.float32) + assert matches_parts_of_year(person, nearby, parts).all() + masked = np.array([0, 2_000], dtype=np.float32) + assert not matches_parts_of_year(person, masked, parts).any() + assert not matches_parts_of_year(person, parts, masked).any() + + +@pytest.mark.parametrize("year", [START_YEAR, FULL_YEAR]) +def test_a_supplied_pre_overlap_award_needs_no_award_before_the_couple_limit(year): + # This reform derives the award before the couple limit from the payment, + # which works only because the supplied pre-overlap award breaks the + # loop. A supplied pre-overlap award carries no dates, so the payment + # doesn't read the stage before it. + situation = carer(0, year, "NORTH_WEST") + situation["people"]["carer"].update( + age={year: 70}, + state_pension={year: 400}, + carers_allowance_pre_overlap={year: 1_000}, + ) + reform = updated( + ["carers_allowance_pre_couple_limit"], + { + "formula": lambda person, period, parameters: ( + person("carers_allowance", period) + + person("carers_allowance_overlapping_benefits", period) + ) + }, + ) + paid = Simulation(situation=situation, reform=reform).calculate( + "carers_allowance", year + ) + assert float(paid[0]) == pytest.approx(600) + + +# An award a reform sets before the couple limit carries no dates, so 2030-31 +# restricts it and takes overlapping benefits off it as 2031-32 does, keeping +# the larger award, the head's where the two are equal. + +reform_award = st.one_of( + st.just(0.0), st.floats(min_value=0.01, max_value=6_000).map(lambda x: round(x, 2)) +) +reform_award_cases = st.lists( + st.tuples( + reform_award, + reform_award, + st.floats(min_value=0, max_value=150), + st.floats(min_value=0, max_value=150), + ), + min_size=CASES, + max_size=CASES, +) + + +def reform_award_situation(): + people, benunits, households = {}, {}, {} + years = (START_YEAR, FULL_YEAR) + for i in range(CASES): + members = [f"head_{i}", f"partner_{i}"] + for name, age in zip(members, (71, 70)): + people[name] = { + "age": {year: age + year - START_YEAR for year in years}, + "care_hours": {year: 40 for year in years}, + } + benunits[f"benunit_{i}"] = { + "members": members, + "would_claim_uc": {year: False for year in years}, + } + households[f"household_{i}"] = { + "members": members, + "country": {year: "ENGLAND" for year in years}, + } + return {"people": people, "benunits": benunits, "households": households} + + +@pytest.fixture(scope="module") +def reform_award_sim(): + from policyengine_core.reforms import Reform + from policyengine_uk.model_api import YEAR, Person, Variable + + class award_set_by_reform(Variable): + value_type = float + entity = Person + label = "Carer's Allowance before the couple limit, as a reform sets it" + definition_period = YEAR + + base = Simulation(situation=both_caring_couple(START_YEAR)).tax_benefit_system + variable = type(base.variables["carers_allowance_pre_couple_limit"]) + + class carers_allowance_pre_couple_limit(variable): + def formula(person, period, parameters): + return person("award_set_by_reform", period) + + class SetAward(Reform): + def apply(self): + self.add_variable(award_set_by_reform) + self.update_variable(carers_allowance_pre_couple_limit) + + return Simulation(situation=reform_award_situation(), reform=SetAward) + + +@SETTINGS +@given(reform_award_cases) +def test_2030_31_pays_an_award_a_reform_sets_as_2031_32_does(reform_award_sim, drawn): + head_award, partner_award, head_pension, partner_pension = map( + np.array, zip(*drawn) + ) + award = np.column_stack([head_award, partner_award]).ravel() + pension = np.column_stack([head_pension, partner_pension]).ravel() + sim = reform_award_sim + for year in (START_YEAR, FULL_YEAR): + sim.set_input("award_set_by_reform", year, award) + sim.set_input("state_pension", year, 52 * pension) + sim.reset_calculations() + paid = { + year: sim.calculate("carers_allowance", year).astype(float).reshape(-1, 2) + for year in (START_YEAR, FULL_YEAR) + } + assert paid[START_YEAR] == pytest.approx(paid[FULL_YEAR], abs=0.01) + for year in (START_YEAR, FULL_YEAR): + assert sim.calculate( + "partners_care_for_same_severely_disabled_person", year + ).all() + award = award.reshape(-1, 2) + head = (award[:, 0] > 0) & (award[:, 0] >= award[:, 1]) + kept = np.column_stack([head, ~head]) * award + expected = np.maximum(0, kept - 52 * pension.reshape(-1, 2)) + expected = np.where(expected < 0.005, 0, expected) + assert paid[START_YEAR] == pytest.approx(expected, abs=0.01) + + +@pytest.mark.parametrize("year", [START_YEAR, FULL_YEAR]) +@pytest.mark.parametrize("award", [1_000.01, 5_000.01]) +def test_a_penny_left_after_overlap_is_paid(year, award): + # Float32 stores 5,000.01 as 5,000.0098, so the penny left over is 0.98p. + situation = carer(0, year, "NORTH_WEST") + person = situation["people"]["carer"] + person["age"] = {year: 70} + person["carers_allowance_pre_overlap"] = {year: award} + person["state_pension"] = {year: round(award - 0.01, 2)} + sim = Simulation(situation=situation) + assert float(sim.calculate("carers_allowance", year)[0]) == pytest.approx( + 0.01, abs=0.001 + ) + assert sim.calculate("receives_carers_allowance", year)[0] + + +# An overlapping benefit equal to the weekly award extinguishes it. In +# float32 the annual amounts can leave a fraction of a penny, which is not a +# payment and is not received. + +residue_cases = st.lists( + st.integers(min_value=1, max_value=9_000), min_size=CASES, max_size=CASES +) + + +def residue_situation(year): + return { + "people": { + f"person_{i}": {"age": {year: 70}, "care_hours": {year: 40}} + for i in range(CASES) + }, + "benunits": { + f"benunit_{i}": { + "members": [f"person_{i}"], + "would_claim_uc": {year: False}, + } + for i in range(CASES) + }, + "households": { + f"household_{i}": { + "members": [f"person_{i}"], + "country": {year: "ENGLAND"}, + } + for i in range(CASES) + }, + } + + +@pytest.fixture(scope="module") +def residue_sims(): + return { + year: Simulation(situation=residue_situation(year)) + for year in (START_YEAR, FULL_YEAR) + } + + +@pytest.mark.parametrize("year", [START_YEAR, FULL_YEAR]) +@SETTINGS +@given(drawn=residue_cases) +def test_an_overlapping_benefit_equal_to_the_award_leaves_nothing_received( + residue_sims, year, drawn +): + _, rate = ca_parameters(year) + # Weekly earnings above the limit, in pence, short of the whole rate. + excess = np.minimum(np.array(drawn), int(rate * 100) - 1) / 100 + sim = residue_sims[year] + sim.set_input("carers_allowance_weekly_earnings_above_limit", year, excess) + sim.set_input("state_pension", year, 52 * (rate - excess)) + sim.reset_calculations() + assert (sim.calculate("carers_allowance", year) == 0).all() + assert not sim.calculate("receives_carers_allowance", year).any() + + +# Whole pence, so at the Budget's taper rate the two partners' weekly awards +# are equal or differ by about a penny or more, after float32 rounding. +pence = weekly.map(lambda earnings: round(earnings, 2)) +couple_cases = st.lists( + st.tuples( + pence, + pence, + st.floats(min_value=0, max_value=150), + st.floats(min_value=0, max_value=150), + ), + min_size=CASES, + max_size=CASES, +) + + +def couples_situation(years=(START_YEAR,)): + people, benunits, households = {}, {}, {} + for i in range(CASES): + members = [f"head_{i}", f"partner_{i}"] + for name, age in zip(members, (71, 70)): + people[name] = { + "age": {year: age + year - START_YEAR for year in years}, + "care_hours": {year: 40 for year in years}, + } + benunits[f"benunit_{i}"] = { + "members": members, + "would_claim_uc": {year: False for year in years}, + } + households[f"household_{i}"] = { + "members": members, + "region": {year: REGIONS[i % len(REGIONS)] for year in years}, + } + return {"people": people, "benunits": benunits, "households": households} + + +@pytest.fixture(scope="module") +def couples_sim(): + return Simulation(situation=couples_situation()) + + +@SETTINGS +@given(couple_cases) +def test_2030_31_couple_allowance_is_the_weekly_rule_on_each_part_of_the_year( + couples_sim, drawn +): + head_earnings, partner_earnings, head_pension, partner_pension = map( + np.array, zip(*drawn) + ) + earnings = np.column_stack([head_earnings, partner_earnings]).ravel() + pension = np.column_stack([head_pension, partner_pension]).ravel() + sim = couples_sim + sim.set_input("carers_allowance_earnings", START_YEAR, 52 * earnings) + sim.set_input("state_pension", START_YEAR, 52 * pension) + sim.reset_calculations() + assert sim.calculate("is_benunit_head", START_YEAR)[::2].all() + assert sim.calculate( + "partners_care_for_same_severely_disabled_person", START_YEAR + ).all() + paid = sim.calculate("carers_allowance", START_YEAR).astype(float).reshape(-1, 2) + above = ( + sim.calculate("carers_allowance_weekly_earnings_above_limit", START_YEAR) + .astype(float) + .reshape(-1, 2) + ) + _, rate = ca_parameters(START_YEAR) + regions = np.array([REGIONS[i % len(REGIONS)] for i in range(CASES)]) + tapers = np.isin(regions, TAPER_REGIONS)[:, None] + pension = pension.reshape(-1, 2) + before = np.where(above > 0, 0, rate) + under = np.where(above > 0, np.where(tapers, np.maximum(0, rate - above), 0), rate) + parts = ((1 - START_YEAR_SHARE, before), (START_YEAR_SHARE, under)) + annual = sum(share * weekly for share, weekly in parts) + expected = np.zeros_like(paid) + for share, weekly in parts: + # The larger award, then the larger award over the year, then the + # head's. + head_paid = (weekly[:, 0] > 0) & ( + (weekly[:, 0] > weekly[:, 1]) + | ((weekly[:, 0] == weekly[:, 1]) & (annual[:, 0] >= annual[:, 1])) + ) + selected = np.column_stack([head_paid, ~head_paid]) + expected += share * 52 * selected * np.maximum(0, weekly - pension) + # Scotland pays Carer Support Payment instead. + england_wales_ni = regions != "SCOTLAND" + assert (np.abs(paid - expected)[england_wales_ni] <= 0.05).all() + assert ((paid == 0) | (paid >= 0.005)).all() + + +# The couple elect the larger award, so an award the taper gives one partner +# never takes the award from the other's larger one: the couple get at least +# what the cliff edge gave them, one partner has the award for the year, and +# in 2031-32 that award is the larger of the two before the couple limit. + + +@pytest.fixture(scope="module") +def couple_years_sims(): + situation = couples_situation((START_YEAR, FULL_YEAR)) + return Simulation(situation=situation), Simulation( + situation=situation, reform=WITHOUT_TAPER + ) + + +@SETTINGS +@given(couple_cases) +def test_the_taper_never_leaves_a_couple_with_less_than_the_cliff_edge( + couple_years_sims, drawn +): + head_earnings, partner_earnings, head_pension, partner_pension = map( + np.array, zip(*drawn) + ) + earnings = np.column_stack([head_earnings, partner_earnings]).ravel() + pension = np.column_stack([head_pension, partner_pension]).ravel() + for sim in couple_years_sims: + for year in (START_YEAR, FULL_YEAR): + sim.set_input("carers_allowance_earnings", year, 52 * earnings) + sim.set_input("state_pension", year, 52 * pension) + sim.reset_calculations() + taper, cliff = couple_years_sims + for year in (START_YEAR, FULL_YEAR): + assert taper.calculate( + "partners_care_for_same_severely_disabled_person", year + ).all() + paid, paid_at_cliff = ( + sim.calculate("carers_allowance", year).astype(float).reshape(-1, 2) + for sim in (taper, cliff) + ) + assert (paid.sum(axis=1) >= paid_at_cliff.sum(axis=1) - 0.01).all() + pre_overlap = ( + taper.calculate("carers_allowance_pre_overlap", year) + .astype(float) + .reshape(-1, 2) + ) + assert ((pre_overlap > 0).sum(axis=1) <= 1).all() + candidate = ( + taper.calculate("carers_allowance_pre_couple_limit", FULL_YEAR) + .astype(float) + .reshape(-1, 2) + ) + assert pre_overlap.sum(axis=1) == pytest.approx(candidate.max(axis=1)) + + +# At a taper rate of zero, a partner over the limit has the full rate under +# the taper, the same award as a partner under it; at 0.0001, so does one a +# penny over the limit, whose tapered award rounds to the full rate in +# float32. One partner still has the award for the year. + + +@pytest.fixture(scope="module") +def taper_rate_sims(): + situation = couples_situation() + return [ + Simulation(situation=situation, reform={f"{TAPER}.rate": {str(START_YEAR): r}}) + for r in (0, 0.0001) + ] + + +@SETTINGS +@given(couple_cases) +def test_one_partner_has_the_award_for_the_year_at_any_taper_rate( + taper_rate_sims, drawn +): + head_earnings, partner_earnings, _, _ = map(np.array, zip(*drawn)) + earnings = np.column_stack([head_earnings, partner_earnings]).ravel() + for sim in taper_rate_sims: + sim.set_input("carers_allowance_earnings", START_YEAR, 52 * earnings) + sim.reset_calculations() + for name in ("carers_allowance_pre_overlap", "is_entitled_to_carer_benefit"): + award = sim.calculate(name, START_YEAR).reshape(-1, 2) + assert ((award > 0).sum(axis=1) <= 1).all() diff --git a/policyengine_uk/tests/test_disable_simulated_benefits_carers.py b/policyengine_uk/tests/test_disable_simulated_benefits_carers.py index 388f2a4b73..a4c9c6410f 100644 --- a/policyengine_uk/tests/test_disable_simulated_benefits_carers.py +++ b/policyengine_uk/tests/test_disable_simulated_benefits_carers.py @@ -326,10 +326,17 @@ def simulate(regions_by_year): assert supplement[0] > 0 else: np.testing.assert_allclose(supplement, 0) - for name in ["household_benefits", "hbai_household_net_income"]: - np.testing.assert_allclose( - values(simulation, name, first + 1), 4_000 + supplement, err_msg=name - ) + # The Household Energy Payment (Budget 2026, England, 2026-27) is in + # household_benefits and HBAI income. + energy_payment = values(simulation, "household_energy_payment", first + 1) + np.testing.assert_allclose( + values(simulation, "household_benefits", first + 1), + 4_000 + supplement + energy_payment, + ) + np.testing.assert_allclose( + values(simulation, "hbai_household_net_income", first + 1), + 4_000 + supplement + energy_payment, + ) def test_a_reform_applied_after_a_move_is_set_reads_the_move(): diff --git a/policyengine_uk/tests/test_domestic_electricity_vat.py b/policyengine_uk/tests/test_domestic_electricity_vat.py index bc75f3f469..4177cae13b 100644 --- a/policyengine_uk/tests/test_domestic_electricity_vat.py +++ b/policyengine_uk/tests/test_domestic_electricity_vat.py @@ -2,10 +2,13 @@ Domestic electricity VAT: the temporary GB zero rate (SI 2026/987) and the explicit energy VAT base (#2166). -From 1 October 2026 to 31 March 2027 domestic electricity in England, Wales -and Scotland is zero-rated; Northern Ireland and gas stay at 5%. Model year -2026 runs 6 April 2026 to 5 April 2027, so with equal daily use it has 183 -days at 5% and 182 at 0%. +From 1 October 2026 domestic electricity in England, Wales and Scotland is +zero-rated. Budget 2026 extends the zero rate to 1 July 2027 and expects it to +apply in Northern Ireland from 1 April 2027 (paragraph 6.210). Gas stays at +5%. Model year 2026 runs 6 April 2026 to 5 April 2027, so with equal daily use +Great Britain has 178 days at 5% and 187 at 0%, and Northern Ireland 360 at 5% +and 5 at 0%. Model year 2027 (366 days) has 86 days at 0% and 280 at 5% in +every nation. """ from pathlib import Path @@ -19,7 +22,9 @@ from policyengine_uk.model_api import Scenario from policyengine_uk.system import system -FY2026_SHARE_AT_5_PERCENT = 183 / 365 +FY2026_SHARE_AT_5_PERCENT = 178 / 365 +NI_FY2026_SHARE_AT_5_PERCENT = 360 / 365 +FY2027_SHARE_AT_5_PERCENT = 280 / 366 GB_RATE = "gov.hmrc.vat.domestic_electricity_rate.great_britain" @@ -57,28 +62,39 @@ def calc(variable, year=2026, scenario=None, **kwargs): @pytest.mark.parametrize( - "date, rate", + "date, great_britain, northern_ireland", [ - ("2026-09-30", 0.05), - ("2026-10-01", 0.0), - ("2027-03-31", 0.0), - ("2027-04-01", 0.05), + ("2026-09-30", 0.05, 0.05), + ("2026-10-01", 0.0, 0.05), + ("2027-03-31", 0.0, 0.05), + ("2027-04-01", 0.0, 0.0), + ("2027-06-30", 0.0, 0.0), + ("2027-07-01", 0.05, 0.05), ], ) -def test_gb_rate_changes_on_both_statutory_dates(date, rate): +def test_rates_change_on_each_scheduled_date(date, great_britain, northern_ireland): rates = raw_parameters().gov.hmrc.vat.domestic_electricity_rate - assert rates.great_britain(date) == pytest.approx(rate) - assert rates.northern_ireland(date) == pytest.approx(0.05) + assert rates.great_britain(date) == pytest.approx(great_britain) + assert rates.northern_ireland(date) == pytest.approx(northern_ireland) -def test_fy2026_gb_rate_is_day_weighted(): +def test_model_year_rates_are_day_weighted(): rates = system.parameters.gov.hmrc.vat.domestic_electricity_rate - assert rates.great_britain("2025") == pytest.approx(0.05) + for year in ("2025", "2028"): + assert rates.great_britain(year) == pytest.approx(0.05) + assert rates.northern_ireland(year) == pytest.approx(0.05) assert rates.great_britain("2026") == pytest.approx( 0.05 * FY2026_SHARE_AT_5_PERCENT ) - assert rates.great_britain("2027") == pytest.approx(0.05) - assert rates.northern_ireland("2026") == pytest.approx(0.05) + assert rates.northern_ireland("2026") == pytest.approx( + 0.05 * NI_FY2026_SHARE_AT_5_PERCENT + ) + assert rates.great_britain("2027") == pytest.approx( + 0.05 * FY2027_SHARE_AT_5_PERCENT + ) + assert rates.northern_ireland("2027") == pytest.approx( + 0.05 * FY2027_SHARE_AT_5_PERCENT + ) @pytest.mark.parametrize("region", ["LONDON", "WALES", "SCOTLAND"]) @@ -89,15 +105,24 @@ def test_gb_electricity_vat_in_fy2026(region): ) -def test_northern_ireland_keeps_5_percent(): - assert calc("domestic_energy_vat", region="NORTHERN_IRELAND") == pytest.approx(50) +def test_northern_ireland_is_zero_rated_only_from_april_2027(): + assert calc("domestic_energy_vat", region="NORTHERN_IRELAND") == pytest.approx( + 50 * NI_FY2026_SHARE_AT_5_PERCENT + ) + + +@pytest.mark.parametrize("region", ["LONDON", "SCOTLAND", "NORTHERN_IRELAND"]) +def test_every_nation_is_zero_rated_to_1_july_2027(region): + assert calc("domestic_energy_vat", year=2027, region=region) == pytest.approx( + 50 * FY2027_SHARE_AT_5_PERCENT + ) def test_gas_is_unchanged(): assert calc("domestic_energy_vat", electricity=0, gas=1_050) == pytest.approx(50) -@pytest.mark.parametrize("year", [2025, 2027]) +@pytest.mark.parametrize("year", [2025, 2028]) def test_years_either_side_are_at_5_percent(year): assert calc("domestic_energy_vat", year=year) == pytest.approx(50) @@ -143,27 +168,35 @@ def test_extending_the_zero_rate_to_fy2027(): # Dated reforms are applied on the statutory timeline and day-weighted # into the model year (#2167), so the zero rate must cover all of model # year 2027: 6 April 2027 to 5 April 2028. A calendar-2027 window would - # leave 96 of 366 days at 5%. + # leave 96 of 366 days at 5%. The baseline already zero-rates the first + # 86 days, so the reform removes the other 280: 50 x 280 / 366 = £38.25. reform = Reform.from_dict( {GB_RATE: {"2027-04-06.2028-04-05": 0.0}}, country_id="uk" ) sim = Simulation(situation=household(year=2027), reform=reform) + baseline = 50 * FY2027_SHARE_AT_5_PERCENT assert sim.calculate("domestic_energy_vat", 2027)[0] == 0 - assert sim.calculate("baseline_domestic_energy_vat", 2027)[0] == pytest.approx(50) - assert sim.calculate("vat_change", 2027)[0] == pytest.approx(-50) + assert sim.calculate("baseline_domestic_energy_vat", 2027)[0] == pytest.approx( + baseline + ) + # vat_change is a difference of float32 totals, good to about 1p. + assert sim.calculate("vat_change", 2027)[0] == pytest.approx(-baseline, abs=0.01) def test_zero_rate_for_half_of_fy2027_is_day_weighted(): # A reform dated part-way through a model year (#2167, fixed by #2305) is # day-weighted on the blended electricity rate. Model year 2027 has 366 # days (6 April 2027 to 5 April 2028); a zero rate for the first 183 - # leaves half the year at 5%: 1,000 x 0.05 x 183 / 366 = £25. + # leaves half the year at 5%: 1,000 x 0.05 x 183 / 366 = £25. The + # baseline has 280 days at 5% (£38.25), so the change is £13.25. reform = Reform.from_dict( {GB_RATE: {"2027-04-06.2027-10-05": 0.0}}, country_id="uk" ) sim = Simulation(situation=household(year=2027), reform=reform) assert sim.calculate("domestic_energy_vat", 2027)[0] == pytest.approx(25) - assert sim.calculate("vat_change", 2027)[0] == pytest.approx(-25) + assert sim.calculate("vat_change", 2027)[0] == pytest.approx( + 25 - 50 * FY2027_SHARE_AT_5_PERCENT, abs=0.01 + ) def test_reduced_rate_reform_reaches_gas_not_electricity(): @@ -230,18 +263,19 @@ def test_scalar_zero_gas_rate_leaves_electricity_vat_alone(): @pytest.mark.parametrize( "region, electricity_rate", [ - # GB, model year 2026: 183 of 365 days at 5%, so 0.05 x 183 / 365. + # GB, model year 2026: 178 of 365 days at 5%, so 0.05 x 178 / 365. ("LONDON", 0.05 * FY2026_SHARE_AT_5_PERCENT), ("SCOTLAND", 0.05 * FY2026_SHARE_AT_5_PERCENT), - # Northern Ireland is outside the zero rate: 5% all year. - ("NORTHERN_IRELAND", 0.05), + # Northern Ireland joins the zero rate on 1 April 2027: 360 of 365 + # days at 5%. + ("NORTHERN_IRELAND", 0.05 * NI_FY2026_SHARE_AT_5_PERCENT), ], ) def test_baseline_fy2026_window_by_nation_with_gas(region, electricity_rate): # £1,050 of electricity and £2,100 of gas, each VAT-exclusive at / 1.05: - # electricity: 1,000 x rate (GB 25.07, NI 50) + # electricity: 1,000 x rate (GB 24.38, NI 49.32) # gas: 2,000 x 5% = £100 in every nation - # so GB = 125.07 and NI = 150. + # so GB = 124.38 and NI = 149.32. vat = calc("domestic_energy_vat", region=region, electricity=1_050, gas=2_100) assert vat == pytest.approx(1_000 * electricity_rate + 100, abs=0.01) gas_only = calc("domestic_energy_vat", region=region, electricity=0, gas=2_100) diff --git a/policyengine_uk/tests/test_fuel_duty_schedule.py b/policyengine_uk/tests/test_fuel_duty_schedule.py index 62611509cd..c52352dd95 100644 --- a/policyengine_uk/tests/test_fuel_duty_schedule.py +++ b/policyengine_uk/tests/test_fuel_duty_schedule.py @@ -2,8 +2,10 @@ The petrol and diesel fuel duty rate follows its dated schedule. Each rate is keyed to the date it takes effect, and `fiscal_year_blend` -day-weights the schedule across each model year (6 April to 5 April). Rates -from 1 April 2027 are an RPI forecast: Budget 2025 policy, not yet law. +day-weights the schedule across each model year (6 April to 5 April). +Budget 2026 keeps the 5p cut to 1 July 2027; the rate from then is the +OBR's forecast, and later rates an RPI forecast. Both are Budget 2026 policy, +not yet law. """ import datetime @@ -21,7 +23,13 @@ from policyengine_uk.system import system PATH = "gov.hmrc.fuel_duty.petrol_and_diesel" -FORECAST_YEARS = range(2027, 2032) +# The 1 July 2027 step is the OBR's 60.59p (EFO October 2026, paragraph +# 3.19). Each 1 April step from 2028 applies the previous calendar year's RPI +# growth. +CUT_END_DATE = "2027-07-01" +CUT_END_RATE = 0.6059 +FORECAST_DATES = [f"{year}-04-01" for year in range(2028, 2032)] +LAST_FORECAST_YEAR = 2031 CONVERTED_YEARS = range(2015, 2041) @@ -57,33 +65,33 @@ def model_year_days(year: int) -> list: ("2022-03-22", 0.5795), ("2022-03-23", 0.5295), # SI 2026/555 removed the 1 September 2026 step and moved the - # 1 December 2026 step to 1 January 2027. + # 1 December 2026 step to 1 January 2027; Budget 2026 cancelled the + # January and March 2027 steps and kept the cut to 1 July 2027. ("2026-09-01", 0.5295), ("2026-12-01", 0.5295), ("2026-12-31", 0.5295), - ("2027-01-01", 0.5595), - ("2027-02-28", 0.5595), - ("2027-03-01", 0.5795), - ("2027-03-31", 0.5795), + ("2027-01-01", 0.5295), + ("2027-03-01", 0.5295), + ("2027-04-01", 0.5295), + ("2027-06-30", 0.5295), ], ) -def test_statutory_rate_on_each_date(date, rate): +def test_scheduled_rate_on_each_date(date, rate): schedule = raw_parameters().gov.hmrc.fuel_duty.petrol_and_diesel assert schedule(date) == pytest.approx(rate, abs=1e-12) -# Model year Y runs from 6 April Y to 5 April Y+1. Rates from 1 April 2027, -# on the OBR's October 2026 RPI forecast: 60.03p, then 62.62p (2028), -# 64.52p (2029), 66.44p (2030), 67.96p (2031). +# Model year Y runs from 6 April Y to 5 April Y+1. Rates, on the OBR's October +# 2026 RPI forecast: 60.59p from 1 July 2027, then 63.21p (2028), 65.13p +# (2029), 67.07p (2030), 68.61p (2031). HAND_COMPUTED = { 2025: 0.5295, - # 6 Apr to 31 Dec 2026, Jan to Feb 2027, Mar 2027, 1 to 5 Apr 2027. - 2026: (0.5295 * 270 + 0.5595 * 59 + 0.5795 * 31 + 0.6003 * 5) / 365, - # 2028 is a leap year: 361 days to 31 March, then 5 days. - 2027: (0.6003 * 361 + 0.6262 * 5) / 366, - 2028: (0.6262 * 360 + 0.6452 * 5) / 365, - 2029: (0.6452 * 360 + 0.6644 * 5) / 365, - 2030: (0.6644 * 360 + 0.6796 * 5) / 365, + 2026: 0.5295, + # 6 Apr to 30 Jun 2027, then to 31 Mar 2028 (a leap year), then 5 days. + 2027: (0.5295 * 86 + 0.6059 * 275 + 0.6321 * 5) / 366, + 2028: (0.6321 * 360 + 0.6513 * 5) / 365, + 2029: (0.6513 * 360 + 0.6707 * 5) / 365, + 2030: (0.6707 * 360 + 0.6861 * 5) / 365, } @@ -93,7 +101,7 @@ def test_model_year_rate_is_hand_computed_day_weighted_average(year, rate): def test_forecast_steps_use_current_rpi_forecast(): - """The written 1 April steps match the RPI series they claim to use. + """The written steps match the RPI series they claim to use. Fails when the RPI forecast in yoy_growth.yaml changes. Regenerate with `uv run python policyengine_uk/parameters/gov/hmrc/fuel_duty/calculate_fuel_duty_rates.py --update`. @@ -101,21 +109,60 @@ def test_forecast_steps_use_current_rpi_forecast(): parameters = raw_parameters() rpi = parameters.gov.economic_assumptions.yoy_growth.obr.rpi schedule = parameters.gov.hmrc.fuel_duty.petrol_and_diesel - rate = Decimal("0.5795") - for year in FORECAST_YEARS: - growth = Decimal(str(rpi(f"{year - 1}-01-01"))) + assert schedule(CUT_END_DATE) == pytest.approx(CUT_END_RATE, abs=1e-12) + rate = Decimal(str(CUT_END_RATE)) + for date in FORECAST_DATES: + day_before = ( + datetime.date.fromisoformat(date) - datetime.timedelta(days=1) + ).isoformat() + growth = Decimal(str(rpi(f"{int(date[:4]) - 1}-01-01"))) rate = (rate * (1 + growth)).quantize(Decimal("0.0001"), ROUND_HALF_UP) - assert schedule(f"{year}-04-01") == pytest.approx(float(rate), abs=1e-12), ( - f"{year}-04-01 is stale against the RPI forecast; run " + assert schedule(date) == pytest.approx(float(rate), abs=1e-12), ( + f"{date} is stale against the RPI forecast; run " + "calculate_fuel_duty_rates.py --update" + ) + assert schedule(day_before) != schedule(date) + + +@pytest.mark.parametrize( + "fuel, full_rate, published", + [("lpg", "0.3161", "0.3305"), ("natural_gas", "0.2470", "0.2582")], +) +def test_road_fuel_gases_get_the_same_rpi_uprating(fuel, full_rate, published): + """LPG and natural gas take the proportionate cut to 1 July 2027 (Budget + 2026, paragraph 6.211; costings page 13). Their full rates then get the + OBR's 1 July uprating for petrol and diesel, 57.95p to 60.59p, and RPI + each 1 April from 2028. HM Treasury publishes the 1 July 2027 rates + (Overview of Tax Legislation and Rates, Annex A), and the schedule uses + them: LPG's matches the uprating, and natural gas's 25.82p falls a + hundredth of a penny short of the uprating's 25.83p.""" + parameters = raw_parameters() + rpi = parameters.gov.economic_assumptions.yoy_growth.obr.rpi + schedule = getattr(parameters.gov.hmrc.fuel_duty, fuel) + july_growth = Decimal("0.045565") + assert (Decimal("0.5795") * (1 + july_growth)).quantize( + Decimal("0.0001"), ROUND_HALF_UP + ) == Decimal(str(CUT_END_RATE)) + uprated = (Decimal(full_rate) * (1 + july_growth)).quantize( + Decimal("0.0001"), ROUND_HALF_UP + ) + rate = Decimal(published) + assert abs(rate - uprated) <= Decimal("0.0001") + assert schedule("2027-06-30") < float(full_rate) + assert schedule(CUT_END_DATE) == pytest.approx(float(rate), abs=1e-12) + for date in FORECAST_DATES: + growth = Decimal(str(rpi(f"{int(date[:4]) - 1}-01-01"))) + rate = (rate * (1 + growth)).quantize(Decimal("0.0001"), ROUND_HALF_UP) + assert schedule(date) == pytest.approx(float(rate), abs=1e-12), ( + f"{fuel} {date} is stale against the RPI forecast; run " "calculate_fuel_duty_rates.py --update" ) - assert schedule(f"{year}-03-31") != schedule(f"{year}-04-01") def test_uprating_continues_each_april_after_the_written_steps(): schedule = dated_schedule() rpi = system.parameters.baseline.gov.economic_assumptions.yoy_growth.obr.rpi - for year in range(max(FORECAST_YEARS) + 1, 2041): + for year in range(LAST_FORECAST_YEAR + 1, 2041): previous = schedule(f"{year - 1}-04-01") assert schedule(f"{year}-03-31") == previous expected = previous * (1 + rpi(f"{year - 1}-01-01")) @@ -163,18 +210,20 @@ def fuel_duty_simulation(parameter_changes=None): return Microsimulation(situation=situation, scenario=scenario) -def test_holding_55_95p_for_2027_removes_the_full_2027_28_rate_rise(): - """Regression: calendar-year averaging cut this effect to 3.30p a litre. +def test_holding_52_95p_for_2027_removes_the_full_2027_28_rate_rise(): + """Regression: calendar-year averaging diluted annual overrides. - The 2027-28 baseline is 60.07p, so holding 55.95p saves 4.12p a litre. + The 2027-28 baseline averages 58.83p, so holding 52.95p for the whole + model year saves 5.88p a litre. """ baseline = fuel_duty_simulation() - reformed = fuel_duty_simulation({PATH: {"2027": 0.5595}}) + reformed = fuel_duty_simulation({PATH: {"2027": 0.5295}}) saving = ( baseline.calculate("fuel_duty", 2027).values[0] - reformed.calculate("fuel_duty", 2027).values[0] ) - assert saving == pytest.approx(1_000 * (HAND_COMPUTED[2027] - 0.5595)) + # fuel_duty is float32: about £590 each side, so allow a tenth of a penny. + assert saving == pytest.approx(1_000 * (HAND_COMPUTED[2027] - 0.5295), abs=1e-3) @settings(max_examples=8, deadline=None) diff --git a/policyengine_uk/tests/test_household_energy_payment_income_measures.py b/policyengine_uk/tests/test_household_energy_payment_income_measures.py new file mode 100644 index 0000000000..8ac05ccfd5 --- /dev/null +++ b/policyengine_uk/tests/test_household_energy_payment_income_measures.py @@ -0,0 +1,47 @@ +"""The Household Energy Payment in the income measures. + +The £100 payment (Budget 2026, paragraph 6.24) raises household net income +and HBAI income alike: HBAI counted the 2022 Council Tax Rebate and the +Energy Bills Support Scheme, the same kind of one-off support, as +miscellaneous income (HBAI quality and methodology report FYE 2023). +""" + +import pytest + +from policyengine_uk import Simulation + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +YEAR = 2026 +WITHOUT_PAYMENT = {"gov.treasury.household_energy_payment.amount": {str(YEAR): 0}} +SITUATION = { + "people": {"adult": {"age": {YEAR: 40}, "employment_income": {YEAR: 30_000}}}, + "benunits": {"benunit": {"members": ["adult"]}}, + "households": { + "household": { + "members": ["adult"], + "region": {YEAR: "NORTH_WEST"}, + "council_tax_band": {YEAR: "B"}, + } + }, +} + + +def measures(reform=None): + sim = Simulation(situation=SITUATION, reform=reform) + return { + v: float(sim.calculate(v, YEAR)[0]) + for v in [ + "household_energy_payment", + "household_net_income", + "hbai_household_net_income", + ] + } + + +def test_payment_raises_net_income_and_hbai_income_by_100(): + law, without = measures(), measures(WITHOUT_PAYMENT) + assert law["household_energy_payment"] == 100 + assert without["household_energy_payment"] == 0 + for income in ("household_net_income", "hbai_household_net_income"): + assert law[income] - without[income] == pytest.approx(100, abs=0.01) diff --git a/policyengine_uk/tests/test_isa_accounting.py b/policyengine_uk/tests/test_isa_accounting.py index 7f733371f9..5378861755 100644 --- a/policyengine_uk/tests/test_isa_accounting.py +++ b/policyengine_uk/tests/test_isa_accounting.py @@ -65,7 +65,11 @@ def test_lisa_bonus_and_charge_reach_only_their_declared_fiscal_totals(): def test_noncash_isa_interest_charge_reaches_tax_and_final_current_income_once(): - """From fiscal2027,100 of ISA interest gives22 tax and78 net income.""" + """From fiscal 2027, 100 of ISA interest gives 24 tax and 76 net income. + + The charge is at the savings basic rate in force for the year (SI + 2026/1018 reg. 15, new reg. 22A(3)), 24% from 2027-28 after Budget 2026. + """ situation = { "people": { "person": { @@ -83,16 +87,16 @@ def test_noncash_isa_interest_charge_reaches_tax_and_final_current_income_once() baseline = Simulation(situation=situation) changed = Simulation(situation=with_return) assert changed.calculate("isa_cash_interest_flat_rate_charge", 2027).tolist() == [ - 22 + 24 ] for variable, expected in ( ("market_income", 100), ("capital_income", 100), ("household_market_income", 100), - ("gov_tax", 22), - ("household_tax", 22), - ("household_net_income", 78), - ("hbai_household_net_income", 78), + ("gov_tax", 24), + ("household_tax", 24), + ("household_net_income", 76), + ("hbai_household_net_income", 76), ("income_tax", 0), ): difference = changed.calculate(variable, 2027) - baseline.calculate( diff --git a/policyengine_uk/tests/test_pension_annual_allowance_properties.py b/policyengine_uk/tests/test_pension_annual_allowance_properties.py index 2e6c991df9..4f28f279d5 100644 --- a/policyengine_uk/tests/test_pension_annual_allowance_properties.py +++ b/policyengine_uk/tests/test_pension_annual_allowance_properties.py @@ -165,15 +165,16 @@ def _situation(people, year, overrides=None): # Without receipts supplied, the model reads a trading profit within the -# trading allowance (£1,000 from 2017-18, so in every year here) as fully -# relieved (ITTOIA 2005 s. 783AF). It is then not chargeable under Part 2 and -# so is not relevant UK earnings (FA 2004 s. 189(2)(b)). -TRADING_ALLOWANCE = 1_000 +# trading allowance as fully relieved (ITTOIA 2005 s. 783AF). It is then not +# chargeable under Part 2 and so is not relevant UK earnings (FA 2004 +# s. 189(2)(b)). The allowance is £1,000 from 2017-18 and £3,000 from 2027-28 +# (Budget 2026, paragraph 6.254). +TRADING_ALLOWANCE = {2020: 1_000, 2024: 1_000, 2026: 1_000, 2027: 3_000} -def _relevant_earnings(person): +def _relevant_earnings(person, year): profit = max(0, person["self_employment_income"]) - chargeable_profit = profit if profit > TRADING_ALLOWANCE else 0 + chargeable_profit = profit if profit > TRADING_ALLOWANCE[year] else 0 return person["employment_income"] + chargeable_profit @@ -194,18 +195,21 @@ def _calc(people, year, variables): return {v: np.array(sim.calculate(v, year), dtype=float) for v in variables} -def _base_contributions(people): - return [p["contribution_share"] * max(_relevant_earnings(p), 3_600) for p in people] +def _base_contributions(people, year): + return [ + p["contribution_share"] * max(_relevant_earnings(p, year), 3_600) + for p in people + ] @PROPERTY_SETTINGS @given(population_strategy, st.sampled_from(YEARS)) def test_relief_is_the_section_190_limit(people, year): - people = _with_contributions(people, _base_contributions(people)) + people = _with_contributions(people, _base_contributions(people, year)) out = _calc(people, year, ["pension_contributions_relief"]) basic_amount = 3_600 for i, person in enumerate(people): - earnings = _relevant_earnings(person) + earnings = _relevant_earnings(person, year) relief_at_source = person["personal_pension_contributions"] total = relief_at_source + person["employee_pension_contributions_reported"] limit = earnings + min(max(0, basic_amount - earnings), relief_at_source) @@ -223,7 +227,7 @@ def test_extra_contributions_never_raise_tax_by_more_than_the_charge( people, year, step ): # Two contribution levels, both within relevant earnings. - earnings = np.array([max(_relevant_earnings(p), 3_600) for p in people]) + earnings = np.array([max(_relevant_earnings(p, year), 3_600) for p in people]) low = np.array([min(p["contribution_share"], 1) for p in people]) * earnings high = low + step * (earnings - low) variables = ["income_tax", "personal_pension_contributions_tax"] @@ -318,7 +322,7 @@ def test_contributions_above_the_allowance_are_not_taxed_twice(people, year, ste np.array(zero_sim.calculate("net_income", year), dtype=float) - restored_allowances, ) - earnings = np.array([_relevant_earnings(p) for p in people], dtype=float) + earnings = np.array([_relevant_earnings(p, year) for p in people], dtype=float) upper = np.minimum(earnings, taxable_income) employer = np.array([p["employer_pension_contributions"] for p in people]) at_allowance = np.clip(allowance - employer, 0, earnings) @@ -433,7 +437,7 @@ def _reference_ras_extension_eligibility( @PROPERTY_SETTINGS @given(reference_population_strategy(), st.sampled_from(YEARS)) def test_charge_matches_band_by_band_reference(people, year): - people = _with_contributions(people, _base_contributions(people)) + people = _with_contributions(people, _base_contributions(people, year)) sim = Simulation(situation=_situation(people, year)) chargeable = np.array( sim.calculate("pension_annual_allowance_chargeable_amount", year), dtype=float @@ -517,7 +521,7 @@ def test_charge_matches_band_by_band_reference(people, year): @PROPERTY_SETTINGS @given(population_strategy, st.sampled_from(YEARS)) def test_gift_aid_never_increases_tax_with_stable_ras_eligibility(people, year): - people = _with_contributions(people, _base_contributions(people)) + people = _with_contributions(people, _base_contributions(people, year)) # Compare the same pension/input amounts with zero, drawn, and larger # gifts, across UK and Scottish rates and mixed income sources. zero_gift = [{**p, "gift_aid": 0} for p in people] @@ -557,7 +561,7 @@ def test_gift_aid_never_increases_tax_with_stable_ras_eligibility(people, year): @PROPERTY_SETTINGS @given(population_strategy, st.sampled_from(YEARS), st.integers(0, 150_000)) def test_unused_allowance_reduces_chargeable_amount_one_for_one(people, year, extra): - people = _with_contributions(people, _base_contributions(people)) + people = _with_contributions(people, _base_contributions(people, year)) variables = [ "pension_annual_allowance_chargeable_amount", "personal_pension_contributions_tax", diff --git a/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py b/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py index f9d3bf1557..a9385d2b8b 100644 --- a/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py +++ b/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py @@ -639,19 +639,36 @@ def test_zero_phase_in_modifier_preserves_a_reformed_own_threshold(): ) +PENSION_TAX_ALLOWANCE = "gov.hmrc.income_tax.reliefs.pension_tax_allowance.in_effect" + + @pytest.mark.parametrize( - "weekly_guarantee,expected_threshold,expected_allowance,expected_step", - [(None, 223.407952, 274.662918, 0.87), (300, 267.965646, 319.220612, 1.38)], + "weekly_guarantee,pension_tax_allowance,expected_threshold,expected_allowance,expected_step", + [ + # From 2027-28 the Budget 2026 Pension Tax Allowance takes the + # claimant out of Income Tax at the boundary, and Pension Credit + # counts income net of tax, so on this £5 grid guarantee credit runs + # out between £13,280 and £13,285 of private pension rather than + # between £13,460 and £13,465. The step is the gain in household net + # income across that £5. + (None, True, 223.407952, 274.662918, 1.98), + (None, False, 223.407952, 274.662918, 0.87), + (300, True, 267.965646, 319.220612, 1.38), + ], ) def test_guarantee_credit_boundary_matches_main( - weekly_guarantee, expected_threshold, expected_allowance, expected_step + weekly_guarantee, + pension_tax_allowance, + expected_threshold, + expected_allowance, + expected_step, ): - """Frozen origin/main (6030cbc0b) cliff-probe values, including current law, - re-based on the October 2026 EFO. Current law: a 2028-29 standard minimum - guarantee of 238 x 1.039 x 1.033 = 255.4423 (May-July 2026 and 2027 - earnings) less a maximum Savings Credit of 17.96 x 1.035 x 1.034 = - 19.2207 (September 2026 and 2027 CPI) over 0.6 gives the threshold; the - Housing Benefit allowance is the sum of the two.""" + """Frozen origin/main (6030cbc0b) cliff-probe values, including current law + without the Pension Tax Allowance, re-based on the October 2026 EFO. + Current law: a 2028-29 standard minimum guarantee of 238 x 1.039 x 1.033 = + 255.4423 (May-July 2026 and 2027 earnings) less a maximum Savings Credit of + 17.96 x 1.035 x 1.034 = 19.2207 (September 2026 and 2027 CPI) over 0.6 + gives the threshold; the Housing Benefit allowance is the sum of the two.""" year = 2028 situation = { "people": {"claimant": {"age": {year: 85}}}, @@ -679,10 +696,12 @@ def test_guarantee_credit_boundary_matches_main( ] ], } - changes = {f"{GUARANTEE}.SINGLE": {"2026-01-01.2100-12-31": weekly_guarantee}} - simulation = Simulation( - situation=situation, reform=changes if weekly_guarantee is not None else None - ) + changes = {} + if weekly_guarantee is not None: + changes[f"{GUARANTEE}.SINGLE"] = {"2026-01-01.2100-12-31": weekly_guarantee} + if not pension_tax_allowance: + changes[PENSION_TAX_ALLOWANCE] = {"2026-01-01.2100-12-31": False} + simulation = Simulation(situation=situation, reform=changes or None) parameters = simulation.tax_benefit_system.parameters assert parameters.gov.dwp.pension_credit.savings_credit.threshold.SINGLE( "2028-04-30" diff --git a/policyengine_uk/tests/test_pension_tax_allowance_properties.py b/policyengine_uk/tests/test_pension_tax_allowance_properties.py new file mode 100644 index 0000000000..b1323fc15a --- /dev/null +++ b/policyengine_uk/tests/test_pension_tax_allowance_properties.py @@ -0,0 +1,225 @@ +"""Property tests for the Budget 2026 Pension Tax Allowance. + +The allowance (paragraph 6.182; Table 6.1, item 52) is 20% of the full new +State Pension above the Personal Allowance, rounded up to the pound, tapered +by 1 for every 3 of total income above the full new State Pension, for people +over State Pension age or reaching it within the tax year, from 6 April 2027 +to 5 April 2030. It is a reduction at Step 6 of the Income Tax calculation, so +it cannot take the tax below zero. Each test draws people and checks +identities that hold for every input: + +- the allowance lies between zero and the tapered full allowance, so it is + zero from three full allowances above the threshold; +- people who reach State Pension age after the tax year get nothing; +- Income Tax falls by exactly the allowance relative to the same law without + it, and never goes below zero; +- the taper has no cliff edge: each extra pound of pension income takes at + most a third of a pound off the allowance; +- the threshold is 52 weeks of the new State Pension at its weekly rate, so + someone on the full new State Pension and nothing else pays no Income Tax, + whatever the rate. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +YEARS = [2027, 2028, 2029] +CASES = 24 +STEP = 25.0 +PTA = "gov.hmrc.income_tax.reliefs.pension_tax_allowance" +WITHOUT_PTA = {f"{PTA}.in_effect": {str(year): False for year in YEARS}} +REGIONS = ["LONDON", "WALES", "NORTHERN_IRELAND", "SCOTLAND"] + +amount = st.one_of( + st.floats(min_value=0, max_value=20_000), + st.floats(min_value=12_000, max_value=14_500), + st.sampled_from([0.0, 12_570.0, 13_036.40, 13_318.40]), +) +case = st.tuples( + st.sampled_from([60, 65, 66, 67, 70, 85]), # age + amount, # State Pension + amount, # private pension + st.one_of(st.just(0.0), amount), # employment income + st.one_of(st.just(0.0), st.floats(min_value=0, max_value=3_000)), # savings +) +cases = st.lists(case, min_size=CASES, max_size=CASES) +years = st.sampled_from(YEARS) + + +def situation(): + # The second half repeats the first with STEP more private pension. + n = 2 * CASES + return { + "people": {f"person_{i}": {} for i in range(n)}, + "benunits": {f"benunit_{i}": {"members": [f"person_{i}"]} for i in range(n)}, + "households": { + f"household_{i}": { + "members": [f"person_{i}"], + "region": {year: REGIONS[i % len(REGIONS)] for year in YEARS}, + } + for i in range(n) + }, + } + + +@pytest.fixture(scope="module") +def sims(): + return Simulation(situation=situation()), Simulation( + situation=situation(), reform=WITHOUT_PTA + ) + + +VARIABLES = [ + "months_since_state_pension_age", + "personal_allowance", + "total_income", + "income_tax_pre_charges", + "pension_tax_allowance", + "income_tax", +] + + +def run(sims, drawn, year): + age, state_pension, private_pension, employment, savings = ( + np.tile(np.array(column), 2) for column in zip(*drawn) + ) + private_pension[CASES:] += STEP + results = [] + for sim in sims: + sim.set_input("age", year, age) + sim.set_input("state_pension", year, state_pension) + sim.set_input("private_pension_income", year, private_pension) + sim.set_input("employment_income", year, employment) + sim.set_input("savings_interest_income", year, savings) + sim.reset_calculations() + results.append({v: sim.calculate(v, year).astype(float) for v in VARIABLES}) + return results + + +def full_allowance(sim, year): + p = sim.tax_benefit_system.parameters.gov.hmrc.income_tax + threshold = 52 * new_state_pension(sim, year) + allowance = p.allowances.personal_allowance.amount(f"{year}-04-30") + return threshold, np.ceil(round(0.2 * (threshold - allowance), 6)) + + +def tolerance(*values): + """A penny plus float32 rounding on the amounts involved.""" + return 0.01 + 1e-6 * sum(np.abs(v) for v in values) + + +SETTINGS = settings( + max_examples=25, + deadline=None, + suppress_health_check=[HealthCheck.too_slow], +) + + +@SETTINGS +@given(cases, years) +def test_allowance_lies_between_zero_and_the_tapered_full_allowance(sims, drawn, year): + law, _ = run(sims, drawn, year) + threshold, full = full_allowance(sims[0], year) + pta = law["pension_tax_allowance"] + excess = np.maximum(0, law["total_income"] - threshold) + tapered = np.maximum(0, full - excess / 3) + tol = tolerance(law["total_income"]) + assert full > 0 + assert (pta >= 0).all() + assert (pta <= tapered + tol).all() + assert (pta[excess >= 3 * full] == 0).all() + + +@SETTINGS +@given(cases, years) +def test_people_reaching_state_pension_age_after_the_year_get_nothing( + sims, drawn, year +): + law, _ = run(sims, drawn, year) + later = law["months_since_state_pension_age"] <= -6 + assert (law["pension_tax_allowance"][later] == 0).all() + + +@SETTINGS +@given(cases, years) +def test_income_tax_falls_by_exactly_the_allowance(sims, drawn, year): + law, without = run(sims, drawn, year) + pta = law["pension_tax_allowance"] + fall = without["income_tax"] - law["income_tax"] + tol = tolerance(without["income_tax"]) + assert (law["income_tax"] >= 0).all() + assert (np.abs(fall - pta) <= tol).all() + assert (without["pension_tax_allowance"] == 0).all() + + +@SETTINGS +@given(cases, years) +def test_the_allowance_falls_by_at_most_a_third_of_extra_income(sims, drawn, year): + law, _ = run(sims, drawn, year) + pta = law["pension_tax_allowance"] + base, more = pta[:CASES], pta[CASES:] + # Capped at the tax due, which rises with income, the allowance can fall + # no faster than the 1-in-3 taper: no cliff edge at the threshold or at + # the point where the cap stops binding. + assert (base - more <= STEP / 3 + 0.01).all() + + +def new_state_pension(sim, year): + nsp = sim.tax_benefit_system.parameters.gov.dwp.state_pension.new_state_pension + return nsp.amount(f"{year}-04-30") + + +def pensioner(year, state_pension): + return { + "people": { + "pensioner": {"age": {year: 70}, "state_pension": {year: state_pension}} + }, + "benunits": {"benunit": {"members": ["pensioner"]}}, + "households": { + "household": {"members": ["pensioner"], "region": {year: "LONDON"}} + }, + } + + +RATE = "gov.dwp.state_pension.new_state_pension.amount" + + +def tax_on_the_full_new_state_pension(year, reform): + weekly = new_state_pension( + Simulation(situation=pensioner(year, 0), reform=reform), year + ) + sim = Simulation(situation=pensioner(year, 52 * weekly), reform=reform) + return ( + float(sim.calculate("income_tax", year)[0]), + float(sim.calculate("pension_tax_allowance", year)[0]), + ) + + +@SETTINGS +@given(st.floats(min_value=0, max_value=400), years) +def test_the_full_new_state_pension_pays_no_income_tax_at_any_rate(weekly, year): + # The threshold is 52 weeks at the weekly rate, so it follows a reform to + # the rate, and a rate of nil gives nothing rather than dividing by zero. + income_tax, pta = tax_on_the_full_new_state_pension(year, {RATE: weekly}) + assert income_tax == pytest.approx(0, abs=0.01) + assert np.isfinite(pta) and pta >= 0 + + +@pytest.mark.parametrize( + "year, reform", + [(2027, None), (2028, None), (2029, None), (2028, {RATE: {"2028": 300}})], +) +def test_the_full_new_state_pension_pays_no_income_tax(year, reform): + # Without a reform, and with the rate raised to £300 for 2028-29 only: + # the allowance there is 20% x (15,600 - 12,570) = £606. + income_tax, pta = tax_on_the_full_new_state_pension(year, reform) + assert income_tax == pytest.approx(0, abs=0.01) + assert pta > 0 + if reform: + assert pta == pytest.approx(606, abs=0.01) diff --git a/policyengine_uk/tests/test_pension_tax_allowance_universal_credit.py b/policyengine_uk/tests/test_pension_tax_allowance_universal_credit.py new file mode 100644 index 0000000000..a6b9fe76fb --- /dev/null +++ b/policyengine_uk/tests/test_pension_tax_allowance_universal_credit.py @@ -0,0 +1,89 @@ +"""The Pension Tax Allowance in Universal Credit. + +Universal Credit deducts the income tax paid on earnings (UC Regulations 2013 +reg. 55(5)(b) and 57(2)), which it works out from the rates less the Step 6 +reductions in gov.hmrc.income_tax.step_6_tax_reductions. The allowance is +on that list, so for a mixed-age couple whose older partner works, it raises +earned income and UC takes back the taper share of it. UC counts pension +income before tax (reg. 66), so there the allowance leaves UC unchanged. + +The working case is Pavel Makarchuk's on uk-autumn-budget-dashboard-2026 #290: +an unmarried couple aged 70 and 60 renting in the North West in 2027-28, the +older partner earning £13,036.40 with the State Pension deferred. +""" + +import pytest + +from policyengine_uk import Simulation + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +YEAR = 2027 +WITHOUT_PTA = { + "gov.hmrc.income_tax.reliefs.pension_tax_allowance.in_effect": {str(YEAR): False} +} + + +def couple(older_income): + return { + "people": { + "older": { + "age": {YEAR: 70}, + **{k: {YEAR: v} for k, v in older_income.items()}, + }, + "younger": {"age": {YEAR: 60}}, + }, + "benunits": { + "benunit": { + "members": ["older", "younger"], + # Unmarried, so no Marriage Allowance transfer + "is_married": {YEAR: False}, + } + }, + "households": { + "household": { + "members": ["older", "younger"], + "region": {YEAR: "NORTH_WEST"}, + "tenure_type": {YEAR: "RENT_PRIVATELY"}, + "rent": {YEAR: 7_200}, + } + }, + } + + +def results(situation, reform=None): + sim = Simulation(situation=situation, reform=reform) + return { + "pension_tax_allowance": float(sim.calculate("pension_tax_allowance", YEAR)[0]), + "universal_credit": float(sim.calculate("universal_credit", YEAR)[0]), + "household_net_income": float(sim.calculate("household_net_income", YEAR)[0]), + "taper": sim.tax_benefit_system.parameters.gov.dwp.universal_credit.means_test.reduction_rate( + f"{YEAR}-04-30" + ), + } + + +def test_uc_takes_back_the_taper_share_of_the_allowance_on_earnings(): + situation = couple({"employment_income": 13_036.40}) + law, without = results(situation), results(situation, WITHOUT_PTA) + assert law["pension_tax_allowance"] == pytest.approx(93.28, abs=0.01) + assert without["universal_credit"] > 0 + uc_change = law["universal_credit"] - without["universal_credit"] + # 55% x 93.28 = 51.30, leaving the couple 41.97 better off + assert uc_change == pytest.approx(-law["taper"] * 93.28, abs=0.01) + assert uc_change == pytest.approx(-51.30, abs=0.01) + assert law["household_net_income"] - without["household_net_income"] == ( + pytest.approx(41.97, abs=0.01) + ) + + +def test_uc_ignores_the_allowance_on_pension_income(): + situation = couple({"private_pension_income": 13_036.40}) + law, without = results(situation), results(situation, WITHOUT_PTA) + assert law["pension_tax_allowance"] == pytest.approx(93.28, abs=0.01) + assert law["universal_credit"] == pytest.approx( + without["universal_credit"], abs=0.01 + ) + assert law["household_net_income"] - without["household_net_income"] == ( + pytest.approx(93.28, abs=0.01) + ) diff --git a/policyengine_uk/tests/test_private_school_vat_receipts.py b/policyengine_uk/tests/test_private_school_vat_receipts.py index 73fbee9b34..50af153c2f 100644 --- a/policyengine_uk/tests/test_private_school_vat_receipts.py +++ b/policyengine_uk/tests/test_private_school_vat_receipts.py @@ -353,10 +353,14 @@ def test_vat_is_never_negative_and_receipts_are_counted_once(households, year, f 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 + # 1 October 2026, and Budget 2026 extends that to 1 July 2027 and to + # Northern Ireland from 1 April 2027. Model year 2026 has 178 of 365 days + # at 5% in Great Britain and 360 in Northern Ireland; model year 2027 has + # 280 of 366 in both. Every other year stays at 5%. + if year == 2026: + return 0.05 * (360 if region == "NORTHERN_IRELAND" else 178) / 365 + if year == 2027: + return 0.05 * 280 / 366 return 0.05 diff --git a/policyengine_uk/tests/test_property_income_properties.py b/policyengine_uk/tests/test_property_income_properties.py index 5ea41141c3..4fa3853c96 100644 --- a/policyengine_uk/tests/test_property_income_properties.py +++ b/policyengine_uk/tests/test_property_income_properties.py @@ -23,7 +23,6 @@ YEAR = 2027 ALLOWANCE = 1_000 -PROPERTY_BASIC_RATE = 0.22 OTHER_EMPLOYMENT_INCOME = [0, 8_000, 13_000, 30_000, 60_000, 110_000, 200_000] # Every third person is a Scottish taxpayer. SCOTTISH = [i % 3 == 2 for i in range(7)] @@ -198,7 +197,13 @@ def test_taxable_profit_matches_statutory_reference(sim, drawn): ).all() +# Finance costs relieved in full below the tax due: the reduction is the +# whole property basic rate on them (24% from 2027-28 after Budget 2026). +UNCAPPED_RELIEF = (1_471.59, 57_327.72, 0.0, 500.0, "below_profit", 0.45, 121.28) + + @SETTINGS +@example([UNCAPPED_RELIEF] * CASES) @given(cases) def test_relief_accounting(sim, drawn): r = run(sim, drawn) @@ -232,11 +237,11 @@ def test_relief_accounting(sim, drawn): ).all() # The reduction is at most the property basic rate on the costs, and at # most the Step 5 tax. + rate = sim.tax_benefit_system.parameters.gov.hmrc.income_tax.rates.property.basic( + f"{YEAR}-04-30" + ) assert (r["relief"] >= 0).all() - assert ( - r["relief"] - <= PROPERTY_BASIC_RATE * r["relievable"] + tolerance(r["relievable"]) - ).all() + assert (r["relief"] <= rate * r["relievable"] + tolerance(r["relievable"])).all() assert (r["relief"] <= r["pre_charges"] + tolerance(r["pre_charges"])).all() # What is not relieved is carried forward; on the allowance route this # year's costs are replaced by the allowance and only the amount brought diff --git a/policyengine_uk/tests/test_property_income_rate_reforms.py b/policyengine_uk/tests/test_property_income_rate_reforms.py index a32895ba8a..2ddeaa5e38 100644 --- a/policyengine_uk/tests/test_property_income_rate_reforms.py +++ b/policyengine_uk/tests/test_property_income_rate_reforms.py @@ -95,8 +95,10 @@ def test_from_2027_main_rate_reform_leaves_separate_property_rate_unchanged( sim = component_simulation( year, 10_000, 5_000, country=country, rates={UK_RATE.format(0): 0.21} ) + # 5,000 at the 24% property basic rate (22% from Budget 2025, 2 points + # more from Budget 2026). assert sim.calculate("property_income_tax", year)[0] == pytest.approx( - 1_100, abs=0.01 + 1_200, abs=0.01 ) @@ -142,7 +144,7 @@ def test_zero_property_income_has_zero_property_tax_under_reform(year): @pytest.mark.parametrize( - "year, expected", [(2025, 1_000), (2026, 1_000), (2027, 1_100), (2028, 1_100)] + "year, expected", [(2025, 1_000), (2026, 1_000), (2027, 1_200), (2028, 1_200)] ) def test_baseline_property_rates_retain_annual_2027_boundary(year, expected): sim = component_simulation(year, 10_000, 5_000) diff --git a/policyengine_uk/tests/test_savings_starting_rate_indexation.py b/policyengine_uk/tests/test_savings_starting_rate_indexation.py index f5df01c766..ce6eb5eca9 100644 --- a/policyengine_uk/tests/test_savings_starting_rate_indexation.py +++ b/policyengine_uk/tests/test_savings_starting_rate_indexation.py @@ -118,7 +118,8 @@ def savings_situation(): def test_indexed_starting_rate_reaches_final_savings_income_tax(route): # Hold the PA at £12,570 to isolate this band's effect. With £20,000 # interest and the unchanged £1,000 PSA, £6,430 remains before the - # starting rate. The unchanged post-2027 basic savings rate is 22%. + # starting rate. The basic savings rate from 2027-28 is 24% (22% from + # Budget 2025 plus 2 points from Budget 2026). changes = { CPI: {"2030-09-01.2038-08-31": 0.02}, PA: {"2030-04-06.2034-04-05": 12_570}, @@ -132,10 +133,10 @@ def test_indexed_starting_rate_reaches_final_savings_income_tax(route): reform = changes if route == "dict" else Reform.from_dict(changes) simulation = Simulation(situation=savings_situation(), reform=reform) for year, starter, tax in [ - (2030, 5_000, 314.60), - (2031, 5_100, 292.60), - (2032, 5_210, 268.40), - (2033, 5_320, 244.20), + (2030, 5_000, 343.20), + (2031, 5_100, 319.20), + (2032, 5_210, 292.80), + (2033, 5_320, 266.40), ]: assert simulation.calculate("personal_allowance", year)[0] == 12_570 assert simulation.calculate("savings_allowance", year)[0] == 1_000 @@ -176,11 +177,11 @@ def test_dated_limit_override_resumes_indexation_from_the_changed_limit(route): assert limit(simulation.tax_benefit_system, 2032) == 6_120 assert limit(control.tax_benefit_system, 2032) == 5_210 # With genuine £20,000 interest, the held PA and natural PSA leave - # £6,430 before the starting rate: (6,430 - limit) x 22%. + # £6,430 before the starting rate: (6,430 - limit) x 24%. for current, year, tax in [ - (simulation, 2031, 94.60), - (simulation, 2032, 68.20), - (control, 2032, 268.40), + (simulation, 2031, 103.20), + (simulation, 2032, 74.40), + (control, 2032, 292.80), ]: assert current.calculate("personal_allowance", year)[0] == 12_570 assert current.calculate("savings_allowance", year)[0] == 1_000 @@ -242,5 +243,5 @@ def test_reset_and_reprocessing_keep_the_changed_savings_indexation_path(): assert simulation.calculate("savings_allowance", 2032)[0] == 1_000 for variable in ("savings_income_tax", "income_tax"): np.testing.assert_allclose( - simulation.calculate(variable, 2032), [68.20], rtol=0, atol=0.001 + simulation.calculate(variable, 2032), [74.40], rtol=0, atol=0.001 ) diff --git a/policyengine_uk/tests/test_trading_allowance_properties.py b/policyengine_uk/tests/test_trading_allowance_properties.py index f24978ca8b..1835606357 100644 --- a/policyengine_uk/tests/test_trading_allowance_properties.py +++ b/policyengine_uk/tests/test_trading_allowance_properties.py @@ -17,15 +17,18 @@ pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") YEAR = 2027 -ALLOWANCE = 1_000 +# £3,000 from 6 April 2027 (Budget 2026, paragraph 6.254); £1,000 before. +ALLOWANCE = 3_000 OTHER_EMPLOYMENT_INCOME = [0, 8_000, 30_000, 60_000, 110_000, 200_000] CASES = 24 # Each case is simulated twice: as drawn, and with more profit. PEOPLE = 2 * CASES amount = st.one_of( - st.floats(min_value=0, max_value=2_500), + st.floats(min_value=0, max_value=7_500), st.floats(min_value=0, max_value=300_000), - st.sampled_from([0.0, 999.99, 1_000.0, 1_000.01, 12_570.0, 50_270.0]), + st.sampled_from( + [0.0, 999.99, 1_000.0, 2_999.99, 3_000.0, 3_000.01, 12_570.0, 50_270.0] + ), ) case = st.tuples( st.one_of(amount, st.floats(min_value=-5_000, max_value=0)), # profit diff --git a/policyengine_uk/utils/benefit_unit.py b/policyengine_uk/utils/benefit_unit.py index cf966e9929..06ed4411b8 100644 --- a/policyengine_uk/utils/benefit_unit.py +++ b/policyengine_uk/utils/benefit_unit.py @@ -3,7 +3,9 @@ import numpy as np # The tolerance for reading a stored award: values within half a penny of -# each other are the same, and an award within half a penny of zero is none. +# each other are the same, and an award within half a penny of zero is none, +# except before the Carer's Allowance couple restriction, which counts any +# positive award (utils/carers_allowance_taper.py). HALF_PENNY = 0.005 # For each award award_of_members reads, the person-level variable saying the diff --git a/policyengine_uk/utils/carers_allowance_taper.py b/policyengine_uk/utils/carers_allowance_taper.py new file mode 100644 index 0000000000..e72d9a1de5 --- /dev/null +++ b/policyengine_uk/utils/carers_allowance_taper.py @@ -0,0 +1,129 @@ +"""Carer's Allowance before and under the Budget 2026 earnings taper. + +Budget 2026 replaces the cliff edge at the earnings limit with a taper in +England and Wales from a date within 2030-31. Before the taper starts, a carer +above the limit gets nothing; once it applies, they get the rate less the +earnings above the limit. In the year it starts, each rule covers part of the +year. The couple restriction and the overlapping-benefit rule work week by +week, so each part gets them on its own award, weighted by its share of the +year. + +A person's weekly award is the same in every part of the year in which it is +positive: the full rate wherever they are within the earnings limit, and the +tapered rate, which only the part under the taper pays, where their earnings +exceed it. So the annual amounts carry the parts as shares of the year: the +pre-overlap award is the candidate award scaled by the share the couple +restriction keeps, and the overlapping benefits come off for that share. + +The shares describe an annual award only where the model's own formulas +calculate it from the parts of the year, and only where it is, for every +member of the benefit unit, the amount the parts give, positive exactly where +they give an award. An award that an input +supplies, or that a reform neutralizes or calculates with a formula of its +own, carries no dates even where it equals the model's: a value can't show +where it came from. And a reform that keeps the formula can still change the +amount, for example with defined_for. Either way the award gets the +whole-year treatment, and so does every stage after it. +""" + +import numpy as np +from policyengine_core.commons.formulas import WEEKS_IN_YEAR + +from policyengine_uk.utils.benefit_unit import HALF_PENNY +from policyengine_uk.utils.couple_carer_award import limit_carer_benefit_to_one_partner +from policyengine_uk.utils.formula_origin import calculated_by +from policyengine_uk.utils.supplied_inputs import supplied_input + + +def carers_allowance_weekly_by_part_of_year(person, period, parameters): + """The weekly award, before the couple and overlap restrictions, in each + part of the year, as (share of the year, weekly award) pairs. The list leaves + out a part with no share of the year, so outside the year the taper + starts in there is one part.""" + ca = parameters(period).gov.dwp.carers_allowance + in_scotland = person.household("country", period).decode_to_str() == "SCOTLAND" + csp_replaces_ca = period.start.year >= 2025 + receives_ca = person("carers_allowance_reported", period) > 0 + meets_age_condition = person("age", period) >= ca.min_age + meets_work_condition = person("care_hours", period) >= ca.min_hours + would_claim = person("would_claim_carers_allowance", period) + eligible = ( + ~(in_scotland & csp_replaces_ca) + & (meets_work_condition | receives_ca) + & would_claim + & meets_age_condition + ) + # SSCBA 1992 s. 70(1)(b): no allowance for anyone gainfully employed, + # which reg. 8 of the 1976 Regulations defines by an earnings limit. + # It applies to reported recipients too. + gainfully_employed = person("is_gainfully_employed_for_carers_allowance", period) + # Budget 2026 (para 6.13) replaces that cliff edge in England and Wales + # with a taper: the weekly allowance less taper.rate for each pound of + # weekly earnings above the limit. + taper = ca.earnings_taper + country = person.household("country", period).decode_to_str() + excess = person("carers_allowance_weekly_earnings_above_limit", period) + tapered = np.isin(country, list(taper.countries)) * np.maximum( + 0, ca.rate - taper.rate * excess + ) + parts = ( + (1 - taper.applies, np.where(gainfully_employed, 0, ca.rate)), + (taper.applies, np.where(gainfully_employed, tapered, ca.rate)), + ) + return [(share, eligible * weekly) for share, weekly in parts if share > 0] + + +def carers_allowance_annual_award(parts): + """The annual award the parts of the year add up to.""" + return sum(share * weekly * WEEKS_IN_YEAR for share, weekly in parts) + + +def carers_allowance_shares_of_year(person, period, parts): + """From carers_allowance_weekly_by_part_of_year's parts: the share of the + year in which the person has an award before the couple restriction, and + the share in which the restriction leaves it with them. Where both + partners have the same weekly award in a part, the restriction prefers the + one with the larger award over the year, so a partner below the earnings + limit keeps the award all year at any taper rate of zero or more.""" + annual = carers_allowance_annual_award(parts) + candidate = kept = 0 + for share, weekly in parts: + selected = limit_carer_benefit_to_one_partner(person, period, weekly, annual) + candidate = candidate + share * (weekly > 0) + kept = kept + share * (selected > 0) + return candidate, kept + + +def calculated_by_model(person, period, model_variable): + """Whether the simulation calculates model_variable with the model's own + formula: no input supplies it, and no reform neutralizes it or puts a + formula of its own in place of the model's.""" + name = model_variable.__name__ + variable = person.simulation.tax_benefit_system.get_variable(name) + return ( + supplied_input(person, name, period) is None + and not variable.is_neutralized + and calculated_by(person, name, period, model_variable) + ) + + +def matches_parts_of_year(person, amount, parts_amount): + """Whether, for every member of the benefit unit, amount is parts_amount, + what the parts of the year give: within half a penny, since annual + amounts are float32, good to about a thousandth of a pound, and positive + exactly where parts_amount is, since the couple restriction chooses only + among partners whose award is positive.""" + same = (np.abs(amount - parts_amount) < HALF_PENNY) & ( + (amount > 0) == (parts_amount > 0) + ) + return person.benunit.all(same) + + +def carries_parts_of_year(person, period, model_variable, amount, parts_amount): + """Whether amount, the annual value of model_variable (a stage before the + payment), carries the parts of the year: the simulation calculates it with + the model's own formula, and it matches what the parts give, so nothing + else a reform changes, such as defined_for, has changed it.""" + return calculated_by_model(person, period, model_variable) & matches_parts_of_year( + person, amount, parts_amount + ) diff --git a/policyengine_uk/utils/couple_carer_award.py b/policyengine_uk/utils/couple_carer_award.py index e5d4ca950d..9900879c5a 100644 --- a/policyengine_uk/utils/couple_carer_award.py +++ b/policyengine_uk/utils/couple_carer_award.py @@ -39,17 +39,30 @@ def unlimited_carer_benefit_entitlement(person, period): return (amounts[0] + amounts[1] > 0) | receives -def limit_carer_benefit_to_one_partner(person, period, candidate): +def limit_carer_benefit_to_one_partner(person, period, candidate, annual=None): """One candidate award for a same-target couple, before overlap reductions. - Election/discretion is not observed. Prefer an eligible reported recipient, - then an eligible benefit-unit head, then the eldest eligible partner (stable - member order breaks age ties). Other members and units with other than two - claimant/partner flags are outside this couple-only restriction. + The couple jointly elect which of them has the entitlement (SSCBA 1992 + s.70(7ZA)(a)). The data don't record the election, so the model takes the + one that gives the larger award, as it does for other elections it models. + The model's own awards differ only under the Carer's Allowance earnings + taper, though supplied awards can differ in any year. In the year the + taper starts, the restriction runs on each part of the year, and annual, + each person's award over the whole year, decides between equal awards in + a part, so a partner with the award all year keeps it in weeks where the + other's award equals theirs. Where the awards are still equal, prefer an + eligible reported recipient, then an eligible benefit-unit head, then the + eldest eligible partner (stable member order breaks age ties). Other + members and units with other than two claimant/partner flags are outside + this couple-only restriction. """ benunit = person.benunit partner = person("is_claimant_or_partner", period) - eligible = partner & (candidate > 0) + largest = benunit.max(np.where(partner, candidate, 0)) + eligible = partner & (candidate > 0) & (candidate >= largest) + if annual is not None: + largest_annual = benunit.max(np.where(eligible, annual, 0)) + eligible = eligible & (annual >= largest_annual) reported = eligible & (person("carers_allowance_reported", period) > 0) preferred = np.where(benunit.any(reported), reported, eligible) head = preferred & person("is_benunit_head", period) diff --git a/policyengine_uk/utils/formula_origin.py b/policyengine_uk/utils/formula_origin.py new file mode 100644 index 0000000000..32762115ea --- /dev/null +++ b/policyengine_uk/utils/formula_origin.py @@ -0,0 +1,20 @@ +"""Whether a simulation calculates a variable with the model's own formula.""" + +import os + + +def calculated_by(population, variable_name, period, model_variable): + """Whether the simulation calculates ``variable_name`` with the model's own + formula (``model_variable``'s), rather than one a reform put in its place. + The tax-benefit system loads each variable file as its own module, so this + matches the formula by where its code is, not by identity.""" + from policyengine_uk.scenarios.uc_reform import lcwra_formula_origin + + variable = population.simulation.tax_benefit_system.get_variable(variable_name) + formula = lcwra_formula_origin(variable.get_formula(period), period) + code = getattr(formula, "__code__", None) + model = model_variable.formula.__code__ + return code is not None and ( + os.path.realpath(code.co_filename), + code.co_firstlineno, + ) == (os.path.realpath(model.co_filename), model.co_firstlineno) diff --git a/policyengine_uk/utils/income_tax_reductions.py b/policyengine_uk/utils/income_tax_reductions.py new file mode 100644 index 0000000000..5018a408b4 --- /dev/null +++ b/policyengine_uk/utils/income_tax_reductions.py @@ -0,0 +1,16 @@ +"""Step 6 of the Income Tax calculation (ITA 2007 s. 23): tax reductions.""" + + +def earlier_tax_reductions(income_tax, name: str) -> list: + """The tax reductions listed before ``name`` in step_6_tax_reductions. + + ITA 2007 s. 29(2)-(3) allows each reduction only so far as there is Step 5 + tax left after the reductions already deducted. The order does not change + their total, so the model deducts them in list order: each capped reduction + takes the tax the ones before it leave. A reduction not in the list (a + reform has removed it) comes after every listed one. + """ + subtractions = list(income_tax.step_6_tax_reductions) + if name in subtractions: + return subtractions[: subtractions.index(name)] + return subtractions diff --git a/policyengine_uk/utils/supplied_inputs.py b/policyengine_uk/utils/supplied_inputs.py index c6f81f512b..38897f81bf 100644 --- a/policyengine_uk/utils/supplied_inputs.py +++ b/policyengine_uk/utils/supplied_inputs.py @@ -47,6 +47,7 @@ { "adjusted_net_income", "carers_allowance", + "carers_allowance_pre_couple_limit", "carers_allowance_pre_overlap", "carer_support_payment", "carer_support_payment_pre_overlap", diff --git a/policyengine_uk/variables/contrib/policyengine/pre_budget_change_household_benefits.py b/policyengine_uk/variables/contrib/policyengine/pre_budget_change_household_benefits.py index a65a6bb943..7e3ec56e00 100644 --- a/policyengine_uk/variables/contrib/policyengine/pre_budget_change_household_benefits.py +++ b/policyengine_uk/variables/contrib/policyengine/pre_budget_change_household_benefits.py @@ -34,6 +34,7 @@ "epg_subsidy", "cost_of_living_support_payment", "energy_bills_rebate", + "household_energy_payment", "carer_support_payment", "scottish_carer_supplement", "disability_basic_income", diff --git a/policyengine_uk/variables/gov/dwp/carers_allowance.py b/policyengine_uk/variables/gov/dwp/carers_allowance.py index b01082dbc8..a3dae8a1a6 100644 --- a/policyengine_uk/variables/gov/dwp/carers_allowance.py +++ b/policyengine_uk/variables/gov/dwp/carers_allowance.py @@ -1,4 +1,18 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.benefit_unit import HALF_PENNY +from policyengine_uk.utils.carers_allowance_taper import ( + carers_allowance_annual_award, + carers_allowance_shares_of_year, + calculated_by_model, + carers_allowance_weekly_by_part_of_year, + carries_parts_of_year, +) +from policyengine_uk.variables.gov.dwp.carers_allowance_pre_couple_limit import ( + carers_allowance_pre_couple_limit, +) +from policyengine_uk.variables.gov.dwp.carers_allowance_pre_overlap import ( + carers_allowance_pre_overlap, +) class carers_allowance(Variable): @@ -7,14 +21,23 @@ class carers_allowance(Variable): label = "Carer's Allowance" documentation = ( "Carer's Allowance payable after the overlapping-benefit adjustment: " - "the allowance is reduced by any other personal benefit that overlaps " - "with it, such as State Pension, and only the balance is paid. The " - "comparison is made on annual amounts, which equals the weekly rule " - "when the overlapping benefit is paid at a constant rate all year. " - "Where the overlapping benefit is above the carer rate in some weeks " - "and lower or absent in others, for example when it is paid for part " - "of the year, the annual comparison pays less than the weekly rule " - "would; annual inputs carry no dates to do better." + "any other personal benefit that overlaps with it, such as State " + "Pension, reduces the allowance, and the model pays only the balance. " + "The model compares annual amounts, which gives the weekly rule's " + "result when the overlapping benefit pays a constant rate all year. " + "Where the overlapping benefit exceeds the carer rate in some weeks " + "and falls short of it or stops in others, for example when it starts " + "partway through the year, the annual comparison pays less than the " + "weekly rule would; annual inputs carry no dates to do better. In the " + "year the Budget 2026 earnings taper starts, overlapping benefits " + "come off only for the share of the year the allowance pays for. A " + "carers_allowance_pre_overlap or carers_allowance_pre_couple_limit " + "that an input supplies, or that a reform neutralizes, calculates " + "with a formula of its own or otherwise changes, carries no dates, so " + "the whole year's overlapping benefits come off the award; the payment " + "reads the stage before the couple restriction only where the model " + "calculates the award before overlap. Less than half a penny for the " + "year is nil." ) definition_period = YEAR unit = GBP @@ -27,4 +50,39 @@ class carers_allowance(Variable): def formula(person, period, parameters): pre_overlap = person("carers_allowance_pre_overlap", period) overlapping = person("carers_allowance_overlapping_benefits", period) - return max_(pre_overlap - overlapping, 0) + parts = carers_allowance_weekly_by_part_of_year(person, period, parameters) + if len(parts) == 1 or not calculated_by_model( + person, period, carers_allowance_pre_overlap + ): + # A pre-overlap award that an input supplies or a reform + # calculates carries no dates, so the payment doesn't read the + # stage before it. + share_paid = 1 + else: + # The overlap rule works week by week, so in the year the + # earnings taper starts, the overlapping benefits come off only + # for the share of the year the person keeps the award in, where + # both stages before the payment carry the parts of the year. + candidate = person("carers_allowance_pre_couple_limit", period) + candidate_share, kept_share = carers_allowance_shares_of_year( + person, period, parts + ) + kept = kept_share / where(candidate_share > 0, candidate_share, 1) + by_parts = carries_parts_of_year( + person, + period, + carers_allowance_pre_couple_limit, + candidate, + carers_allowance_annual_award(parts), + ) & carries_parts_of_year( + person, + period, + carers_allowance_pre_overlap, + pre_overlap, + candidate * kept, + ) + share_paid = where(by_parts, kept_share, 1) + paid = max_(pre_overlap - share_paid * overlapping, 0) + # Where an overlapping benefit equals the award, float32 leaves about a + # thousandth of a pound, which is not a payment; a penny is. + return where(paid < HALF_PENNY, 0, paid) diff --git a/policyengine_uk/variables/gov/dwp/carers_allowance_pre_couple_limit.py b/policyengine_uk/variables/gov/dwp/carers_allowance_pre_couple_limit.py index d2fb955e66..0341e9ec77 100644 --- a/policyengine_uk/variables/gov/dwp/carers_allowance_pre_couple_limit.py +++ b/policyengine_uk/variables/gov/dwp/carers_allowance_pre_couple_limit.py @@ -1,4 +1,8 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.carers_allowance_taper import ( + carers_allowance_annual_award, + carers_allowance_weekly_by_part_of_year, +) class carers_allowance_pre_couple_limit(Variable): @@ -8,7 +12,10 @@ class carers_allowance_pre_couple_limit(Variable): documentation = ( "The existing age, caring, earnings and take-up calculation before " "the same-person couple restriction and overlapping-benefit adjustment. " - "This candidate amount is not final entitlement or payment." + "This candidate amount is not final entitlement or payment. In the " + "year the Budget 2026 earnings taper starts, it adds the award before " + "the taper starts and the award under it, each for its share of the " + "year." ) definition_period = YEAR unit = GBP @@ -18,25 +25,5 @@ class carers_allowance_pre_couple_limit(Variable): ) def formula(person, period, parameters): - in_scotland = person.household("country", period).decode_to_str() == "SCOTLAND" - csp_replaces_ca = period.start.year >= 2025 - receives_ca = person("carers_allowance_reported", period) > 0 - ca = parameters(period).gov.dwp.carers_allowance - meets_age_condition = person("age", period) >= ca.min_age - weekly_care_hours = person("care_hours", period) - meets_work_condition = weekly_care_hours >= ca.min_hours - would_claim = person("would_claim_carers_allowance", period) - # SSCBA 1992 s. 70(1)(b): no allowance for anyone gainfully employed, - # which reg. 8 of the 1976 Regulations defines by an earnings limit. - # It applies to reported recipients too. - gainfully_employed = person( - "is_gainfully_employed_for_carers_allowance", period - ) - eligible = ( - ~(in_scotland & csp_replaces_ca) - & (meets_work_condition | receives_ca) - & ~gainfully_employed - & would_claim - & meets_age_condition - ) - return eligible * ca.rate * WEEKS_IN_YEAR + parts = carers_allowance_weekly_by_part_of_year(person, period, parameters) + return carers_allowance_annual_award(parts) diff --git a/policyengine_uk/variables/gov/dwp/carers_allowance_pre_overlap.py b/policyengine_uk/variables/gov/dwp/carers_allowance_pre_overlap.py index c251fc77d7..b0be0ee2b7 100644 --- a/policyengine_uk/variables/gov/dwp/carers_allowance_pre_overlap.py +++ b/policyengine_uk/variables/gov/dwp/carers_allowance_pre_overlap.py @@ -1,5 +1,14 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.carers_allowance_taper import ( + carers_allowance_annual_award, + carers_allowance_shares_of_year, + carers_allowance_weekly_by_part_of_year, + carries_parts_of_year, +) from policyengine_uk.utils.couple_carer_award import limit_carer_benefit_to_one_partner +from policyengine_uk.variables.gov.dwp.carers_allowance_pre_couple_limit import ( + carers_allowance_pre_couple_limit, +) class carers_allowance_pre_overlap(Variable): @@ -7,15 +16,25 @@ class carers_allowance_pre_overlap(Variable): entity = Person label = "Carer's Allowance before the overlapping-benefit adjustment" documentation = ( - "The same-person couple restriction is applied before overlapping " - "benefits, using the reported-award/head modelling convention described " - "in the carer benefit assumptions. " + "The model applies the same-person couple restriction before " + "overlapping benefits, giving the award to the partner the couple " + "would elect, as the carer benefit assumptions describe. " "The Carer's Allowance a person is entitled to before the Social " "Security (Overlapping Benefits) Regulations 1979 reduce it by another " - "personal benefit. A person whose allowance is reduced to nil keeps " - "this underlying entitlement. is_entitled_to_carer_benefit combines " + "personal benefit. A person whose allowance overlapping benefits " + "reduce to nil keeps this underlying entitlement. is_entitled_to_carer_benefit combines " "the pre-overlap amounts and any benefit in payment for rules that " - "test entitlement. carers_allowance is the amount payable." + "test entitlement. carers_allowance is the amount payable. In the " + "year the Budget 2026 earnings taper starts, the couple restriction " + "works on the weeks before the taper and the weeks under it " + "separately, and where the two partners' weekly awards are equal, it " + "prefers the larger award over the year; a partner below the earnings " + "limit keeps the award in both, so one partner has the award for the " + "year at any taper rate of zero or more. A " + "carers_allowance_pre_couple_limit that an input supplies, or that a " + "reform neutralizes, calculates with a formula of its own or " + "otherwise changes (with defined_for, say), carries no dates, so the " + "restriction applies to the benefit unit's awards for the whole year." ) definition_period = YEAR unit = GBP @@ -26,4 +45,21 @@ class carers_allowance_pre_overlap(Variable): def formula(person, period, parameters): candidate = person("carers_allowance_pre_couple_limit", period) - return limit_carer_benefit_to_one_partner(person, period, candidate) + whole_year = limit_carer_benefit_to_one_partner(person, period, candidate) + parts = carers_allowance_weekly_by_part_of_year(person, period, parameters) + if len(parts) == 1: + return whole_year + # In the year the earnings taper starts, a person keeps their award + # for the parts of the year the restriction picks them in. + candidate_share, kept_share = carers_allowance_shares_of_year( + person, period, parts + ) + by_parts = carries_parts_of_year( + person, + period, + carers_allowance_pre_couple_limit, + candidate, + carers_allowance_annual_award(parts), + ) & (candidate_share > 0) + kept = kept_share / where(by_parts, candidate_share, 1) + return where(by_parts, candidate * kept, whole_year) diff --git a/policyengine_uk/variables/gov/dwp/carers_allowance_weekly_earnings_above_limit.py b/policyengine_uk/variables/gov/dwp/carers_allowance_weekly_earnings_above_limit.py new file mode 100644 index 0000000000..8f77427e41 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/carers_allowance_weekly_earnings_above_limit.py @@ -0,0 +1,37 @@ +from policyengine_uk.model_api import * + + +class carers_allowance_weekly_earnings_above_limit(Variable): + value_type = float + entity = Person + label = "weekly earnings above the Carer's Allowance earnings limit" + documentation = ( + "How far the person's weekly net earnings exceed the Carer's Allowance " + "earnings limit, or zero at or below it. The regulation tests the " + "earnings of the preceding week; annual earnings are spread evenly " + "over 52 weeks. The model disregards a fraction of a penny, as regulation 5 " + "of the Computation of Earnings Regulations requires when that favours " + "the claimant. Any excess means the person is gainfully employed; " + "where the Budget 2026 earnings taper applies, the allowance tapers " + "away on it instead of stopping." + ) + definition_period = YEAR + unit = GBP + reference = [ + dict( + title="The Social Security (Invalid Care Allowance) Regulations 1976 reg. 8(1)", + href="https://www.legislation.gov.uk/uksi/1976/409/regulation/8", + ), + dict( + title="Social Security Benefit (Computation of Earnings) Regulations 1996 reg. 5", + href="https://www.legislation.gov.uk/uksi/1996/2745/regulation/5", + ), + ] + + def formula(person, period, parameters): + limit = parameters(period).gov.dwp.carers_allowance.earnings_limit + weekly = person("carers_allowance_earnings", period) / WEEKS_IN_YEAR + # Disregard a fraction of a penny; the tolerance keeps an exact + # number of pence safe from floating-point error. + weekly_in_pence = np.floor(weekly * 100 + 1e-6) / 100 + return max_(0, weekly_in_pence - limit) diff --git a/policyengine_uk/variables/gov/dwp/is_gainfully_employed_for_carers_allowance.py b/policyengine_uk/variables/gov/dwp/is_gainfully_employed_for_carers_allowance.py index b6e6b4b981..d2e8695003 100644 --- a/policyengine_uk/variables/gov/dwp/is_gainfully_employed_for_carers_allowance.py +++ b/policyengine_uk/variables/gov/dwp/is_gainfully_employed_for_carers_allowance.py @@ -31,9 +31,4 @@ class is_gainfully_employed_for_carers_allowance(Variable): ] def formula(person, period, parameters): - limit = parameters(period).gov.dwp.carers_allowance.earnings_limit - weekly = person("carers_allowance_earnings", period) / WEEKS_IN_YEAR - # Disregard a fraction of a penny; the tolerance keeps an exact - # number of pence from being knocked down by floating-point error. - weekly_in_pence = np.floor(weekly * 100 + 1e-6) / 100 - return weekly_in_pence > limit + return person("carers_allowance_weekly_earnings_above_limit", period) > 0 diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/is_uc_benefit_cap_exempt_specified_benefit.py b/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/is_uc_benefit_cap_exempt_specified_benefit.py index d752aad48f..a0d689c3c7 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/is_uc_benefit_cap_exempt_specified_benefit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/is_uc_benefit_cap_exempt_specified_benefit.py @@ -1,7 +1,6 @@ -import os - from policyengine_uk.model_api import * from policyengine_uk.utils.benefit_unit import award_of_couple +from policyengine_uk.utils.formula_origin import calculated_by from policyengine_uk.utils.supplied_inputs import supplied_input from policyengine_uk.utils.uc_work_related_requirements import ( other_member_of_single_claim, @@ -49,23 +48,6 @@ ] -def calculated_by(population, variable_name, period, model_variable): - """Whether the simulation calculates ``variable_name`` with the model's own - formula (``model_variable``'s), rather than one a reform put in its place. - The tax-benefit system loads each variable file as its own module, so the - formula is matched by where its code is, not by identity.""" - from policyengine_uk.scenarios.uc_reform import lcwra_formula_origin - - variable = population.simulation.tax_benefit_system.get_variable(variable_name) - formula = lcwra_formula_origin(variable.get_formula(period), period) - code = getattr(formula, "__code__", None) - model = model_variable.formula.__code__ - return code is not None and ( - os.path.realpath(code.co_filename), - code.co_firstlineno, - ) == (os.path.realpath(model.co_filename), model.co_firstlineno) - - class is_uc_benefit_cap_exempt_specified_benefit(Variable): value_type = bool entity = BenUnit diff --git a/policyengine_uk/variables/gov/gov_spending.py b/policyengine_uk/variables/gov/gov_spending.py index 2ac8ed36ed..f8ff6bd218 100644 --- a/policyengine_uk/variables/gov/gov_spending.py +++ b/policyengine_uk/variables/gov/gov_spending.py @@ -36,6 +36,7 @@ "epg_subsidy", "cost_of_living_support_payment", "energy_bills_rebate", + "household_energy_payment", "winter_fuel_allowance", "pawhp", "other_public_spending_budget_change", diff --git a/policyengine_uk/variables/gov/hmrc/domestic_energy_vat.py b/policyengine_uk/variables/gov/hmrc/domestic_energy_vat.py index 8050829aba..0a9c2b9dc0 100644 --- a/policyengine_uk/variables/gov/hmrc/domestic_energy_vat.py +++ b/policyengine_uk/variables/gov/hmrc/domestic_energy_vat.py @@ -29,9 +29,10 @@ def _domestic_energy_vat(household, period, parameters, electricity_rate, gas_ra class domestic_energy_vat(Variable): label = "VAT on domestic electricity and gas" documentation = ( - "VAT on the household's electricity and gas bills. Electricity is " - "charged at its own rate, which is zero in Great Britain from 1 October " - "2026 to 31 March 2027; gas is charged at the reduced rate." + "VAT on the household's electricity and gas bills. Electricity has " + "its own rate, which is zero in Great Britain from 1 October 2026 and " + "in Northern Ireland from 1 April 2027, to 30 June 2027; gas takes the " + "reduced rate." ) entity = Household definition_period = YEAR diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/liability/property_income_tax.py b/policyengine_uk/variables/gov/hmrc/income_tax/liability/property_income_tax.py index 0e9aeb56ba..1181eae828 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/liability/property_income_tax.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/liability/property_income_tax.py @@ -32,6 +32,10 @@ class property_income_tax(Variable): title="Finance Act 2026, s. 7 (property income rates)", href="https://www.legislation.gov.uk/ukpga/2026/11/section/7/enacted", ), + dict( + title="Budget 2026, paragraph 6.246 (property rates of 24%, 44% and 49%)", + href="https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=142", + ), ] unit = GBP @@ -77,8 +81,10 @@ def formula_2027(person, period, parameters): apply to Scotland). For rUK taxpayers, property income is taxed using property-specific - rates. From April 2027, these are 2pp higher than standard UK rates - (22%, 42%, 47% vs 20%, 40%, 45%). + rates. From April 2027, these are 4pp higher than standard UK rates + (24%, 44%, 49% vs 20%, 40%, 45%): 2pp from Budget 2025 and a further + 2pp from Budget 2026 (paragraph 6.246), which applies them across + England, Wales and Northern Ireland. Property income is stacked on top of other earned income for determining which tax band it falls into. diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/pension_tax_allowance.py b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/pension_tax_allowance.py new file mode 100644 index 0000000000..d042ab2dea --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/pension_tax_allowance.py @@ -0,0 +1,67 @@ +from policyengine_uk.model_api import * +from policyengine_uk.utils.dates import MONTHS_FROM_TAX_YEAR_START_TO_MID_YEAR +from policyengine_uk.utils.income_tax_reductions import earlier_tax_reductions + + +class pension_tax_allowance(Variable): + value_type = float + entity = Person + label = "Pension Tax Allowance" + documentation = ( + "Income Tax reduction for people over State Pension age or reaching it " + "in the tax year, from 2027-28 to 2029-30. The full allowance is 20% of " + "the full new State Pension above the Personal Allowance, rounded up to " + "the pound (£94 in 2027-28). It falls by £1 for every £3 of total " + "income above the full new State Pension, which is the tax year's 52 " + "weeks at the weekly rate, so it follows any change to that rate. The " + "model projects the rate unrounded, so the threshold is £13,036.97 in " + "2027-28 where HM Treasury's explainer gives £13,036.40. It is " + "non-refundable: it is " + "deducted at Step 6 after the reductions listed before it in " + "step_6_tax_reductions, only so far as there is Step 5 tax left, " + "and it does not change the Personal Allowance." + ) + definition_period = YEAR + unit = GBP + reference = [ + dict( + title="Budget 2026, paragraph 6.182", + href="https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=135", + ), + dict( + title="HM Treasury, Pension Tax Allowance explainer", + href="https://www.gov.uk/government/publications/pension-tax-allowance-explainer/pension-tax-allowance-explainer", + ), + dict( + title="Income Tax Act 2007, s. 23 (Step 6) and s. 29(2)-(3)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/29", + ), + ] + + def formula(person, period, parameters): + income_tax = parameters(period).gov.hmrc.income_tax + p = income_tax.reliefs.pension_tax_allowance + # The explainer's threshold is the full annual value of the new State + # Pension, "the total income you are entitled to receive within the + # tax year": 52 weeks at the weekly rate. + new_state_pension = parameters(period).gov.dwp.state_pension.new_state_pension + threshold = WEEKS_IN_YEAR * new_state_pension.amount + personal_allowance = income_tax.allowances.personal_allowance.amount + # The explainer rounds 20% of £466.40 (£93.28) up to £94, and tapers + # from £94: Jonathan, £150 above the threshold, gets £94 - £50 = £44. + # Rounding to a millionth first keeps a whole-pound value whole. + full = np.ceil(np.round(p.rate * max_(0, threshold - personal_allowance), 6)) + excess = max_(0, person("total_income", period) - threshold) + allowance = max_(0, full - p.taper * excess) + # Over State Pension age or reaching it within the tax year: attaining + # it before 6 April of the next year, six months after 6 October. + eligible = ( + person("months_since_state_pension_age", period) + > -MONTHS_FROM_TAX_YEAR_START_TO_MID_YEAR + ) + earlier = earlier_tax_reductions(income_tax, "pension_tax_allowance") + tax_left = max_( + 0, + person("income_tax_pre_charges", period) - add(person, period, earlier), + ) + return p.in_effect * eligible * min_(allowance, tax_left) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/property_finance_cost_relief.py b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/property_finance_cost_relief.py index 4be8225499..665e728cdc 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/property_finance_cost_relief.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/property_finance_cost_relief.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.income_tax_reductions import earlier_tax_reductions class property_finance_cost_relief(Variable): @@ -11,8 +12,9 @@ class property_finance_cost_relief(Variable): "rate before 2027-28) times the finance costs relieved this year. " "The rate is the same for Scottish taxpayers, whose property income " "is taxed at Scottish rates. It is deducted at Step 6 of the income " - "tax calculation after the person's other tax reductions, and only " - "so far as there is tax left to deduct it from." + "tax calculation after the reductions listed before it in " + "step_6_tax_reductions, and only so far as there is tax left to " + "deduct it from." ) definition_period = YEAR unit = GBP @@ -41,17 +43,12 @@ def formula(person, period, parameters): "property_finance_costs_relieved", period ) # The order of Step 6 reductions does not change their total, so the - # person's other reductions come first; this one is limited to the - # Step 5 tax they leave. Step 7 charges (ITA 2007 s. 30), such as the - # High Income Child Benefit Charge, are not reduced. - other_reductions = [ - variable - for variable in income_tax.step_6_tax_reductions - if variable != "property_finance_cost_relief" - ] + # reductions listed before this one come first, and the Step 5 tax they + # leave caps it. No reduction comes off Step 7 charges (ITA 2007 + # s. 30), such as the High Income Child Benefit Charge. + earlier = earlier_tax_reductions(income_tax, "property_finance_cost_relief") tax_left = max_( 0, - person("income_tax_pre_charges", period) - - add(person, period, other_reductions), + person("income_tax_pre_charges", period) - add(person, period, earlier), ) return min_(reduction, tax_left) diff --git a/policyengine_uk/variables/gov/treasury/household_energy_payment/household_energy_payment.py b/policyengine_uk/variables/gov/treasury/household_energy_payment/household_energy_payment.py new file mode 100644 index 0000000000..dbb38bf13a --- /dev/null +++ b/policyengine_uk/variables/gov/treasury/household_energy_payment/household_energy_payment.py @@ -0,0 +1,34 @@ +from policyengine_uk.model_api import * + + +class household_energy_payment(Variable): + label = "Household Energy Payment" + documentation = ( + "One-off payment to households in the qualifying council tax bands and " + "countries, which local authorities pay into bank accounts. Budget " + "2026 sets £100 for bands A to D in England, from " + "January 2027. It goes to the householder liable for council tax on " + "their main home, including most households exempt from council tax. " + "The model pays every household in a qualifying band: survey data " + "hold only main homes, so second homes and empty properties do not " + "arise, but the model can't tell which households have a landlord " + "liable for council tax (council tax in the rent), so it pays them, " + "which slightly overstates recipients." + ) + entity = Household + definition_period = YEAR + value_type = float + unit = GBP + reference = [ + "https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=117", + "https://www.gov.uk/government/publications/budget-2026-document/budget-2026-html", + "https://www.gov.uk/government/news/what-the-autumn-budget-2026-means-for-your-energy-bills", + ] + + def formula(household, period, parameters): + p = parameters(period).gov.treasury.household_energy_payment + band = household("council_tax_band", period) + country = household("country", period) + in_band = np.isin(band.decode_to_str(), list(p.bands)) + in_country = np.isin(country.decode_to_str(), list(p.countries)) + return in_band * in_country * p.amount diff --git a/policyengine_uk/variables/household/income/hbai_household_net_income.py b/policyengine_uk/variables/household/income/hbai_household_net_income.py index aa7b958211..3942971ffb 100644 --- a/policyengine_uk/variables/household/income/hbai_household_net_income.py +++ b/policyengine_uk/variables/household/income/hbai_household_net_income.py @@ -75,6 +75,11 @@ # docs/book/programs/gov/treasury/cost-of-living-payments.md): # https://www.gov.uk/government/statistics/households-below-average-income-for-financial-years-ending-1995-to-2023/households-below-average-income-series-quality-and-methodology-information-report-fye-2023 "energy_bills_rebate", + # HBAI counted the 2022 £150 Council Tax Rebate and the Energy Bills + # Support Scheme as miscellaneous income (HBAI quality and methodology + # report FYE 2023). The Household Energy Payment is the same kind of + # one-off support to the same council tax bands, so it counts too. + "household_energy_payment", # Reference for tax-free-childcare: https://assets.publishing.service.gov.uk/media/5e7b191886650c744175d08b/households-below-average-income-1994-1995-2018-2019.pdf ] diff --git a/policyengine_uk/variables/household/income/household_benefits.py b/policyengine_uk/variables/household/income/household_benefits.py index 6c28430a2f..191050ba30 100644 --- a/policyengine_uk/variables/household/income/household_benefits.py +++ b/policyengine_uk/variables/household/income/household_benefits.py @@ -35,6 +35,7 @@ "epg_subsidy", "cost_of_living_support_payment", "energy_bills_rebate", + "household_energy_payment", "winter_fuel_allowance", "pawhp", "tax_free_childcare",