From b6eadca441472551d7e6f4087f39514974b9b21b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 15:47:01 -0400 Subject: [PATCH 1/8] Count the 2022 Energy Bills Rebate in HBAI income; trading allowance on Chapter 8 income - HBAI household net income adds energy_bills_rebate as miscellaneous income (DWP HBAI QMI FYE 2023); the Council Tax Rebate is paid in 2022-23 only (payments by 30 September 2022). - The trading allowance relieves income not otherwise charged (ITTOIA 2005 s. 783AB), sharing one allowance with trade receipts. New optional input income_not_otherwise_charged (default 0), deduction miscellaneous_income_trading_allowance_deduction, flag uses_trading_allowance for the loss opt-out, and miscellaneous_income_before_trading_allowance for means tests. Fixes #2724, fixes #2725. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2724.fixed.md | 1 + changelog.d/2725.fixed.md | 1 + .../council_tax_rebate/amount.yaml | 16 ++- .../income/gross_income_sources.yaml | 4 +- .../council_tax_rebate.yaml | 25 +++- .../energy_bills_rebate.yaml | 87 ++++++++++++++ policyengine_uk/utils/pensions.py | 2 +- policyengine_uk/utils/trading_allowance.py | 109 ++++++++++++++++++ .../applicable_income/_net_earnings.py | 2 +- .../housing_benefit_non_dep_gross_income.py | 2 +- .../uc_individual_earned_income_before_mif.py | 10 +- ...eous_income_trading_allowance_deduction.py | 50 ++++++++ .../allowances/trading_allowance_deduction.py | 46 ++------ .../allowances/uses_trading_allowance.py | 33 ++++++ ...laneous_income_before_trading_allowance.py | 40 +++++++ .../bases/taxable_miscellaneous_income.py | 25 ++-- ...rade_loss_relief_against_general_income.py | 2 +- .../class_4/ni_class_4_trading_loss.py | 7 +- ...ncil_tax_reduction_non_dep_gross_income.py | 2 +- .../household/income/gross_taxable_inputs.py | 6 +- .../income/hbai_household_net_income.py | 6 + .../input/income_not_otherwise_charged.py | 34 ++++++ 22 files changed, 441 insertions(+), 69 deletions(-) create mode 100644 changelog.d/2724.fixed.md create mode 100644 changelog.d/2725.fixed.md create mode 100644 policyengine_uk/utils/trading_allowance.py create mode 100644 policyengine_uk/variables/gov/hmrc/income_tax/allowances/miscellaneous_income_trading_allowance_deduction.py create mode 100644 policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_trading_allowance.py create mode 100644 policyengine_uk/variables/gov/hmrc/income_tax/bases/miscellaneous_income_before_trading_allowance.py create mode 100644 policyengine_uk/variables/input/income_not_otherwise_charged.py diff --git a/changelog.d/2724.fixed.md b/changelog.d/2724.fixed.md new file mode 100644 index 0000000000..b4673125c4 --- /dev/null +++ b/changelog.d/2724.fixed.md @@ -0,0 +1 @@ +Counted the 2022 Energy Bills Rebate (the Energy Bills Support Scheme credit and the £150 Council Tax Rebate) in HBAI household net income as miscellaneous income, as DWP's HBAI does, and paid the one-off Council Tax Rebate in 2022-23 only rather than again in 2023-24. diff --git a/changelog.d/2725.fixed.md b/changelog.d/2725.fixed.md new file mode 100644 index 0000000000..0d29eaab62 --- /dev/null +++ b/changelog.d/2725.fixed.md @@ -0,0 +1 @@ +Applied the trading allowance to income not otherwise charged (ITTOIA 2005 Part 5 Chapter 8), sharing one allowance with trade receipts, through a new optional `income_not_otherwise_charged` input that defaults to zero. Means tests and gross income bases now read `miscellaneous_income_before_trading_allowance`, so the income tax relief does not reach them. diff --git a/policyengine_uk/parameters/gov/treasury/energy_bills_rebate/council_tax_rebate/amount.yaml b/policyengine_uk/parameters/gov/treasury/energy_bills_rebate/council_tax_rebate/amount.yaml index 311502e4ea..cd116eabe3 100644 --- a/policyengine_uk/parameters/gov/treasury/energy_bills_rebate/council_tax_rebate/amount.yaml +++ b/policyengine_uk/parameters/gov/treasury/energy_bills_rebate/council_tax_rebate/amount.yaml @@ -1,8 +1,20 @@ description: Council Tax rebate paid to households in qualifying Council Tax bands under the Energy Bills Rebate. +# A one-off £150 per household in 2022-23. Eligibility was fixed at the end of +# 1 April 2022, and billing authorities had to make every payment by +# 30 September 2022 (billing authority guidance, paragraphs 7, 14 and 19). +# 2023-24 had no rebate. values: 0001-01-01: 0 - 2022-01-01: 150 - 2023-10-01: 0 + 2022-04-01: + value: 150 + reference: + - title: The Council Tax Rebate 2022-23 billing authority guidance, paragraphs 7, 14 and 19 + href: https://www.gov.uk/government/publications/the-council-tax-rebate-2022-23-billing-authority-guidance/support-for-energy-bills-the-council-tax-rebate-2022-23-billing-authority-guidance + 2022-10-01: + value: 0 + reference: + - title: The Council Tax Rebate 2022-23 billing authority guidance, paragraph 7 (payments made by 30 September 2022) + href: https://www.gov.uk/government/publications/the-council-tax-rebate-2022-23-billing-authority-guidance/support-for-energy-bills-the-council-tax-rebate-2022-23-billing-authority-guidance metadata: unit: currency-GBP period: year diff --git a/policyengine_uk/parameters/gov/welsh_government/childcare_offer/income/gross_income_sources.yaml b/policyengine_uk/parameters/gov/welsh_government/childcare_offer/income/gross_income_sources.yaml index 1e14c7c83a..c062d8e88c 100644 --- a/policyengine_uk/parameters/gov/welsh_government/childcare_offer/income/gross_income_sources.yaml +++ b/policyengine_uk/parameters/gov/welsh_government/childcare_offer/income/gross_income_sources.yaml @@ -61,7 +61,7 @@ values: # education_grants residual can still hold Childcare Grant and Disabled # Students' Allowance, because the dataset moves those out only through # England-only formulas; and the FRS allpay2 amount reaches both - # taxable_miscellaneous_income and private_transfer_income + # miscellaneous_income_before_trading_allowance and private_transfer_income # (policyengine-uk-data#627). 2019-08-01: - employment_income @@ -92,6 +92,6 @@ values: - savings_interest_income - other_investment_income - dividend_income - - taxable_miscellaneous_income + - miscellaneous_income_before_trading_allowance - maintenance_income - spousal_maintenance_income diff --git a/policyengine_uk/tests/policy/baseline/gov/treasury/energy_bills_rebate/council_tax_rebate.yaml b/policyengine_uk/tests/policy/baseline/gov/treasury/energy_bills_rebate/council_tax_rebate.yaml index ba7fceee11..60829709ff 100644 --- a/policyengine_uk/tests/policy/baseline/gov/treasury/energy_bills_rebate/council_tax_rebate.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/treasury/energy_bills_rebate/council_tax_rebate.yaml @@ -1,5 +1,5 @@ - name: CT bands A-D get full rebate, others don't. - period: 2023 + period: 2022 input: council_tax: - 150 @@ -32,3 +32,26 @@ - 0 - 0 - 0 + +# The rebate was a one-off in 2022-23: billing authorities made every payment +# by 30 September 2022 (billing authority guidance, paragraph 7). It was not +# paid again in 2023-24 or before 2022-23. +- name: No rebate in 2023-24, in any band. + period: 2023 + input: + council_tax_band: + - A + - D + - E + output: + ebr_council_tax_rebate: + - 0 + - 0 + - 0 + +- name: No rebate in 2021-22. + period: 2021 + input: + council_tax_band: A + output: + ebr_council_tax_rebate: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/treasury/energy_bills_rebate/energy_bills_rebate.yaml b/policyengine_uk/tests/policy/baseline/gov/treasury/energy_bills_rebate/energy_bills_rebate.yaml index 257afcb44c..ae4647d681 100644 --- a/policyengine_uk/tests/policy/baseline/gov/treasury/energy_bills_rebate/energy_bills_rebate.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/treasury/energy_bills_rebate/energy_bills_rebate.yaml @@ -12,3 +12,90 @@ council_tax_band: G output: energy_bills_rebate: 199 + +# HBAI income. DWP's HBAI classifies the Energy Bills Support Scheme (£600 in +# Northern Ireland) and the £150 Council Tax Rebate as miscellaneous income +# (HBAI quality and methodology report FYE 2023, "Treatment of cost of living +# and wider support schemes"), so both reach HBAI household net income. +# Each household below has no other income, does not claim Universal Credit +# and has no council tax to pay, so its HBAI income is the rebate alone. The +# Great Britain credit is £66 in October and November 2022 and £67 a month +# from December 2022 to March 2023, summed by calendar year (£199 and £201). + +- name: Case 3, a band C household's 2022 rebates are HBAI income. + period: 2022 + input: + people: + adult: + age: 40 + benunits: + unit: + members: [adult] + would_claim_uc: false + households: + household: + members: [adult] + council_tax_band: C + council_tax: 0 + output: + ebr_council_tax_rebate: 150 + ebr_energy_bills_credit: 199 + hbai_household_net_income: 150 + 199 + +- name: Case 4, in 2023 only the credit's remaining instalments are paid, and counted. + period: 2023 + input: + people: + adult: + age: 40 + benunits: + unit: + members: [adult] + would_claim_uc: false + households: + household: + members: [adult] + council_tax_band: C + council_tax: 0 + output: + ebr_council_tax_rebate: 0 + energy_bills_rebate: 201 + hbai_household_net_income: 201 + +- name: Case 5, Northern Ireland's January 2023 credit is HBAI income. + period: 2023 + input: + people: + adult: + age: 40 + benunits: + unit: + members: [adult] + would_claim_uc: false + households: + household: + members: [adult] + country: NORTHERN_IRELAND + council_tax: 0 + output: + energy_bills_rebate: 400 + hbai_household_net_income: 400 + +- name: Case 6, the rebates are miscellaneous income, not HBAI state support. + period: 2022 + input: + people: + adult: + age: 40 + benunits: + unit: + members: [adult] + would_claim_uc: false + households: + household: + members: [adult] + council_tax_band: A + council_tax: 0 + output: + energy_bills_rebate: 349 + hbai_benefits: 0 diff --git a/policyengine_uk/utils/pensions.py b/policyengine_uk/utils/pensions.py index be14ef3e7c..0c6a4eb202 100644 --- a/policyengine_uk/utils/pensions.py +++ b/policyengine_uk/utils/pensions.py @@ -123,7 +123,7 @@ def annual_allowance_net_income(person, period, parameters): cap = hmrc.income_tax.reliefs.cap loss_cap = np.maximum(cap.amount, cap.rate * adjusted_total) loss = np.where( - person("trading_allowance_deduction", period) > 0, + person("uses_trading_allowance", period), 0, person("trading_loss", period), ) diff --git a/policyengine_uk/utils/trading_allowance.py b/policyengine_uk/utils/trading_allowance.py new file mode 100644 index 0000000000..3064cca1cb --- /dev/null +++ b/policyengine_uk/utils/trading_allowance.py @@ -0,0 +1,109 @@ +"""The trading allowance (ITTOIA 2005 Part 6A Chapter 1), shared by +trading_allowance_deduction and miscellaneous_income_trading_allowance_deduction. + +One allowance covers an individual's "relevant income": the receipts of +their relevant trades and their miscellaneous income, which s. 783AB defines +as income chargeable under Part 5 Chapter 8 (income not otherwise charged), +before expenses (ss. 783A and 783AC). Full relief, partial relief and the +opt-out each apply to the relevant income as a whole, so the two deductions +come from one decision. +""" + +from policyengine_core.model_api import max_, min_, where + +from policyengine_uk.utils.supplied_inputs import supplied_input + + +def trading_allowance_reliefs(person, period, parameters): + """The allowance's relief on trades and on miscellaneous income. + + Returns ``(trade_relief, miscellaneous_relief)``: the deduction from + trading profits beyond the expenses and capital allowances already + deducted from them, and the deduction from income not otherwise charged. + + - Full relief (s. 783AE(1)): when trade receipts and miscellaneous income + together are within the allowance, trade profits or losses and the + miscellaneous income are treated as nil (ss. 783AF and 783AG). + - Partial relief (ss. 783AH-783AK, on election under s. 783AM): above the + allowance, each trade's profit is its receipts less its share of the + allowance, with no expenses, and the miscellaneous income is that + income less its share. The shares add up to the allowance and the + individual chooses them (s. 783AK). Any split gives the same income + tax, since the total charged is relevant income less the allowance; + the model gives the trades their receipts' worth first, which keeps + Class 4 as low as it can be. So the miscellaneous income gets the part + of the allowance the trade receipts leave. Where trade receipts exceed + the allowance it gets none: partial relief then beats actual + deductions exactly when the trades' expenses and capital allowances + are under the allowance, and the trades take all of it. + - Opting out (s. 783AL): a raw current trading loss larger than the + saving from both deductions is claimed instead, and then neither + applies. + + Without gross receipts, a profit within the allowance is taken as + receipts equal to the profit, which takes up that much of the allowance; + a larger profit is taken to reflect the better of actual expenses and the + allowance already, which leaves none of it for miscellaneous income. + + The miscellaneous income is income_not_otherwise_charged, up to the + person's miscellaneous_income when that is positive (it is part of it). + The model holds no expenses against it. Not modelled: the exclusions in + ss. 783AN-783AQ (rent-a-room receipts, and income including a payment + from the person's employer, firm or close company), and a loss created + by capital allowances, which the model never sets against other income. + """ + allowance = parameters(period).gov.hmrc.income_tax.allowances.trading_allowance + profit = person("self_employment_income", period) + receipts = person("self_employment_gross_receipts", period) + capital_allowances = person("capital_allowances", period) + # Receipts below profit (including the default zero) mean unknown. + receipts_known = (receipts > 0) & (receipts >= profit) + expenses = max_(receipts - profit, 0) + # Full relief (receipts within the allowance, s. 783AE-783AF) makes + # the profit nil; partial relief (s. 783AI) replaces expenses and + # capital allowances with the allowance. Both come to the allowance's + # excess over actual deductions, capped at profit. + relief_with_receipts = min_(profit, allowance - expenses) - capital_allowances + relief_without_receipts = where(profit <= allowance, profit - capital_allowances, 0) + trade_relief = where(receipts_known, relief_with_receipts, relief_without_receipts) + trade_relief = max_(trade_relief, 0) + + # The trade receipts that use the allowance first. Unknown receipts are + # the profit within the allowance, and otherwise use all of it. + trade_receipts = where( + receipts_known, + receipts, + where(profit <= allowance, max_(profit, 0), allowance), + ) + miscellaneous_income = min_( + max_(person("income_not_otherwise_charged", period), 0), + max_(person("miscellaneous_income", period), 0), + ) + miscellaneous_relief = min_( + miscellaneous_income, max_(allowance - trade_receipts, 0) + ) + + # Full relief makes every relevant trade's current profit AND loss + # nil (s.783AF); the opt-out is for the individual/year (s.783AL), and + # takes the miscellaneous income's relief with it. Partial relief also + # replaces every trade's actual expenses. Choose ordinary treatment if + # the raw supplied current loss exceeds the allowance saving. Before + # applying net-income and relief-cap limits, this also preserves an + # unused loss if both choices give nil income. Prior s.83 losses remain + # separate and may follow partial relief. + profit_after_other_deductions = max_( + 0, profit - capital_allowances - person("loss_relief", period) + ) + allowance_saving = ( + min_(trade_relief, profit_after_other_deductions) + miscellaneous_relief + ) + # An earlier input carried/uprated into this year is not a new loss + # for the election, just as it is not a new Class 4 loss. + current_loss = supplied_input(person, "trading_loss", period) + if current_loss is None: + current_loss = person.empty_array() + claim_current_loss = current_loss > allowance_saving + return ( + where(claim_current_loss, 0, trade_relief), + where(claim_current_loss, 0, miscellaneous_relief), + ) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/_net_earnings.py b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/_net_earnings.py index 993bfd0a45..e724fe8d1c 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/_net_earnings.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/_net_earnings.py @@ -70,7 +70,7 @@ def person_net_earnings( "savings_interest_income", "other_investment_income", "dividend_income", - "taxable_miscellaneous_income", + "miscellaneous_income_before_trading_allowance", ] ) earnings_share = np.divide( diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_non_dep_gross_income.py b/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_non_dep_gross_income.py index a493f0d7e5..527588841d 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_non_dep_gross_income.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_non_dep_gross_income.py @@ -52,7 +52,7 @@ def formula(person, period, parameters): "property_income", "savings_interest_income", "dividend_income", - "taxable_miscellaneous_income", + "miscellaneous_income_before_trading_allowance", "maternity_allowance", "iidb", "sda", diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py index 95a0d853dc..ed286a1a0b 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py @@ -49,9 +49,11 @@ def formula(person, period, parameters): # The deductions are the person's own and come off only their own # earnings, so one partner's tax, NI or pension contributions never # reduce the other partner's earned income. - # Miscellaneous income as income tax counts it, so a loss does not - # reduce pay (and matches uc_income_tax_on_earnings). Whether it is - # earnings at all is issue #1961. + # Miscellaneous income with a net loss counted as nil, so a loss does + # not reduce pay, and before the trading allowance, an income tax + # relief the UC earnings rules do not give (as a trade counts its + # profit before it). uc_income_tax_on_earnings deducts the tax on it. + # Whether it is earnings at all is issue #1961. # # Reg. 55(4) treats statutory sick, maternity and paternity pay as # employed earnings, and reg. 55(5) takes the deductions off "the @@ -64,7 +66,7 @@ def formula(person, period, parameters): # payments rose. earnings_components = [ "employment_income", - "taxable_miscellaneous_income", + "miscellaneous_income_before_trading_allowance", "statutory_sick_pay", "statutory_maternity_pay", "statutory_paternity_pay", diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/miscellaneous_income_trading_allowance_deduction.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/miscellaneous_income_trading_allowance_deduction.py new file mode 100644 index 0000000000..fc3f378bdf --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/miscellaneous_income_trading_allowance_deduction.py @@ -0,0 +1,50 @@ +from policyengine_uk.model_api import * +from policyengine_uk.utils.trading_allowance import trading_allowance_reliefs + + +class miscellaneous_income_trading_allowance_deduction(Variable): + value_type = float + entity = Person + label = "Deduction from miscellaneous income applied by the trading allowance" + documentation = ( + "The trading allowance's relief on income not otherwise charged " + "(ITTOIA 2005 Part 5 Chapter 8), the miscellaneous income Part 6A " + "covers alongside trade receipts. One allowance covers both. When " + "trade receipts and this income together are within it, both are " + "relieved in full (ss. 783AE, 783AF and 783AG). Above it, the person " + "can elect to deduct the allowance, split between them as they " + "choose, in place of actual expenses (ss. 783AI-783AK); the model " + "gives the trades their receipts' worth first, so this income gets " + "the part of the allowance the trade receipts leave. Opting out to " + "claim a current trading loss (s. 783AL) leaves it unrelieved. Only " + "income tax reads this deduction, through taxable_miscellaneous_income; " + "means tests and gross income bases read " + "miscellaneous_income_before_trading_allowance. Datasets that hold " + "income already net of the allowance should set it to zero, as " + "policyengine-uk-data does for trading_allowance_deduction on SPI " + "records." + ) + definition_period = YEAR + reference = [ + dict( + title="Income Tax (Trading and Other Income) Act 2005 s. 783AB", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/783AB", + ), + dict( + title="Income Tax (Trading and Other Income) Act 2005 s. 783AG (full relief)", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/783AG", + ), + dict( + title="Income Tax (Trading and Other Income) Act 2005 s. 783AJ (partial relief)", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/783AJ", + ), + dict( + title="Income Tax (Trading and Other Income) Act 2005 s. 783AK (splitting the allowance)", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/783AK", + ), + ] + unit = GBP + + def formula(person, period, parameters): + _, miscellaneous_relief = trading_allowance_reliefs(person, period, parameters) + return miscellaneous_relief diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/trading_allowance_deduction.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/trading_allowance_deduction.py index 25554afc7f..565a4957d9 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/trading_allowance_deduction.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/trading_allowance_deduction.py @@ -1,5 +1,5 @@ from policyengine_uk.model_api import * -from policyengine_uk.utils.supplied_inputs import supplied_input +from policyengine_uk.utils.trading_allowance import trading_allowance_reliefs class trading_allowance_deduction(Variable): @@ -20,7 +20,8 @@ class trading_allowance_deduction(Variable): "full profit just above the allowance. Receipts below profit are " "inconsistent and treated as unknown. The model compares the raw loss " "supplied for this tax year, before net-income and relief-cap limits, " - "with the allowance's effective saving after other deductions. " + "with the allowance's effective saving after other deductions, " + "including its relief on miscellaneous income. " "Ordinary expenses with a loss claim win when that raw loss is " "larger; allowance treatment otherwise, including ties. Comparing " "the raw loss can preserve an unused carry-forward even when both " @@ -29,7 +30,9 @@ class trading_allowance_deduction(Variable): "the individual's relevant trades " "for the tax year. Allowance treatment makes their current losses " "nil for Income Tax and Class 4. It does not optimise future loss use " - "or benefits." + "or benefits. The same allowance relieves income not otherwise " + "charged (miscellaneous_income_trading_allowance_deduction), which " + "gets only what the trade receipts leave of it." ) definition_period = YEAR reference = [ @@ -57,38 +60,5 @@ class trading_allowance_deduction(Variable): unit = GBP def formula(person, period, parameters): - allowance = parameters(period).gov.hmrc.income_tax.allowances.trading_allowance - profit = person("self_employment_income", period) - receipts = person("self_employment_gross_receipts", period) - capital_allowances = person("capital_allowances", period) - # Receipts below profit (including the default zero) mean unknown. - receipts_known = (receipts > 0) & (receipts >= profit) - expenses = max_(receipts - profit, 0) - # Full relief (receipts within the allowance, s. 783AE-783AF) makes - # the profit nil; partial relief (s. 783AI) replaces expenses and - # capital allowances with the allowance. Both come to the allowance's - # excess over actual deductions, capped at profit. - relief_with_receipts = min_(profit, allowance - expenses) - capital_allowances - relief_without_receipts = where( - profit <= allowance, profit - capital_allowances, 0 - ) - relief = where(receipts_known, relief_with_receipts, relief_without_receipts) - relief = max_(relief, 0) - # Full relief makes every relevant trade's current profit AND loss - # nil (s.783AF); the opt-out is for the individual/year (s.783AL). - # Partial relief also replaces every trade's actual expenses. Choose - # ordinary treatment if the raw supplied current loss exceeds the - # allowance saving. Before applying net-income and relief-cap limits, - # this also preserves an unused loss if both choices give nil income. - # Prior s.83 losses remain separate and may follow partial relief. - profit_after_other_deductions = max_( - 0, profit - capital_allowances - person("loss_relief", period) - ) - allowance_saving = min_(relief, profit_after_other_deductions) - # An earlier input carried/uprated into this year is not a new loss - # for the election, just as it is not a new Class 4 loss. - current_loss = supplied_input(person, "trading_loss", period) - if current_loss is None: - current_loss = person.empty_array() - claim_current_loss = current_loss > allowance_saving - return where(claim_current_loss, 0, relief) + trade_relief, _ = trading_allowance_reliefs(person, period, parameters) + return trade_relief diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_trading_allowance.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_trading_allowance.py new file mode 100644 index 0000000000..cb9d4d8ad4 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_trading_allowance.py @@ -0,0 +1,33 @@ +from policyengine_uk.model_api import * + + +class uses_trading_allowance(Variable): + value_type = bool + entity = Person + label = "Uses the trading allowance" + documentation = ( + "Whether the person takes the trading allowance for the year, by full " + "or partial relief, rather than ordinary treatment: the allowance " + "relieves their trade profits or their income not otherwise charged. " + "Either relief covers all their relevant income (ITTOIA 2005 s. 783A), " + "so it makes every relevant trade's current profit or loss its " + "allowance figure (ss. 783AF and 783AI), and a current trading loss " + "cannot then be claimed. A person with a larger current loss opts out " + "(s. 783AL) and claims it." + ) + definition_period = YEAR + reference = [ + dict( + title="Income Tax (Trading and Other Income) Act 2005 s. 783A", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/783A", + ), + dict( + title="Income Tax (Trading and Other Income) Act 2005 s. 783AL", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/783AL", + ), + ] + + def formula(person, period, parameters): + return (person("trading_allowance_deduction", period) > 0) | ( + person("miscellaneous_income_trading_allowance_deduction", period) > 0 + ) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bases/miscellaneous_income_before_trading_allowance.py b/policyengine_uk/variables/gov/hmrc/income_tax/bases/miscellaneous_income_before_trading_allowance.py new file mode 100644 index 0000000000..cc45a0c247 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bases/miscellaneous_income_before_trading_allowance.py @@ -0,0 +1,40 @@ +from policyengine_uk.model_api import * + + +class miscellaneous_income_before_trading_allowance(Variable): + value_type = float + entity = Person + label = "Miscellaneous income before the trading allowance" + documentation = ( + "Miscellaneous income with a net loss counted as nil, before the " + "trading allowance relieves income not otherwise charged. Means tests " + "and gross income bases read this figure, as they read a trade's " + "profit before the allowance: the allowance is an income tax relief. " + "Income tax reads taxable_miscellaneous_income, which is this less " + "miscellaneous_income_trading_allowance_deduction. A loss on a " + "miscellaneous transaction is relieved only against income under the " + "same section 1016 provision (ITA 2007 s. 152(4)-(5)) and never set " + "against other income, which is why a net loss counts as nil. The " + "model holds one net figure per person, so it assumes the items netted " + "inside miscellaneous_income can be relieved against each other; that " + "can undertax a person with a gain under one provision and a loss " + "under another. The input also covers income from sources outside " + "section 1016, some of which carry no loss relief at all. Carrying a " + "loss forward against later years' miscellaneous income is not " + "modelled." + ) + definition_period = YEAR + reference = [ + dict( + title="Income Tax (Trading and Other Income) Act 2005 s. 574", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/574", + ), + dict( + title="Income Tax Act 2007 s. 152", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/152", + ), + ] + unit = GBP + + def formula(person, period, parameters): + return max_(0, person("miscellaneous_income", period)) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bases/taxable_miscellaneous_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/bases/taxable_miscellaneous_income.py index ce8be05b12..0c5248ceab 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bases/taxable_miscellaneous_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bases/taxable_miscellaneous_income.py @@ -6,17 +6,12 @@ class taxable_miscellaneous_income(Variable): entity = Person label = "Amount of miscellaneous income that is taxable" documentation = ( - "Miscellaneous income charged to income tax. On a claim, a loss on a " - "miscellaneous transaction is relieved only against relevant " - "miscellaneous income: income under the same section 1016 provision as " - "the loss (ITA 2007 s. 152(4)-(5)). It is never set against other " - "income, so a net loss counts as nil here. The model holds one net " - "figure per person, so it assumes the items netted inside " - "miscellaneous_income can be relieved against each other; that can " - "undertax a person with a gain under one provision and a loss under " - "another. The input also covers income from sources outside section " - "1016, some of which carry no loss relief at all. Carrying a loss " - "forward against later years' miscellaneous income is not modelled." + "Miscellaneous income charged to income tax: " + "miscellaneous_income_before_trading_allowance, in which a net loss " + "counts as nil, less the trading allowance's relief on income not " + "otherwise charged (miscellaneous_income_trading_allowance_deduction). " + "Income tax reads this figure; means tests and gross income bases read " + "the figure before the allowance." ) definition_period = YEAR reference = [ @@ -24,6 +19,10 @@ class taxable_miscellaneous_income(Variable): title="Income Tax (Trading and Other Income) Act 2005 s. 574", href="https://www.legislation.gov.uk/ukpga/2005/5/section/574", ), + dict( + title="Income Tax (Trading and Other Income) Act 2005 s. 688(2)(za)", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/688", + ), dict( title="Income Tax Act 2007 s. 152", href="https://www.legislation.gov.uk/ukpga/2007/3/section/152", @@ -32,4 +31,6 @@ class taxable_miscellaneous_income(Variable): unit = GBP def formula(person, period, parameters): - return max_(0, person("miscellaneous_income", period)) + return person("miscellaneous_income_before_trading_allowance", period) - person( + "miscellaneous_income_trading_allowance_deduction", period + ) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.py index 5f45fcbdef..a63ba805ae 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.py @@ -42,7 +42,7 @@ def formula(person, period, parameters): # Allowance treatment cannot retain a current expense-generated loss # on another trade in the same individual's tax year. loss = where( - person("trading_allowance_deduction", period) > 0, + person("uses_trading_allowance", period), 0, person("trading_loss", period), ) diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_trading_loss.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_trading_loss.py index 5bda122912..4d0f62e334 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_trading_loss.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_trading_loss.py @@ -56,8 +56,9 @@ def formula(person, period, parameters): if loss is None: return person.empty_array() # Class 4 uses the same year's recomputed trade profit or loss as - # Income Tax. A positive allowance deduction means the taxpayer - # chose allowance treatment and gave up ordinary current losses. + # Income Tax. A positive allowance deduction, on trade profits or on + # income not otherwise charged, means the taxpayer chose allowance + # treatment and gave up ordinary current losses. # This leaves independently carried earlier Class 4 losses intact. - allowance_chosen = person("trading_allowance_deduction", period) > 0 + allowance_chosen = person("uses_trading_allowance", period) return where(allowance_chosen, 0, max_(loss, 0)) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_gross_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_gross_income.py index 74bb4a87d3..24b75757db 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_gross_income.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_gross_income.py @@ -56,7 +56,7 @@ def formula(person, period, parameters): "property_income", "savings_interest_income", "dividend_income", - "taxable_miscellaneous_income", + "miscellaneous_income_before_trading_allowance", "maternity_allowance", "iidb", "sda", diff --git a/policyengine_uk/variables/household/income/gross_taxable_inputs.py b/policyengine_uk/variables/household/income/gross_taxable_inputs.py index c759969f7a..97c3ee9f31 100644 --- a/policyengine_uk/variables/household/income/gross_taxable_inputs.py +++ b/policyengine_uk/variables/household/income/gross_taxable_inputs.py @@ -7,7 +7,9 @@ class gross_taxable_inputs(Variable): This is a modelling choice, not a statutory definition of gross income. Employment income excludes statutory pay and employment expenses are not deducted. Property and trading income retain signed input values; a net - miscellaneous loss is floored inside taxable_miscellaneous_income. + miscellaneous loss is floored inside + miscellaneous_income_before_trading_allowance, which is before the + trading allowance as trading income is. """ value_type = float @@ -34,5 +36,5 @@ class gross_taxable_inputs(Variable): "savings_interest_income", "other_investment_income", "dividend_income", - "taxable_miscellaneous_income", + "miscellaneous_income_before_trading_allowance", ] 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 604ec70078..c02f042e54 100644 --- a/policyengine_uk/variables/household/income/hbai_household_net_income.py +++ b/policyengine_uk/variables/household/income/hbai_household_net_income.py @@ -63,6 +63,12 @@ # HBAI classifies the Warm Home Discount as miscellaneous income: # https://www.gov.uk/government/statistics/households-below-average-income-for-financial-years-ending-1995-to-2025/households-below-average-income-background-information-and-methodology-report-fye-2025 "warm_home_discount", + # HBAI classifies the £400 Energy Bills Support Scheme (£600 in Northern + # Ireland) and the £150 Council Tax Rebate as miscellaneous income, not + # state support. DWP imputes receipt, since the FRS did not ask about + # them, so no FRS income field behind miscellaneous_income holds them: + # 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", # 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/input/income_not_otherwise_charged.py b/policyengine_uk/variables/input/income_not_otherwise_charged.py new file mode 100644 index 0000000000..7a83e97da9 --- /dev/null +++ b/policyengine_uk/variables/input/income_not_otherwise_charged.py @@ -0,0 +1,34 @@ +from policyengine_uk.model_api import * + + +class income_not_otherwise_charged(Variable): + value_type = float + entity = Person + label = "income not otherwise charged" + documentation = ( + "The part of miscellaneous_income chargeable to income tax under ITTOIA " + "2005 Part 5 Chapter 8 (s. 687): income from a source no other " + "provision charges, such as casual income that does not come from a " + "trade. It is part of miscellaneous_income, not added to it, and counts " + "up to miscellaneous_income where that is positive. This is the " + '"miscellaneous income" the trading allowance relieves (s. 783AB), ' + "measured before expenses; the model holds no expenses against it. " + "Optional: used only to apply the trading allowance. Leave it at zero " + "when unknown. The survey data do not separate it from the rest of " + "miscellaneous_income, which also holds royalties, charges under other " + "Part 5 Chapters and, on SPI records, benefits and termination " + "payments, none of which the allowance relieves." + ) + definition_period = YEAR + unit = GBP + reference = [ + dict( + title="Income Tax (Trading and Other Income) Act 2005 s. 687", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/687", + ), + dict( + title="Income Tax (Trading and Other Income) Act 2005 s. 783AB", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/783AB", + ), + ] + uprating = "gov.economic_assumptions.indices.obr.per_capita.gdp" From bd9cca3af46986bf6720ea8037468e7a48b264db Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 16:16:25 -0400 Subject: [PATCH 2/8] Test the trading allowance on Chapter 8 income and the rebate in HBAI income YAML cases for miscellaneous_income_trading_allowance_deduction worked from ITTOIA 2005 Part 6A, Hypothesis properties for both fixes (a statutory differential, one allowance, bounds, monotonicity, trade parity, the loss election), and the Energy Bills Rebate docs: the monthly EBSS, the one-off Council Tax Rebate and its HBAI treatment as miscellaneous income. Co-Authored-By: Claude Opus 5.5 --- .../gov/treasury/cost-of-living-payments.md | 43 ++- ...us_income_trading_allowance_deduction.yaml | 186 ++++++++++ ...est_energy_bills_rebate_hbai_properties.py | 143 ++++++++ ...lowance_miscellaneous_income_properties.py | 335 ++++++++++++++++++ 4 files changed, 696 insertions(+), 11 deletions(-) create mode 100644 policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/miscellaneous_income_trading_allowance_deduction.yaml create mode 100644 policyengine_uk/tests/test_energy_bills_rebate_hbai_properties.py create mode 100644 policyengine_uk/tests/test_trading_allowance_miscellaneous_income_properties.py 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 798c0fefe7..9a3240d15e 100644 --- a/docs/book/programs/gov/treasury/cost-of-living-payments.md +++ b/docs/book/programs/gov/treasury/cost-of-living-payments.md @@ -2,9 +2,10 @@ In response to the 2021-23 cost-of-living crisis the UK government announced two distinct one-off support packages. Both are modelled in -PolicyEngine UK under `gov.treasury.*` and feed into household net -income via [`gov_spending`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/variables/gov/gov_spending.py) -and the HBAI benefit aggregates. +PolicyEngine UK under `gov.treasury.*` and count in household net income +and in [`gov_spending`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/variables/gov/gov_spending.py). +Both also count in HBAI household net income, the income behind the poverty +statistics, though under different HBAI headings (see below). ## Energy Bills Rebate (winter 2022) @@ -12,21 +13,40 @@ Announced by the Chancellor in February 2022 to help households with the spike in domestic energy prices. Two strands: - **£150 Council Tax Rebate** for households in Council Tax bands A–D in - England, paid in April 2022. Equivalent payments were made by the - devolved administrations. -- **£200 Energy Bills Credit** delivered via energy suppliers to all - domestic electricity customers from October 2022. Originally announced - as a repayable loan, the repayment requirement was scrapped at the May - 2022 emergency support package. + England, a one-off in 2022-23. Eligibility was fixed on 1 April 2022 and + billing authorities made every payment by 30 September 2022. Equivalent + payments were made by the devolved administrations. +- **Energy Bills Support Scheme**, a £400 discount on domestic electricity + bills in Great Britain, paid in six monthly instalments from October 2022 + to March 2023 (£66 for two months, then £67). Announced in February 2022 + as a repayable £200, it became a £400 grant in the May 2022 support + package. Northern Ireland paid £400 in one go in January 2023, alongside + a £200 Alternative Fuel Payment that the model does not include. PolicyEngine models these via: - `energy_bills_rebate` (Household, YEAR) = `ebr_council_tax_rebate` + `ebr_energy_bills_credit` - Parameters at `parameters/gov/treasury/energy_bills_rebate/`: - - `energy_bills_credit.yaml` — the £200 flat household amount + - `energy_bills_credit_monthly.yaml` — the Great Britain monthly + instalments + - `energy_bills_credit_northern_ireland.yaml` — the Northern Ireland + payment in January 2023 + - `energy_bills_credit.yaml` — a yearly top-up, zero by default, kept for + reforms - `council_tax_rebate/bands.yaml` — qualifying CT bands (A–D) - - `council_tax_rebate/amount.yaml` — the £150 rebate amount + - `council_tax_rebate/amount.yaml` — £150 in 2022-23 and nothing in any + other year + +`ebr_energy_bills_credit` sums the monthly instalments within each calendar +year, so a Great Britain household on the scheme throughout gets £199 in +2022 and £201 in 2023, while HBAI counts the whole £400 in 2022-23. + +HBAI classifies both strands as miscellaneous income, not state support, +and DWP imputes receipt because the FRS did not ask about them. So +`energy_bills_rebate` is in `hbai_household_net_income` but not in +`hbai_benefits`. No FRS income field behind `miscellaneous_income` holds +the rebates, so they are not counted twice. ## Cost-of-Living Payments (2022-23 and 2023-24) @@ -62,4 +82,5 @@ will collect all three payments. This matches the actual policy. - [HMT/DWP, May 2022 cost-of-living support package announcement](https://www.gov.uk/government/news/millions-of-most-vulnerable-households-will-receive-1200-of-help-with-cost-of-living) - DWP, [Cost-of-Living Payments 2023-24 guidance](https://www.gov.uk/guidance/cost-of-living-payments-2023-to-2024) (means-tested, pensioner and disability strands). - HMT, [Energy Bills Support Scheme](https://www.gov.uk/government/publications/the-energy-bills-support-scheme-explainer) and [Council Tax Energy Rebate](https://www.gov.uk/government/publications/the-council-tax-rebate-2022-23-billing-authority-guidance). +- DWP, [HBAI quality and methodology information 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) (treatment of the cost-of-living support schemes in HBAI income). - House of Commons Library briefing [CBP-9501 — Cost of Living Payments and the energy crisis](https://commonslibrary.parliament.uk/research-briefings/cbp-9501/). diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/miscellaneous_income_trading_allowance_deduction.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/miscellaneous_income_trading_allowance_deduction.yaml new file mode 100644 index 0000000000..860602d743 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/miscellaneous_income_trading_allowance_deduction.yaml @@ -0,0 +1,186 @@ +# The trading allowance on miscellaneous income, ITTOIA 2005 Part 6A +# Chapter 1. Relief covers the income of a relevant trade and "miscellaneous +# income" (s. 783A(1)): income chargeable under Part 5 Chapter 8, income not +# otherwise charged, before expenses (s. 783AB). One allowance covers both +# (s. 783AC, "relevant income"). +# - Full relief: relevant income within the allowance is not charged +# (ss. 783AE, 783AF and 783AG). +# - Partial relief, by election above the allowance: each source less its +# share of the allowance, the shares adding up to the allowance and chosen +# by the person, with no expenses (ss. 783AI-783AK). +# income_not_otherwise_charged is the part of miscellaneous_income that is +# Chapter 8 income. 2026-27: trading allowance £1,000 (s. 783AD), personal +# allowance £12,570, basic rate 20%. + +- name: Case 1, casual income within the allowance is fully relieved. + period: 2026 + absolute_error_margin: 0 + input: + miscellaneous_income: 800 + income_not_otherwise_charged: 800 + output: + miscellaneous_income_trading_allowance_deduction: 800 + taxable_miscellaneous_income: 0 + uses_trading_allowance: true + +- name: Case 2, above the allowance, £1,000 comes off. + period: 2026 + absolute_error_margin: 0.01 + input: + employment_income: 30_000 + miscellaneous_income: 2_000 + income_not_otherwise_charged: 2_000 + output: + miscellaneous_income_trading_allowance_deduction: 1_000 + taxable_miscellaneous_income: 1_000 + # (30,000 + 1,000 - 12,570) x 20% + income_tax: 3_686 + +- name: Case 3, the same tax as £2,000 of trade receipts with no expenses. + period: 2026 + absolute_error_margin: 0.01 + input: + employment_income: 30_000 + self_employment_gross_receipts: 2_000 + self_employment_income: 2_000 + output: + trading_allowance_deduction: 1_000 + income_tax: 3_686 + +- name: Case 4, miscellaneous income outside Chapter 8 gets no allowance. + period: 2026 + absolute_error_margin: 0.01 + input: + employment_income: 30_000 + miscellaneous_income: 2_000 + output: + miscellaneous_income_trading_allowance_deduction: 0 + taxable_miscellaneous_income: 2_000 + uses_trading_allowance: false + # (30,000 + 2,000 - 12,570) x 20% + income_tax: 3_886 + +- name: Case 5, trade and casual income share one allowance. + period: 2026 + absolute_error_margin: 0 + input: + # Receipts unknown: a profit within the allowance is taken as receipts + # of £600, leaving £400 of the allowance. + self_employment_income: 600 + miscellaneous_income: 900 + income_not_otherwise_charged: 900 + output: + trading_allowance_deduction: 600 + taxable_self_employment_income: 0 + miscellaneous_income_trading_allowance_deduction: 400 + taxable_miscellaneous_income: 500 + +- name: Case 6, receipts of £800 and casual income of £150 are within the allowance together. + period: 2026 + absolute_error_margin: 0 + input: + self_employment_gross_receipts: 800 + self_employment_income: 500 + miscellaneous_income: 150 + income_not_otherwise_charged: 150 + output: + trading_allowance_deduction: 500 + miscellaneous_income_trading_allowance_deduction: 150 + taxable_self_employment_income: 0 + taxable_miscellaneous_income: 0 + +- name: Case 7, trade receipts above the allowance take all of it. + period: 2026 + absolute_error_margin: 0 + input: + # £300 of expenses: receipts less the allowance (£4,000) beats profit + # (£4,700), and the trade uses the whole allowance. + self_employment_gross_receipts: 5_000 + self_employment_income: 4_700 + miscellaneous_income: 900 + income_not_otherwise_charged: 900 + output: + trading_allowance_deduction: 700 + taxable_self_employment_income: 4_000 + miscellaneous_income_trading_allowance_deduction: 0 + taxable_miscellaneous_income: 900 + +- name: Case 8, partial relief would cost the trade more in expenses than it saves. + period: 2026 + absolute_error_margin: 0 + input: + # £1,500 of expenses: partial relief would charge receipts plus casual + # income less the allowance, £4,900, against £3,500 + £900 = £4,400. + self_employment_gross_receipts: 5_000 + self_employment_income: 3_500 + miscellaneous_income: 900 + income_not_otherwise_charged: 900 + output: + trading_allowance_deduction: 0 + miscellaneous_income_trading_allowance_deduction: 0 + taxable_miscellaneous_income: 900 + uses_trading_allowance: false + +- name: Case 9, Chapter 8 income counts only up to miscellaneous income. + period: 2026 + absolute_error_margin: 0 + input: + miscellaneous_income: 500 + income_not_otherwise_charged: 2_000 + output: + miscellaneous_income_trading_allowance_deduction: 500 + taxable_miscellaneous_income: 0 + +- name: Case 10, a larger current trading loss is claimed instead, and the casual income is taxed. + period: 2026 + absolute_error_margin: 0.01 + input: + employment_income: 30_000 + trading_loss: 5_000 + miscellaneous_income: 800 + income_not_otherwise_charged: 800 + output: + uses_trading_allowance: false + miscellaneous_income_trading_allowance_deduction: 0 + taxable_miscellaneous_income: 800 + trade_loss_relief_against_general_income: 5_000 + # (30,000 + 800 - 5,000 - 12,570) x 20% + income_tax: 2_646 + +- name: Case 11, full relief on casual income makes a smaller current trading loss nil. + period: 2026 + absolute_error_margin: 0.01 + input: + employment_income: 30_000 + trading_loss: 500 + miscellaneous_income: 800 + income_not_otherwise_charged: 800 + output: + # Full relief saves £800 of income against a £500 loss (s. 783AF makes + # the trade's loss nil). + uses_trading_allowance: true + miscellaneous_income_trading_allowance_deduction: 800 + trade_loss_relief_against_general_income: 0 + # (30,000 - 12,570) x 20% + income_tax: 3_486 + +- name: Case 12, means tests read the income before the allowance. + period: 2026 + absolute_error_margin: 0 + input: + miscellaneous_income: 2_000 + income_not_otherwise_charged: 2_000 + output: + miscellaneous_income_before_trading_allowance: 2_000 + taxable_miscellaneous_income: 1_000 + gross_taxable_inputs: 2_000 + +- name: Case 13, no trading allowance before 2017-18. + period: 2016 + absolute_error_margin: 0 + input: + miscellaneous_income: 800 + income_not_otherwise_charged: 800 + output: + miscellaneous_income_trading_allowance_deduction: 0 + taxable_miscellaneous_income: 800 diff --git a/policyengine_uk/tests/test_energy_bills_rebate_hbai_properties.py b/policyengine_uk/tests/test_energy_bills_rebate_hbai_properties.py new file mode 100644 index 0000000000..64de39ae84 --- /dev/null +++ b/policyengine_uk/tests/test_energy_bills_rebate_hbai_properties.py @@ -0,0 +1,143 @@ +"""Property tests for the 2022 Energy Bills Rebate in HBAI income. + +DWP's HBAI classifies the Energy Bills Support Scheme (£600 in Northern +Ireland) and the £150 Council Tax Rebate as miscellaneous income, not state +support (HBAI quality and methodology report FYE 2023, "Treatment of cost of +living and wider support schemes in the HBAI income estimates for FYE +2023"). The Council Tax Rebate was a one-off: billing authorities made every +payment by 30 September 2022 (Council Tax Rebate 2022-23 billing authority +guidance, paragraph 7). + +Invariants, each checked over many generated households in one reusable +simulation: + +1. HBAI household net income before the reset to zero rises by exactly the + household's energy_bills_rebate, in every year, nation and band. +2. The rebate is miscellaneous income: hbai_benefits does not change with it. +3. The Council Tax Rebate is paid once: in 2022 only, at £150, and only in + bands A to D. +""" + +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.variables.household.income.hbai_household_net_income import ( + HBAI_HOUSEHOLD_NET_INCOME_ADDS, +) + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +YEARS = [2021, 2022, 2023, 2024] +BANDS = ["A", "B", "C", "D", "E", "F", "G", "H", "I"] +COUNTRIES = ["ENGLAND", "SCOTLAND", "WALES", "NORTHERN_IRELAND"] +HOUSEHOLDS = 16 + +household = st.tuples( + st.sampled_from(BANDS), + st.sampled_from(COUNTRIES), + st.sampled_from([0.0, 6_000.0, 18_000.0, 45_000.0]), # employment income + st.booleans(), # claims Universal Credit + st.lists(st.booleans(), min_size=12, max_size=12), # EBSS months eligible +) +households = st.lists(household, min_size=HOUSEHOLDS, max_size=HOUSEHOLDS) + + +def simulation(drawn, years, without_rebate=False): + """One household per drawn case, with the same circumstances in each of + ``years``.""" + bands, countries, pay, claims_uc, months = zip(*drawn) + + def each_year(value): + return {year: value for year in years} + + people = { + f"adult_{i}": {"age": each_year(40), "employment_income": each_year(pay[i])} + for i in range(HOUSEHOLDS) + } + benunits = { + f"unit_{i}": { + "members": [f"adult_{i}"], + "would_claim_uc": each_year(claims_uc[i]), + } + for i in range(HOUSEHOLDS) + } + households = { + f"household_{i}": { + "members": [f"adult_{i}"], + "council_tax_band": each_year(bands[i]), + "country": each_year(countries[i]), + "ebr_energy_bills_credit_eligible": { + f"{year}-{month:02d}": months[i][month - 1] + for year in years + for month in range(1, 13) + }, + } + for i in range(HOUSEHOLDS) + } + if without_rebate: + for values in households.values(): + values["energy_bills_rebate"] = each_year(0) + return Simulation( + situation={"people": people, "benunits": benunits, "households": households} + ) + + +SETTINGS = settings( + max_examples=10, + deadline=None, + suppress_health_check=[HealthCheck.too_slow], +) + + +@SETTINGS +@given(households, st.sampled_from(YEARS)) +def test_hbai_income_rises_by_exactly_the_rebate(drawn, year): + with_rebate = simulation(drawn, [year]) + without = simulation(drawn, [year], without_rebate=True) + rebate = with_rebate.calculate("energy_bills_rebate", year) + gain = with_rebate.calculate( + "hbai_household_net_income_before_reset_to_zero", year + ) - without.calculate("hbai_household_net_income_before_reset_to_zero", year) + np.testing.assert_allclose(gain, rebate, atol=0.01) + np.testing.assert_allclose( + with_rebate.calculate("hbai_benefits", year), + without.calculate("hbai_benefits", year), + atol=0.01, + ) + + +@SETTINGS +@given(households) +def test_the_council_tax_rebate_is_paid_once(drawn): + bands = np.array([band for band, *_ in drawn]) + years = range(2015, 2031) + sim = simulation(drawn, years) + total = np.zeros(HOUSEHOLDS) + for year in years: + rebate = sim.calculate("ebr_council_tax_rebate", year) + if year == 2022: + np.testing.assert_array_equal( + rebate, np.where(np.isin(bands, ["A", "B", "C", "D"]), 150, 0) + ) + else: + np.testing.assert_array_equal(rebate, 0) + total += rebate + assert (total <= 150).all() + + +def test_the_rebate_is_counted_once_and_not_as_state_support(): + """HBAI adds energy_bills_rebate once, and not its parts as well; it and + the Warm Home Discount are miscellaneous income, outside hbai_benefits.""" + from policyengine_uk import CountryTaxBenefitSystem + + system = CountryTaxBenefitSystem() + parts = system.variables["energy_bills_rebate"].adds + assert set(parts) == {"ebr_council_tax_rebate", "ebr_energy_bills_credit"} + assert HBAI_HOUSEHOLD_NET_INCOME_ADDS.count("energy_bills_rebate") == 1 + assert not set(parts) & set(HBAI_HOUSEHOLD_NET_INCOME_ADDS) + state_support = system.variables["hbai_benefits"].adds + for miscellaneous_income in ["energy_bills_rebate", "warm_home_discount"]: + assert miscellaneous_income not in state_support diff --git a/policyengine_uk/tests/test_trading_allowance_miscellaneous_income_properties.py b/policyengine_uk/tests/test_trading_allowance_miscellaneous_income_properties.py new file mode 100644 index 0000000000..9938f69124 --- /dev/null +++ b/policyengine_uk/tests/test_trading_allowance_miscellaneous_income_properties.py @@ -0,0 +1,335 @@ +"""Property tests for the trading allowance on miscellaneous income +(ITTOIA 2005 Part 6A Chapter 1). + +The allowance relieves the receipts of a person's relevant trades and their +"miscellaneous income", income chargeable under Part 5 Chapter 8 (income not +otherwise charged), before expenses (ss. 783A, 783AB and 783AC). One +allowance covers both. Within it, both are relieved in full (ss. 783AF and +783AG); above it, the person can elect to charge each source less a share of +the allowance, the shares adding up to the allowance, in place of actual +expenses (ss. 783AI-783AK). income_not_otherwise_charged is the part of +miscellaneous_income that is Chapter 8 income. + +Invariants, each checked over many generated people in one reusable +simulation: + +1. With trade receipts known, taxable trade profit plus taxable + miscellaneous income equals the statutory figure: the lower of ordinary + treatment and the allowance (differential against a reference written + from the Act). +2. One allowance: the deductions from trade profits and from miscellaneous + income never add up to more than the allowance. +3. Bounds: the miscellaneous deduction is between nil and the Chapter 8 + income, which counts up to positive miscellaneous income; taxable + miscellaneous income is between nil and miscellaneous income. +4. The allowance's relief on trade profits does not depend on Chapter 8 + income when no current trading loss is supplied: the miscellaneous income + gets only what the trade receipts leave. +5. With no Chapter 8 income nothing changes: no deduction, and taxable + miscellaneous income is miscellaneous income with a loss counted as nil. +6. Means tests read the income before the allowance, whatever part of it is + Chapter 8 income. +7. Income tax never rises as more of a person's miscellaneous income is + Chapter 8 income. +8. Parity (differential): Chapter 8 income of X, with no trade, is charged + the same income tax as trade receipts of X with no expenses. +9. A current trading loss is never both claimed and given up: when the + allowance gives either relief, no current loss is relieved against + general income or Class 4, and uses_trading_allowance says so. +""" + +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") + +YEAR = 2026 +OTHER_EMPLOYMENT_INCOME = [0, 8_000, 30_000, 60_000, 110_000, 200_000] +CASES = 24 +# Each case is simulated three times: as drawn, with no Chapter 8 income, and +# with all its positive miscellaneous income Chapter 8 income. +PEOPLE = 3 * CASES + +amount = st.one_of( + st.floats(min_value=0, max_value=3_500), + st.floats(min_value=0, max_value=60_000), + st.sampled_from([0.0, 400.0, 999.99, 1_000.0, 1_000.01, 3_000.0, 12_570.0]), +) +case = st.tuples( + amount, # trade profit + amount, # actual expenses + st.sampled_from([0.0, 0.0, 150.0, 900.0]), # capital allowances + st.sampled_from(["unknown", "consistent", "none"]), # receipts + st.one_of(amount, st.floats(min_value=-2_000, max_value=0)), # misc income + st.floats(min_value=0, max_value=1.5), # Chapter 8 share of misc income + st.sampled_from([0.0, 0.0, 0.0, 300.0, 5_000.0]), # current trading loss +) +cases = st.lists(case, min_size=CASES, max_size=CASES) + + +@pytest.fixture(scope="module") +def sim(): + people = { + f"person_{i}": { + "age": {YEAR: 40}, + "employment_income": { + YEAR: OTHER_EMPLOYMENT_INCOME[i % CASES % len(OTHER_EMPLOYMENT_INCOME)] + }, + "self_employment_income": {YEAR: 0}, + "self_employment_gross_receipts": {YEAR: 0}, + "capital_allowances": {YEAR: 0}, + "miscellaneous_income": {YEAR: 0}, + "income_not_otherwise_charged": {YEAR: 0}, + "trading_loss": {YEAR: 0}, + } + for i in range(PEOPLE) + } + return Simulation( + situation={ + "people": people, + "benunits": { + f"benunit_{i}": {"members": [f"person_{i}"]} for i in range(PEOPLE) + }, + "households": { + f"household_{i}": {"members": [f"person_{i}"]} for i in range(PEOPLE) + }, + } + ) + + +OUTPUTS = [ + "trading_allowance_deduction", + "miscellaneous_income_trading_allowance_deduction", + "taxable_self_employment_income", + "taxable_miscellaneous_income", + "miscellaneous_income_before_trading_allowance", + "uses_trading_allowance", + "trade_loss_relief_against_general_income", + "ni_class_4_trading_loss", + "income_tax", +] + + +def run(sim, drawn, trade=True): + """Simulate the drawn cases three times over (see PEOPLE). With + ``trade`` false, the trade is dropped.""" + profit, expenses, capital_allowances, mode, misc, share, loss = map( + np.array, zip(*drawn) + ) + if not trade: + profit = np.zeros_like(profit) + expenses = np.zeros_like(expenses) + capital_allowances = np.zeros_like(capital_allowances) + mode = np.full_like(mode, "none") + chapter_8 = np.concatenate( + [share * np.maximum(misc, 0), np.zeros_like(misc), np.maximum(misc, 0)] + ) + profit, expenses, capital_allowances, mode, misc, loss = ( + np.tile(x, 3) for x in (profit, expenses, capital_allowances, mode, misc, loss) + ) + profit = np.where(mode == "none", 0, profit) + receipts = np.where(mode == "consistent", profit + expenses, 0) + sim.set_input("self_employment_income", YEAR, profit) + sim.set_input("self_employment_gross_receipts", YEAR, receipts) + sim.set_input("capital_allowances", YEAR, capital_allowances) + sim.set_input("miscellaneous_income", YEAR, misc) + sim.set_input("income_not_otherwise_charged", YEAR, chapter_8) + sim.set_input("trading_loss", YEAR, loss) + sim.reset_calculations() + + def stored(name): + return sim.calculate(name, YEAR).astype(float) + + # Compare against the stored (float32) inputs, not the float64 draws. + r = dict( + profit=stored("self_employment_income"), + receipts=stored("self_employment_gross_receipts"), + capital_allowances=stored("capital_allowances"), + misc=stored("miscellaneous_income"), + chapter_8=stored("income_not_otherwise_charged"), + loss=stored("trading_loss"), + allowance=float( + sim.tax_benefit_system.parameters( + YEAR + ).gov.hmrc.income_tax.allowances.trading_allowance + ), + ) + r["known"] = (r["receipts"] > 0) & (r["receipts"] >= r["profit"]) + r["expenses"] = np.maximum(r["receipts"] - r["profit"], 0) + for name in OUTPUTS: + r[name] = sim.calculate(name, YEAR) + return r + + +def part(r, name, i): + """The ith of the three copies of each case.""" + return r[name][i * CASES : (i + 1) * CASES] + + +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) + + +def statutory_taxable_income(r): + """Taxable trade profit plus taxable miscellaneous income under Part 6A, + with trade receipts known and no current trading loss. + + Ordinary treatment charges the profit after capital allowances (nil if + they exceed it) and all positive miscellaneous income. Under the + allowance, relevant income (receipts plus Chapter 8 income) within it is + not charged; above it, relevant income less the allowance is charged, + however the person splits it. Miscellaneous income outside Chapter 8 is + charged either way. The person takes the lower. + """ + allowance = r["allowance"] + misc = np.maximum(r["misc"], 0) + chapter_8 = np.minimum(np.maximum(r["chapter_8"], 0), misc) + other = misc - chapter_8 + receipts = np.where(r["known"], r["receipts"], 0) + ordinary = np.maximum(r["profit"] - r["capital_allowances"], 0) + misc + relevant = receipts + chapter_8 + with_allowance = np.maximum(relevant - allowance, 0) + other + return np.minimum(ordinary, with_allowance) + + +SETTINGS = settings( + max_examples=30, + deadline=None, + suppress_health_check=[HealthCheck.too_slow], +) + + +@SETTINGS +@given(cases) +def test_taxable_income_matches_statutory_reference(sim, drawn): + r = run(sim, drawn) + # Known receipts or no trade, and no current loss to weigh. + applies = (r["known"] | (r["profit"] == 0)) & (r["loss"] == 0) + taxable = r["taxable_self_employment_income"] + r["taxable_miscellaneous_income"] + expected = statutory_taxable_income(r) + error = np.abs(taxable - expected) + assert ( + error[applies] + <= tolerance(r["receipts"], r["profit"], r["misc"], r["chapter_8"])[applies] + ).all() + + +@SETTINGS +@given(cases) +def test_one_allowance_covers_both(sim, drawn): + r = run(sim, drawn) + total = ( + r["trading_allowance_deduction"] + + r["miscellaneous_income_trading_allowance_deduction"] + ) + assert (total <= r["allowance"] + tolerance(r["allowance"])).all() + + +@SETTINGS +@given(cases) +def test_deduction_and_taxable_income_are_bounded(sim, drawn): + r = run(sim, drawn) + deduction = r["miscellaneous_income_trading_allowance_deduction"] + eligible = np.minimum(np.maximum(r["chapter_8"], 0), np.maximum(r["misc"], 0)) + assert (deduction >= 0).all() + assert (deduction <= eligible + tolerance(eligible)).all() + taxable = r["taxable_miscellaneous_income"] + assert (taxable >= -tolerance(r["misc"])).all() + assert (taxable <= np.maximum(r["misc"], 0) + tolerance(r["misc"])).all() + + +@SETTINGS +@given(cases) +def test_trade_relief_does_not_depend_on_chapter_8_income(sim, drawn): + r = run(sim, drawn) + no_loss = part(r, "loss", 0) == 0 + drawn_share = part(r, "trading_allowance_deduction", 0) + none = part(r, "trading_allowance_deduction", 1) + all_of_it = part(r, "trading_allowance_deduction", 2) + assert np.allclose(drawn_share[no_loss], none[no_loss], atol=0.01) + assert np.allclose(all_of_it[no_loss], none[no_loss], atol=0.01) + + +@SETTINGS +@given(cases) +def test_no_chapter_8_income_changes_nothing(sim, drawn): + r = run(sim, drawn) + misc = part(r, "misc", 1) + np.testing.assert_array_equal( + part(r, "miscellaneous_income_trading_allowance_deduction", 1), 0 + ) + np.testing.assert_allclose( + part(r, "taxable_miscellaneous_income", 1), + np.maximum(misc, 0), + atol=0.01, + ) + + +@SETTINGS +@given(cases) +def test_means_tests_read_income_before_the_allowance(sim, drawn): + r = run(sim, drawn) + np.testing.assert_allclose( + r["miscellaneous_income_before_trading_allowance"], + np.maximum(r["misc"], 0), + atol=0.01, + ) + + +@SETTINGS +@given(cases) +def test_income_tax_never_rises_with_more_chapter_8_income(sim, drawn): + r = run(sim, drawn) + # Without a current loss, whose opt-out weighs raw amounts, not tax. + no_loss = part(r, "loss", 0) == 0 + none, drawn_share, all_of_it = (part(r, "income_tax", i) for i in (1, 0, 2)) + # The drawn share can exceed one, so order the two by the Chapter 8 + # income each copy actually has. + more_than_all = part(r, "chapter_8", 0) > part(r, "chapter_8", 2) + assert (drawn_share[no_loss] <= none[no_loss] + tolerance(none[no_loss])).all() + lower = np.where(more_than_all, all_of_it, drawn_share) + higher = np.where(more_than_all, drawn_share, all_of_it) + assert (lower[no_loss] >= higher[no_loss] - tolerance(lower[no_loss])).all() + + +@SETTINGS +@given(st.lists(amount, min_size=CASES, max_size=CASES)) +def test_chapter_8_income_is_taxed_like_trade_receipts(sim, amounts): + amounts = np.array(amounts) + zeros = np.zeros(CASES) + # Copy 0: Chapter 8 income of X. Copy 1: receipts and profit of X. + misc = np.concatenate([amounts, zeros, zeros]) + profit = np.concatenate([zeros, amounts, zeros]) + sim.set_input("self_employment_income", YEAR, profit) + sim.set_input("self_employment_gross_receipts", YEAR, profit) + sim.set_input("capital_allowances", YEAR, np.zeros(PEOPLE)) + sim.set_input("miscellaneous_income", YEAR, misc) + sim.set_input("income_not_otherwise_charged", YEAR, misc) + sim.set_input("trading_loss", YEAR, np.zeros(PEOPLE)) + sim.reset_calculations() + tax = sim.calculate("income_tax", YEAR) + np.testing.assert_allclose( + tax[:CASES], tax[CASES : 2 * CASES], atol=float(tolerance(amounts).max()) + ) + + +@SETTINGS +@given(cases) +def test_a_current_loss_is_never_both_claimed_and_given_up(sim, drawn): + for trade in (True, False): + r = run(sim, drawn, trade=trade) + # Either relief, not only the trade's, means the allowance was used. + used = (r["trading_allowance_deduction"] > 0) | ( + r["miscellaneous_income_trading_allowance_deduction"] > 0 + ) + assert (r["trade_loss_relief_against_general_income"][used] == 0).all() + assert (r["ni_class_4_trading_loss"][used] == 0).all() + # A loss larger than everything the allowance could save is claimed. + saving_bound = r["allowance"] + tolerance(r["allowance"]) + assert not used[r["loss"] > saving_bound].any() + np.testing.assert_array_equal(r["uses_trading_allowance"], used) From ddbdf0d94fa87b5b7e6a224eb29dbc3f36349943 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 16:25:50 -0400 Subject: [PATCH 3/8] Uprate income_not_otherwise_charged with miscellaneous_income It declares the GDP-per-capita index, so dataset projection needs it in uprating_indices.yaml (test_uprating_declarations), in the block that grows miscellaneous_income, which it is part of. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/data/uprating_indices.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/policyengine_uk/data/uprating_indices.yaml b/policyengine_uk/data/uprating_indices.yaml index 8c2e4a57ae..d978c0ffb3 100644 --- a/policyengine_uk/data/uprating_indices.yaml +++ b/policyengine_uk/data/uprating_indices.yaml @@ -116,6 +116,8 @@ gov.economic_assumptions.yoy_growth.obr.per_capita.gdp: - lump_sum_income - maintenance_income - miscellaneous_income +# Part of miscellaneous_income, so it grows with it. +- income_not_otherwise_charged - mortgage_capital_repayment - net_financial_wealth - non_residential_property_value From 729062e31b41bfb20f748ea1c8263062f6551c70 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 16:53:54 -0400 Subject: [PATCH 4/8] Address review r1: losses brought forward, unknown receipts, s. 783AM The trades-first split of the allowance charges the least income tax only without trade losses brought forward. With them, a smaller trade share can charge less now but uses up a loss that otherwise carries forward (ITA 2007 ss. 83-84) and raises Class 4 profits. The helper now says so instead of claiming every split is tax-neutral, and YAML Case 14 pins the split. The property tests draw losses brought forward and negative profits. The statutory reference returns the trades-first figure, which the model must match, and the lowest figure any split under s. 783AK allows, which it must never undercut and must equal without losses brought forward. The docs state the zero-receipts reading for a loss-making trade, and that ordinary treatment is an election out of full relief (s. 783AL) or no election for partial relief (s. 783AM). Co-Authored-By: Claude Opus 5.5 --- ...us_income_trading_allowance_deduction.yaml | 27 ++++- ...lowance_miscellaneous_income_properties.py | 110 +++++++++++++----- policyengine_uk/utils/trading_allowance.py | 45 ++++--- ...eous_income_trading_allowance_deduction.py | 7 +- .../allowances/uses_trading_allowance.py | 10 +- .../input/income_not_otherwise_charged.py | 4 +- 6 files changed, 149 insertions(+), 54 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/miscellaneous_income_trading_allowance_deduction.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/miscellaneous_income_trading_allowance_deduction.yaml index 860602d743..e25bf75621 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/miscellaneous_income_trading_allowance_deduction.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/miscellaneous_income_trading_allowance_deduction.yaml @@ -157,7 +157,8 @@ income_not_otherwise_charged: 800 output: # Full relief saves £800 of income against a £500 loss (s. 783AF makes - # the trade's loss nil). + # the trade's loss nil). Without receipts, the loss-making trade is + # taken to have none, so the casual income alone is relevant income. uses_trading_allowance: true miscellaneous_income_trading_allowance_deduction: 800 trade_loss_relief_against_general_income: 0 @@ -184,3 +185,27 @@ output: miscellaneous_income_trading_allowance_deduction: 0 taxable_miscellaneous_income: 800 + +- name: Case 14, losses brought forward do not move the allowance from the trade. + period: 2026 + absolute_error_margin: 0.01 + input: + employment_income: 30_000 + self_employment_gross_receipts: 800 + self_employment_income: 800 + loss_relief: 800 + miscellaneous_income: 800 + income_not_otherwise_charged: 800 + output: + # Relevant income of £1,600 is above the allowance. The trade takes its + # receipts' worth of it (£800) and the casual income the other £200 + # (s. 783AK). The trade's profit is then nil, so none of the £800 loss + # is used this year; it carries forward (ITA 2007 ss. 83 and 84). The Act + # would also allow £200 for the trade and £800 for the casual income: + # £3,486 of tax now, using £600 of the loss. + trading_allowance_deduction: 800 + miscellaneous_income_trading_allowance_deduction: 200 + taxable_self_employment_income: 0 + taxable_miscellaneous_income: 600 + # (30,000 + 600 - 12,570) x 20% + income_tax: 3_606 diff --git a/policyengine_uk/tests/test_trading_allowance_miscellaneous_income_properties.py b/policyengine_uk/tests/test_trading_allowance_miscellaneous_income_properties.py index 9938f69124..4ff76072dd 100644 --- a/policyengine_uk/tests/test_trading_allowance_miscellaneous_income_properties.py +++ b/policyengine_uk/tests/test_trading_allowance_miscellaneous_income_properties.py @@ -13,10 +13,13 @@ Invariants, each checked over many generated people in one reusable simulation: -1. With trade receipts known, taxable trade profit plus taxable - miscellaneous income equals the statutory figure: the lower of ordinary - treatment and the allowance (differential against a reference written - from the Act). +1. With trade receipts known (or a trade with a loss or no profit, taken + to have none), taxable trade profit plus taxable miscellaneous income + equals the figure the Act gives under the model's split of the allowance, + trades first: the lower of ordinary treatment and the allowance. It is + never below the lowest figure any split the Act allows gives, and equals + it when no trade losses are brought forward (differential against a + reference written from the Act). 2. One allowance: the deductions from trade profits and from miscellaneous income never add up to more than the allowance. 3. Bounds: the miscellaneous deduction is between nil and the Chapter 8 @@ -35,7 +38,8 @@ the same income tax as trade receipts of X with no expenses. 9. A current trading loss is never both claimed and given up: when the allowance gives either relief, no current loss is relieved against - general income or Class 4, and uses_trading_allowance says so. + general income or Class 4, and a loss above anything the allowance could + save is claimed. """ import numpy as np @@ -60,13 +64,14 @@ st.sampled_from([0.0, 400.0, 999.99, 1_000.0, 1_000.01, 3_000.0, 12_570.0]), ) case = st.tuples( - amount, # trade profit + st.one_of(amount, st.floats(min_value=-3_000, max_value=0)), # trade profit amount, # actual expenses st.sampled_from([0.0, 0.0, 150.0, 900.0]), # capital allowances st.sampled_from(["unknown", "consistent", "none"]), # receipts st.one_of(amount, st.floats(min_value=-2_000, max_value=0)), # misc income st.floats(min_value=0, max_value=1.5), # Chapter 8 share of misc income st.sampled_from([0.0, 0.0, 0.0, 300.0, 5_000.0]), # current trading loss + st.sampled_from([0.0, 0.0, 0.0, 300.0, 1_500.0, 20_000.0]), # losses b/f ) cases = st.lists(case, min_size=CASES, max_size=CASES) @@ -85,6 +90,7 @@ def sim(): "miscellaneous_income": {YEAR: 0}, "income_not_otherwise_charged": {YEAR: 0}, "trading_loss": {YEAR: 0}, + "loss_relief": {YEAR: 0}, } for i in range(PEOPLE) } @@ -117,28 +123,39 @@ def sim(): def run(sim, drawn, trade=True): """Simulate the drawn cases three times over (see PEOPLE). With ``trade`` false, the trade is dropped.""" - profit, expenses, capital_allowances, mode, misc, share, loss = map( - np.array, zip(*drawn) + profit, expenses, capital_allowances, mode, misc, share, loss, brought_forward = ( + map(np.array, zip(*drawn)) ) if not trade: profit = np.zeros_like(profit) expenses = np.zeros_like(expenses) capital_allowances = np.zeros_like(capital_allowances) + brought_forward = np.zeros_like(brought_forward) mode = np.full_like(mode, "none") chapter_8 = np.concatenate( [share * np.maximum(misc, 0), np.zeros_like(misc), np.maximum(misc, 0)] ) - profit, expenses, capital_allowances, mode, misc, loss = ( - np.tile(x, 3) for x in (profit, expenses, capital_allowances, mode, misc, loss) + profit, expenses, capital_allowances, mode, misc, loss, brought_forward = ( + np.tile(x, 3) + for x in ( + profit, + expenses, + capital_allowances, + mode, + misc, + loss, + brought_forward, + ) ) profit = np.where(mode == "none", 0, profit) - receipts = np.where(mode == "consistent", profit + expenses, 0) + receipts = np.where(mode == "consistent", np.maximum(profit + expenses, 0), 0) sim.set_input("self_employment_income", YEAR, profit) sim.set_input("self_employment_gross_receipts", YEAR, receipts) sim.set_input("capital_allowances", YEAR, capital_allowances) sim.set_input("miscellaneous_income", YEAR, misc) sim.set_input("income_not_otherwise_charged", YEAR, chapter_8) sim.set_input("trading_loss", YEAR, loss) + sim.set_input("loss_relief", YEAR, brought_forward) sim.reset_calculations() def stored(name): @@ -152,6 +169,7 @@ def stored(name): misc=stored("miscellaneous_income"), chapter_8=stored("income_not_otherwise_charged"), loss=stored("trading_loss"), + brought_forward=stored("loss_relief"), allowance=float( sim.tax_benefit_system.parameters( YEAR @@ -177,24 +195,49 @@ def tolerance(*values): def statutory_taxable_income(r): """Taxable trade profit plus taxable miscellaneous income under Part 6A, - with trade receipts known and no current trading loss. - - Ordinary treatment charges the profit after capital allowances (nil if - they exceed it) and all positive miscellaneous income. Under the - allowance, relevant income (receipts plus Chapter 8 income) within it is - not charged; above it, relevant income less the allowance is charged, - however the person splits it. Miscellaneous income outside Chapter 8 is - charged either way. The person takes the lower. + with trade receipts known (or a trade with a loss or no profit, taken to + have none) and no current trading loss, as ``(trades_first, lowest)``. + + Ordinary treatment charges the profit after capital allowances and + losses brought forward (nil if they exceed it) and all positive + miscellaneous income. Relevant income (receipts plus Chapter 8 income) + within the allowance is not charged (ss. 783AF and 783AG). Above it, + partial relief charges each trade's receipts less its share of the + allowance, less losses brought forward (ITA 2007 s. 83), and the Chapter + 8 income less the rest of the allowance (ss. 783AI-783AK). The trade's + share is at most its receipts and the allowance, and at least what the + Chapter 8 income cannot take (s. 783AK(4) and (5)). Miscellaneous income + outside Chapter 8 is charged either way. The person takes the lower of + ordinary treatment and the allowance, with the trade's share either as + large as it can be (the model's split) or whatever charges least. """ allowance = r["allowance"] misc = np.maximum(r["misc"], 0) chapter_8 = np.minimum(np.maximum(r["chapter_8"], 0), misc) other = misc - chapter_8 receipts = np.where(r["known"], r["receipts"], 0) - ordinary = np.maximum(r["profit"] - r["capital_allowances"], 0) + misc - relevant = receipts + chapter_8 - with_allowance = np.maximum(relevant - allowance, 0) + other - return np.minimum(ordinary, with_allowance) + losses = r["brought_forward"] + ordinary = np.maximum(r["profit"] - r["capital_allowances"] - losses, 0) + misc + + def partial_relief(trade_share): + trade = np.maximum(receipts - trade_share - losses, 0) + return trade + chapter_8 - (allowance - trade_share) + other + + # The charge is piecewise linear in the trade's share, so its least value + # is at an end or where losses brought forward stop absorbing the trade. + smallest = np.maximum(allowance - chapter_8, 0) + largest = np.minimum(receipts, allowance) + kink = np.clip(receipts - losses, smallest, largest) + within = receipts + chapter_8 <= allowance + trades_first = np.where(within, other, partial_relief(largest)) + lowest = np.where( + within, + other, + np.minimum.reduce( + [partial_relief(share) for share in (smallest, largest, kink)] + ), + ) + return np.minimum(ordinary, trades_first), np.minimum(ordinary, lowest) SETTINGS = settings( @@ -208,15 +251,19 @@ def statutory_taxable_income(r): @given(cases) def test_taxable_income_matches_statutory_reference(sim, drawn): r = run(sim, drawn) - # Known receipts or no trade, and no current loss to weigh. - applies = (r["known"] | (r["profit"] == 0)) & (r["loss"] == 0) + # Known receipts, or none (no trade, or a loss), and no current loss. + applies = (r["known"] | (r["profit"] <= 0)) & (r["loss"] == 0) taxable = r["taxable_self_employment_income"] + r["taxable_miscellaneous_income"] - expected = statutory_taxable_income(r) - error = np.abs(taxable - expected) - assert ( - error[applies] - <= tolerance(r["receipts"], r["profit"], r["misc"], r["chapter_8"])[applies] - ).all() + trades_first, lowest = statutory_taxable_income(r) + margin = tolerance( + r["receipts"], r["profit"], r["misc"], r["chapter_8"], r["brought_forward"] + ) + assert (np.abs(taxable - trades_first)[applies] <= margin[applies]).all() + # Never more relief than the Act allows, and all of it without losses + # brought forward, which only a smaller trade share can put to use. + assert (taxable[applies] >= lowest[applies] - margin[applies]).all() + no_losses = applies & (r["brought_forward"] == 0) + assert (np.abs(taxable - lowest)[no_losses] <= margin[no_losses]).all() @SETTINGS @@ -311,6 +358,7 @@ def test_chapter_8_income_is_taxed_like_trade_receipts(sim, amounts): sim.set_input("miscellaneous_income", YEAR, misc) sim.set_input("income_not_otherwise_charged", YEAR, misc) sim.set_input("trading_loss", YEAR, np.zeros(PEOPLE)) + sim.set_input("loss_relief", YEAR, np.zeros(PEOPLE)) sim.reset_calculations() tax = sim.calculate("income_tax", YEAR) np.testing.assert_allclose( diff --git a/policyengine_uk/utils/trading_allowance.py b/policyengine_uk/utils/trading_allowance.py index 3064cca1cb..3139f2e2df 100644 --- a/policyengine_uk/utils/trading_allowance.py +++ b/policyengine_uk/utils/trading_allowance.py @@ -28,22 +28,32 @@ def trading_allowance_reliefs(person, period, parameters): allowance, each trade's profit is its receipts less its share of the allowance, with no expenses, and the miscellaneous income is that income less its share. The shares add up to the allowance and the - individual chooses them (s. 783AK). Any split gives the same income - tax, since the total charged is relevant income less the allowance; - the model gives the trades their receipts' worth first, which keeps - Class 4 as low as it can be. So the miscellaneous income gets the part - of the allowance the trade receipts leave. Where trade receipts exceed - the allowance it gets none: partial relief then beats actual - deductions exactly when the trades' expenses and capital allowances - are under the allowance, and the trades take all of it. - - Opting out (s. 783AL): a raw current trading loss larger than the - saving from both deductions is claimed instead, and then neither - applies. + individual chooses them (s. 783AK). The model gives the trades their + receipts' worth first, which keeps Class 4 as low as it can be, so the + miscellaneous income gets the part of the allowance the trade receipts + leave. Where trade receipts exceed the allowance it gets none: partial + relief then beats actual deductions exactly when the trades' expenses + and capital allowances are under the allowance, and the trades take + all of it. Without losses brought forward, every split charges the + same income tax: relevant income less the allowance. With them + (loss_relief, ITA 2007 s. 83), a smaller trade share can charge less + tax this year, because the loss absorbs the trade profit it leaves, but + it uses up that much of the loss, which otherwise carries forward + against later profits of the trade (s. 84). The trades-first split + leaves the loss for later years and keeps Class 4 profits lower, so + the model uses it there too. + - Ordinary treatment: an individual whose relevant income is within the + allowance can elect out of full relief (s. 783AL), and one above it + need not elect for partial relief (s. 783AM). A raw current trading + loss larger than the saving from both deductions is claimed instead, + and then neither applies. - Without gross receipts, a profit within the allowance is taken as - receipts equal to the profit, which takes up that much of the allowance; - a larger profit is taken to reflect the better of actual expenses and the - allowance already, which leaves none of it for miscellaneous income. + Without gross receipts, a trade's receipts are taken as the lowest its + profit allows: a profit within the allowance is taken as receipts equal + to the profit, which takes up that much of the allowance, and a loss or + no profit as no receipts, which leaves all of it for miscellaneous + income. A larger profit is taken to reflect the better of actual + expenses and the allowance already, which leaves none of it. The miscellaneous income is income_not_otherwise_charged, up to the person's miscellaneous_income when that is positive (it is part of it). @@ -84,8 +94,9 @@ def trading_allowance_reliefs(person, period, parameters): ) # Full relief makes every relevant trade's current profit AND loss - # nil (s.783AF); the opt-out is for the individual/year (s.783AL), and - # takes the miscellaneous income's relief with it. Partial relief also + # nil (s.783AF); the election out of it (s.783AL), like not electing for + # partial relief (s.783AM), is for the individual/year and takes the + # miscellaneous income's relief with it. Partial relief also # replaces every trade's actual expenses. Choose ordinary treatment if # the raw supplied current loss exceeds the allowance saving. Before # applying net-income and relief-cap limits, this also preserves an diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/miscellaneous_income_trading_allowance_deduction.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/miscellaneous_income_trading_allowance_deduction.py index fc3f378bdf..44264fd693 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/miscellaneous_income_trading_allowance_deduction.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/miscellaneous_income_trading_allowance_deduction.py @@ -15,8 +15,11 @@ class miscellaneous_income_trading_allowance_deduction(Variable): "can elect to deduct the allowance, split between them as they " "choose, in place of actual expenses (ss. 783AI-783AK); the model " "gives the trades their receipts' worth first, so this income gets " - "the part of the allowance the trade receipts leave. Opting out to " - "claim a current trading loss (s. 783AL) leaves it unrelieved. Only " + "the part of the allowance the trade receipts leave, even where trade " + "losses brought forward would absorb the trade's share (see " + "utils/trading_allowance.py). Ordinary treatment to claim a current " + "trading loss (no full relief, s. 783AL, or no partial relief, s. " + "783AM) leaves it unrelieved. Only " "income tax reads this deduction, through taxable_miscellaneous_income; " "means tests and gross income bases read " "miscellaneous_income_before_trading_allowance. Datasets that hold " diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_trading_allowance.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_trading_allowance.py index cb9d4d8ad4..588605e2e8 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_trading_allowance.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_trading_allowance.py @@ -12,8 +12,10 @@ class uses_trading_allowance(Variable): "Either relief covers all their relevant income (ITTOIA 2005 s. 783A), " "so it makes every relevant trade's current profit or loss its " "allowance figure (ss. 783AF and 783AI), and a current trading loss " - "cannot then be claimed. A person with a larger current loss opts out " - "(s. 783AL) and claims it." + "cannot then be claimed. A person with a larger current loss takes " + "ordinary treatment and claims it: within the allowance by electing " + "out of full relief (s. 783AL), above it by not electing for partial " + "relief (s. 783AM)." ) definition_period = YEAR reference = [ @@ -25,6 +27,10 @@ class uses_trading_allowance(Variable): title="Income Tax (Trading and Other Income) Act 2005 s. 783AL", href="https://www.legislation.gov.uk/ukpga/2005/5/section/783AL", ), + dict( + title="Income Tax (Trading and Other Income) Act 2005 s. 783AM", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/783AM", + ), ] def formula(person, period, parameters): diff --git a/policyengine_uk/variables/input/income_not_otherwise_charged.py b/policyengine_uk/variables/input/income_not_otherwise_charged.py index 7a83e97da9..615a6ec09a 100644 --- a/policyengine_uk/variables/input/income_not_otherwise_charged.py +++ b/policyengine_uk/variables/input/income_not_otherwise_charged.py @@ -17,7 +17,9 @@ class income_not_otherwise_charged(Variable): "when unknown. The survey data do not separate it from the rest of " "miscellaneous_income, which also holds royalties, charges under other " "Part 5 Chapters and, on SPI records, benefits and termination " - "payments, none of which the allowance relieves." + "payments, none of which the allowance relieves. Without " + "self_employment_gross_receipts, a trade with a loss or no profit is " + "taken to have no receipts, so its relevant income is this alone." ) definition_period = YEAR unit = GBP From 1077aa261edd400f6cf0ea0a58e83338d5da25c0 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 19:54:34 -0400 Subject: [PATCH 5/8] Address review r2: HBAI rebate by financial year, Welsh guard, pension relief MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HBAI counts the Energy Bills Rebate by financial year, as DWP does: hbai_energy_bills_rebate sums the monthly credit from April to the next March, so the whole £400 (Northern Ireland's January 2023 payment too) counts in 2022-23 and nothing in 2023-24. energy_bills_rebate keeps the calendar-year sum for household net income and government spending. - The Welsh childcare offer's reviewed source set now names miscellaneous_income_before_trading_allowance (CI's only failure). - The trading allowance docstring no longer claims every split charges the same income tax without losses brought forward: pension relief reads the trade's share too. YAML Case 15 pins the trades-first result. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2724.fixed.md | 2 +- .../gov/treasury/cost-of-living-payments.md | 18 ++-- ...us_income_trading_allowance_deduction.yaml | 24 +++++ .../energy_bills_rebate.yaml | 50 ++++++++-- ...est_energy_bills_rebate_hbai_properties.py | 93 ++++++++++++++++--- ...lowance_miscellaneous_income_properties.py | 5 +- ...st_wales_childcare_offer_income_sources.py | 2 +- policyengine_uk/utils/trading_allowance.py | 21 +++-- ...eous_income_trading_allowance_deduction.py | 3 +- .../hbai_energy_bills_rebate.py | 34 +++++++ .../income/hbai_household_net_income.py | 5 +- 11 files changed, 210 insertions(+), 47 deletions(-) create mode 100644 policyengine_uk/variables/gov/treasury/energy_bills_rebate/hbai_energy_bills_rebate.py diff --git a/changelog.d/2724.fixed.md b/changelog.d/2724.fixed.md index b4673125c4..f0df337638 100644 --- a/changelog.d/2724.fixed.md +++ b/changelog.d/2724.fixed.md @@ -1 +1 @@ -Counted the 2022 Energy Bills Rebate (the Energy Bills Support Scheme credit and the £150 Council Tax Rebate) in HBAI household net income as miscellaneous income, as DWP's HBAI does, and paid the one-off Council Tax Rebate in 2022-23 only rather than again in 2023-24. +Counted the 2022 Energy Bills Rebate (the Energy Bills Support Scheme credit and the £150 Council Tax Rebate) in HBAI household net income as miscellaneous income, by financial year as DWP's HBAI does (all £400 of the credit in 2022-23), and paid the one-off Council Tax Rebate in 2022-23 only rather than again in 2023-24. 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 9a3240d15e..43b8f1c97e 100644 --- a/docs/book/programs/gov/treasury/cost-of-living-payments.md +++ b/docs/book/programs/gov/treasury/cost-of-living-payments.md @@ -40,13 +40,17 @@ PolicyEngine models these via: `ebr_energy_bills_credit` sums the monthly instalments within each calendar year, so a Great Britain household on the scheme throughout gets £199 in -2022 and £201 in 2023, while HBAI counts the whole £400 in 2022-23. - -HBAI classifies both strands as miscellaneous income, not state support, -and DWP imputes receipt because the FRS did not ask about them. So -`energy_bills_rebate` is in `hbai_household_net_income` but not in -`hbai_benefits`. No FRS income field behind `miscellaneous_income` holds -the rebates, so they are not counted twice. +2022 and £201 in 2023. `energy_bills_rebate`, its sum with the Council Tax +Rebate, counts in household net income and government spending that way. + +HBAI counts both strands by financial year, all in FYE 2023, and classifies +them as miscellaneous income, not state support; DWP imputes receipt +because the FRS did not ask about them. So `hbai_household_net_income` adds +`hbai_energy_bills_rebate`, which sums the credit from April of the year to +the next March: the whole £400 (Northern Ireland's January 2023 payment +included) in 2022-23 and nothing in 2023-24. It is not in `hbai_benefits`. +No FRS income field behind `miscellaneous_income` holds the rebates, so +they are not counted twice. ## Cost-of-Living Payments (2022-23 and 2023-24) diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/miscellaneous_income_trading_allowance_deduction.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/miscellaneous_income_trading_allowance_deduction.yaml index e25bf75621..c89f3aaaca 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/miscellaneous_income_trading_allowance_deduction.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/miscellaneous_income_trading_allowance_deduction.yaml @@ -209,3 +209,27 @@ taxable_miscellaneous_income: 600 # (30,000 + 600 - 12,570) x 20% income_tax: 3_606 + +- name: Case 15, the trade takes the allowance even where pension relief would rise without it. + period: 2026 + absolute_error_margin: 0.01 + input: + self_employment_gross_receipts: 20_000 + self_employment_income: 20_000 + miscellaneous_income: 30_000 + income_not_otherwise_charged: 30_000 + personal_pension_contributions: 20_000 + output: + # The trade's receipts of £20,000 exceed the allowance, so the trade + # takes all £1,000 of it and the casual income none (s. 783AK). Trade + # profit of £19,000 is the relevant UK earnings that cap pension relief + # (FA 2004 s. 189), so £19,000 of the £20,000 contributions get relief. + # The Act would also allow the casual income the whole allowance: relief + # on all £20,000 and £3,286 of income tax, but £445.80 of Class 4. + trading_allowance_deduction: 1_000 + miscellaneous_income_trading_allowance_deduction: 0 + pension_contributions_relief: 19_000 + # (19,000 + 30,000 - 19,000 - 12,570) x 20% + income_tax: 3_486 + # (19,000 - 12,570) x 6% + ni_class_4: 385.80 diff --git a/policyengine_uk/tests/policy/baseline/gov/treasury/energy_bills_rebate/energy_bills_rebate.yaml b/policyengine_uk/tests/policy/baseline/gov/treasury/energy_bills_rebate/energy_bills_rebate.yaml index ae4647d681..72c0694ef2 100644 --- a/policyengine_uk/tests/policy/baseline/gov/treasury/energy_bills_rebate/energy_bills_rebate.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/treasury/energy_bills_rebate/energy_bills_rebate.yaml @@ -20,9 +20,11 @@ # Each household below has no other income, does not claim Universal Credit # and has no council tax to pay, so its HBAI income is the rebate alone. The # Great Britain credit is £66 in October and November 2022 and £67 a month -# from December 2022 to March 2023, summed by calendar year (£199 and £201). +# from December 2022 to March 2023. energy_bills_rebate sums it by calendar +# year (£199 and £201); HBAI counts it by financial year, all £400 in +# 2022-23, through hbai_energy_bills_rebate. -- name: Case 3, a band C household's 2022 rebates are HBAI income. +- name: Case 3, a band C household's 2022-23 rebates are HBAI income in full. period: 2022 input: people: @@ -40,9 +42,10 @@ output: ebr_council_tax_rebate: 150 ebr_energy_bills_credit: 199 - hbai_household_net_income: 150 + 199 + hbai_energy_bills_rebate: 150 + 400 + hbai_household_net_income: 150 + 400 -- name: Case 4, in 2023 only the credit's remaining instalments are paid, and counted. +- name: Case 4, the January to March 2023 instalments belong to 2022-23, not 2023-24. period: 2023 input: people: @@ -60,9 +63,34 @@ output: ebr_council_tax_rebate: 0 energy_bills_rebate: 201 - hbai_household_net_income: 201 + hbai_energy_bills_rebate: 0 + hbai_household_net_income: 0 -- name: Case 5, Northern Ireland's January 2023 credit is HBAI income. +# Northern Ireland's £400 credit is one payment in January 2023. Band E +# keeps out the Council Tax Rebate, which the model pays by band in every +# nation. +- name: Case 5, Northern Ireland's January 2023 credit is 2022-23 HBAI income. + period: 2022 + input: + people: + adult: + age: 40 + benunits: + unit: + members: [adult] + would_claim_uc: false + households: + household: + members: [adult] + region: NORTHERN_IRELAND + council_tax_band: E + council_tax: 0 + output: + energy_bills_rebate: 0 + hbai_energy_bills_rebate: 400 + hbai_household_net_income: 400 + +- name: Case 6, in 2023 Northern Ireland's credit counts by calendar year but not in HBAI. period: 2023 input: people: @@ -75,13 +103,15 @@ households: household: members: [adult] - country: NORTHERN_IRELAND + region: NORTHERN_IRELAND + council_tax_band: E council_tax: 0 output: energy_bills_rebate: 400 - hbai_household_net_income: 400 + hbai_energy_bills_rebate: 0 + hbai_household_net_income: 0 -- name: Case 6, the rebates are miscellaneous income, not HBAI state support. +- name: Case 7, the rebates are miscellaneous income, not HBAI state support. period: 2022 input: people: @@ -97,5 +127,5 @@ council_tax_band: A council_tax: 0 output: - energy_bills_rebate: 349 + hbai_energy_bills_rebate: 550 hbai_benefits: 0 diff --git a/policyengine_uk/tests/test_energy_bills_rebate_hbai_properties.py b/policyengine_uk/tests/test_energy_bills_rebate_hbai_properties.py index 64de39ae84..e582cb52b6 100644 --- a/policyengine_uk/tests/test_energy_bills_rebate_hbai_properties.py +++ b/policyengine_uk/tests/test_energy_bills_rebate_hbai_properties.py @@ -4,18 +4,25 @@ Ireland) and the £150 Council Tax Rebate as miscellaneous income, not state support (HBAI quality and methodology report FYE 2023, "Treatment of cost of living and wider support schemes in the HBAI income estimates for FYE -2023"). The Council Tax Rebate was a one-off: billing authorities made every -payment by 30 September 2022 (Council Tax Rebate 2022-23 billing authority -guidance, paragraph 7). +2023"), and counts both in FYE 2023. The Council Tax Rebate was a one-off: +billing authorities made every payment by 30 September 2022 (Council Tax +Rebate 2022-23 billing authority guidance, paragraph 7). The Great Britain +credit ran from October 2022 to March 2023, and Northern Ireland's was one +payment in January 2023, so all of it falls in the 2022-23 financial year. Invariants, each checked over many generated households in one reusable simulation: 1. HBAI household net income before the reset to zero rises by exactly the - household's energy_bills_rebate, in every year, nation and band. + household's hbai_energy_bills_rebate, in every year, nation and band. 2. The rebate is miscellaneous income: hbai_benefits does not change with it. 3. The Council Tax Rebate is paid once: in 2022 only, at £150, and only in bands A to D. +4. HBAI counts the credit by financial year: in 2022 it is the household's + eligible months of the October 2022 to March 2023 schedule (Northern + Ireland's January 2023 payment), and in every other year nil. +5. Financial and calendar years count the same payments: over 2021 to 2024, + hbai_energy_bills_rebate and energy_bills_rebate add up to the same total. """ import numpy as np @@ -35,16 +42,34 @@ COUNTRIES = ["ENGLAND", "SCOTLAND", "WALES", "NORTHERN_IRELAND"] HOUSEHOLDS = 16 +# The enacted schedule, written out here rather than read from parameters. +GREAT_BRITAIN_CREDIT = { + "2022-10": 66, + "2022-11": 66, + "2022-12": 67, + "2023-01": 67, + "2023-02": 67, + "2023-03": 67, +} +NORTHERN_IRELAND_CREDIT = {"2023-01": 400} + household = st.tuples( st.sampled_from(BANDS), st.sampled_from(COUNTRIES), st.sampled_from([0.0, 6_000.0, 18_000.0, 45_000.0]), # employment income st.booleans(), # claims Universal Credit - st.lists(st.booleans(), min_size=12, max_size=12), # EBSS months eligible + st.lists(st.booleans(), min_size=24, max_size=24), # EBSS months eligible ) households = st.lists(household, min_size=HOUSEHOLDS, max_size=HOUSEHOLDS) +def eligible(months, month): + """Whether the drawn household qualifies in ``month`` ("YYYY-MM"): + the first 12 draws are 2022's months, the rest 2023's.""" + year, number = (int(part) for part in month.split("-")) + return months[(year - 2022) % 2 * 12 + number - 1] + + def simulation(drawn, years, without_rebate=False): """One household per drawn case, with the same circumstances in each of ``years``.""" @@ -70,7 +95,7 @@ def each_year(value): "council_tax_band": each_year(bands[i]), "country": each_year(countries[i]), "ebr_energy_bills_credit_eligible": { - f"{year}-{month:02d}": months[i][month - 1] + f"{year}-{month:02d}": eligible(months[i], f"{year}-{month:02d}") for year in years for month in range(1, 13) }, @@ -79,7 +104,7 @@ def each_year(value): } if without_rebate: for values in households.values(): - values["energy_bills_rebate"] = each_year(0) + values["hbai_energy_bills_rebate"] = each_year(0) return Simulation( situation={"people": people, "benunits": benunits, "households": households} ) @@ -95,9 +120,10 @@ def each_year(value): @SETTINGS @given(households, st.sampled_from(YEARS)) def test_hbai_income_rises_by_exactly_the_rebate(drawn, year): - with_rebate = simulation(drawn, [year]) - without = simulation(drawn, [year], without_rebate=True) - rebate = with_rebate.calculate("energy_bills_rebate", year) + # The financial year reads January to March of the next calendar year. + with_rebate = simulation(drawn, [year, year + 1]) + without = simulation(drawn, [year, year + 1], without_rebate=True) + rebate = with_rebate.calculate("hbai_energy_bills_rebate", year) gain = with_rebate.calculate( "hbai_household_net_income_before_reset_to_zero", year ) - without.calculate("hbai_household_net_income_before_reset_to_zero", year) @@ -128,16 +154,53 @@ def test_the_council_tax_rebate_is_paid_once(drawn): assert (total <= 150).all() +@SETTINGS +@given(households) +def test_hbai_counts_the_credit_by_financial_year(drawn): + sim = simulation(drawn, range(2021, 2026)) + expected = np.array( + [ + sum( + amount + for month, amount in ( + NORTHERN_IRELAND_CREDIT + if country == "NORTHERN_IRELAND" + else GREAT_BRITAIN_CREDIT + ).items() + if eligible(months, month) + ) + for _, country, _, _, months in drawn + ] + ) + for year in YEARS: + credit = sim.calculate("hbai_energy_bills_rebate", year) - sim.calculate( + "ebr_council_tax_rebate", year + ) + np.testing.assert_allclose(credit, expected if year == 2022 else 0, atol=0.01) + + +@SETTINGS +@given(households) +def test_financial_and_calendar_years_count_the_same_payments(drawn): + sim = simulation(drawn, range(2021, 2026)) + by_financial_year = sum( + sim.calculate("hbai_energy_bills_rebate", year) for year in YEARS + ) + by_calendar_year = sum(sim.calculate("energy_bills_rebate", year) for year in YEARS) + np.testing.assert_allclose(by_financial_year, by_calendar_year, atol=0.01) + + def test_the_rebate_is_counted_once_and_not_as_state_support(): - """HBAI adds energy_bills_rebate once, and not its parts as well; it and - the Warm Home Discount are miscellaneous income, outside hbai_benefits.""" + """HBAI adds the financial-year rebate once, and not the calendar-year + rebate or its parts as well; it and the Warm Home Discount are + miscellaneous income, outside hbai_benefits.""" from policyengine_uk import CountryTaxBenefitSystem system = CountryTaxBenefitSystem() parts = system.variables["energy_bills_rebate"].adds assert set(parts) == {"ebr_council_tax_rebate", "ebr_energy_bills_credit"} - assert HBAI_HOUSEHOLD_NET_INCOME_ADDS.count("energy_bills_rebate") == 1 - assert not set(parts) & set(HBAI_HOUSEHOLD_NET_INCOME_ADDS) + assert HBAI_HOUSEHOLD_NET_INCOME_ADDS.count("hbai_energy_bills_rebate") == 1 + assert not ({"energy_bills_rebate", *parts} & set(HBAI_HOUSEHOLD_NET_INCOME_ADDS)) state_support = system.variables["hbai_benefits"].adds - for miscellaneous_income in ["energy_bills_rebate", "warm_home_discount"]: + for miscellaneous_income in ["hbai_energy_bills_rebate", "warm_home_discount"]: assert miscellaneous_income not in state_support diff --git a/policyengine_uk/tests/test_trading_allowance_miscellaneous_income_properties.py b/policyengine_uk/tests/test_trading_allowance_miscellaneous_income_properties.py index 4ff76072dd..eef1f1c8b9 100644 --- a/policyengine_uk/tests/test_trading_allowance_miscellaneous_income_properties.py +++ b/policyengine_uk/tests/test_trading_allowance_miscellaneous_income_properties.py @@ -34,8 +34,9 @@ Chapter 8 income. 7. Income tax never rises as more of a person's miscellaneous income is Chapter 8 income. -8. Parity (differential): Chapter 8 income of X, with no trade, is charged - the same income tax as trade receipts of X with no expenses. +8. Parity (differential): without pension contributions, Chapter 8 income + of X, with no trade, is charged the same income tax as trade receipts of + X with no expenses. 9. A current trading loss is never both claimed and given up: when the allowance gives either relief, no current loss is relieved against general income or Class 4, and a loss above anything the allowance could diff --git a/policyengine_uk/tests/test_wales_childcare_offer_income_sources.py b/policyengine_uk/tests/test_wales_childcare_offer_income_sources.py index 3508c8ffed..f083cb4b4f 100644 --- a/policyengine_uk/tests/test_wales_childcare_offer_income_sources.py +++ b/policyengine_uk/tests/test_wales_childcare_offer_income_sources.py @@ -83,7 +83,7 @@ def sources(name): "savings_interest_income", "other_investment_income", "dividend_income", - "taxable_miscellaneous_income", + "miscellaneous_income_before_trading_allowance", "maintenance_income", "spousal_maintenance_income", }, diff --git a/policyengine_uk/utils/trading_allowance.py b/policyengine_uk/utils/trading_allowance.py index 3139f2e2df..d16a3b54f0 100644 --- a/policyengine_uk/utils/trading_allowance.py +++ b/policyengine_uk/utils/trading_allowance.py @@ -34,14 +34,19 @@ def trading_allowance_reliefs(person, period, parameters): leave. Where trade receipts exceed the allowance it gets none: partial relief then beats actual deductions exactly when the trades' expenses and capital allowances are under the allowance, and the trades take - all of it. Without losses brought forward, every split charges the - same income tax: relevant income less the allowance. With them - (loss_relief, ITA 2007 s. 83), a smaller trade share can charge less - tax this year, because the loss absorbs the trade profit it leaves, but - it uses up that much of the loss, which otherwise carries forward - against later profits of the trade (s. 84). The trades-first split - leaves the loss for later years and keeps Class 4 profits lower, so - the model uses it there too. + all of it. Every split leaves the same trade and miscellaneous income + in total, relevant income less the allowance, but two other reliefs + read the trade's share alone, so a smaller trade share can charge less + income tax this year: + - losses brought forward (loss_relief, ITA 2007 s. 83) absorb the + trade profit it leaves, but that uses up loss which otherwise + carries forward against later profits of the trade (s. 84); + - pension contributions above relevant UK earnings (FA 2004 s. 189) + get more relief, since trade profit counts towards those earnings + and miscellaneous income does not. + The model keeps the trades-first split in both cases: it leaves the + loss for later years and keeps Class 4 profits lower, but it does not + always minimise the year's income tax and Class 4 together. - Ordinary treatment: an individual whose relevant income is within the allowance can elect out of full relief (s. 783AL), and one above it need not elect for partial relief (s. 783AM). A raw current trading diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/miscellaneous_income_trading_allowance_deduction.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/miscellaneous_income_trading_allowance_deduction.py index 44264fd693..8b48624fdc 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/miscellaneous_income_trading_allowance_deduction.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/miscellaneous_income_trading_allowance_deduction.py @@ -16,7 +16,8 @@ class miscellaneous_income_trading_allowance_deduction(Variable): "choose, in place of actual expenses (ss. 783AI-783AK); the model " "gives the trades their receipts' worth first, so this income gets " "the part of the allowance the trade receipts leave, even where trade " - "losses brought forward would absorb the trade's share (see " + "losses brought forward would absorb the trade's share or pension " + "contributions exceed relevant UK earnings (see " "utils/trading_allowance.py). Ordinary treatment to claim a current " "trading loss (no full relief, s. 783AL, or no partial relief, s. " "783AM) leaves it unrelieved. Only " diff --git a/policyengine_uk/variables/gov/treasury/energy_bills_rebate/hbai_energy_bills_rebate.py b/policyengine_uk/variables/gov/treasury/energy_bills_rebate/hbai_energy_bills_rebate.py new file mode 100644 index 0000000000..a49e52c032 --- /dev/null +++ b/policyengine_uk/variables/gov/treasury/energy_bills_rebate/hbai_energy_bills_rebate.py @@ -0,0 +1,34 @@ +from policyengine_uk.model_api import * + +# The months of the financial year that starts in April of the model year. +FINANCIAL_YEAR_MONTHS = [(0, month) for month in range(4, 13)] + [ + (1, month) for month in range(1, 4) +] + + +class hbai_energy_bills_rebate(Variable): + label = "Energy Bills Rebate in HBAI income" + documentation = ( + "The Energy Bills Rebate by financial year, as HBAI counts it: the " + "Council Tax Rebate and the Energy Bills Support Scheme credit paid " + "from April of the year to the next March. HBAI counts the whole " + "October 2022 to March 2023 credit in FYE 2023. energy_bills_rebate " + "sums the credit by calendar year instead." + ) + entity = Household + definition_period = YEAR + value_type = float + unit = GBP + reference = "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" + + def formula(household, period, parameters): + credit = 0 + for years_ahead, month in FINANCIAL_YEAR_MONTHS: + credit = credit + household( + "monthly_ebr_energy_bills_credit", + f"{period.start.year + years_ahead}-{month:02d}", + ) + annual_top_up = parameters( + period + ).gov.treasury.energy_bills_rebate.energy_bills_credit + return household("ebr_council_tax_rebate", period) + credit + annual_top_up 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 c02f042e54..4277857b80 100644 --- a/policyengine_uk/variables/household/income/hbai_household_net_income.py +++ b/policyengine_uk/variables/household/income/hbai_household_net_income.py @@ -66,9 +66,10 @@ # HBAI classifies the £400 Energy Bills Support Scheme (£600 in Northern # Ireland) and the £150 Council Tax Rebate as miscellaneous income, not # state support. DWP imputes receipt, since the FRS did not ask about - # them, so no FRS income field behind miscellaneous_income holds them: + # them, so no FRS income field behind miscellaneous_income holds them. + # HBAI counts them by financial year, all in 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 - "energy_bills_rebate", + "hbai_energy_bills_rebate", # Reference for tax-free-childcare: https://assets.publishing.service.gov.uk/media/5e7b191886650c744175d08b/households-below-average-income-1994-1995-2018-2019.pdf ] From cc7002d8590b4cc8277a9018123a911c251efd96 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 20:36:32 -0400 Subject: [PATCH 6/8] Address review r3: the financial year's nation, HBAI small-cell protection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - hbai_energy_bills_rebate now picks the scheme by the household's nation in the financial year, reading eligibility and the schedule month by month. Before, January to March read the next model year's country, so a move (or a country set for one year only) mixed the two schemes: £599 or £201 where 2022-23 should hold £400. The monthly credit's scheme choice moves into ebss_credit, shared by both. YAML Cases 8-10 and the financial-year property test (now with households that move) pin it. - The aggregate-impact report counts the financial-year rebate's own records and suppresses it like a head (measure.HBAI_ONLY_COMPONENTS): HBAI income, its deciles, poverty and inequality leave it out in a year it is suppressed. HBAI no longer holds the calendar-year head by name, so suppressing that head left the payment in. New measure tests, including a differential over awards, pin it. Co-Authored-By: Claude Opus 5.5 --- .../gov/treasury/cost-of-living-payments.md | 3 +- .../test_aggregate_impact_measure.py | 94 +++++++++++++++++++ .../energy_bills_rebate.yaml | 66 +++++++++++++ ...est_energy_bills_rebate_hbai_properties.py | 20 ++-- .../utils/aggregate_impact/README.md | 3 + .../utils/aggregate_impact/measure.py | 38 +++++++- .../utils/aggregate_impact/privacy.py | 5 +- .../hbai_energy_bills_rebate.py | 23 ++--- .../monthly_ebr_energy_bills_credit.py | 20 ++-- 9 files changed, 241 insertions(+), 31 deletions(-) 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 43b8f1c97e..ad375b3619 100644 --- a/docs/book/programs/gov/treasury/cost-of-living-payments.md +++ b/docs/book/programs/gov/treasury/cost-of-living-payments.md @@ -47,7 +47,8 @@ HBAI counts both strands by financial year, all in FYE 2023, and classifies them as miscellaneous income, not state support; DWP imputes receipt because the FRS did not ask about them. So `hbai_household_net_income` adds `hbai_energy_bills_rebate`, which sums the credit from April of the year to -the next March: the whole £400 (Northern Ireland's January 2023 payment +the next March, on the scheme of the nation the household lives in that +financial year: the whole £400 (Northern Ireland's January 2023 payment included) in 2022-23 and nothing in 2023-24. It is not in `hbai_benefits`. No FRS income field behind `miscellaneous_income` holds the rebates, so they are not counted twice. diff --git a/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_measure.py b/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_measure.py index 20636f0dad..565538e5fc 100644 --- a/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_measure.py +++ b/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_measure.py @@ -1096,3 +1096,97 @@ def guard(units): assert shown in text and "NO LONGER contained" in text if units == 3: assert "0.43" not in text + + +def _hbai_only_year(rebate): + """A year whose only HBAI income outside market income is the Energy + Bills Rebate counted by financial year, a part of HBAI income outside the + partition.""" + market = np.full(N, 10_000.0) + before = market + rebate + return _measured_year( + { + "hbai_energy_bills_rebate": rebate, + "employment_income": market, + "household_market_income": market, + "household_net_income": market, + "hbai_household_net_income_before_reset_to_zero": before, + "hbai_household_net_income": before, + } + ) + + +def test_hbai_only_components_are_hbai_parts_outside_the_partition(): + from policyengine_uk.utils.aggregate_impact.measure import ( + HBAI_ONLY_COMPONENTS, + hbai_holds_any, + ) + from policyengine_uk.utils.aggregate_impact.partition import ( + default_partition_path, + load_partition, + ) + from policyengine_uk.variables.household.income.hbai_household_net_income import ( + HBAI_HOUSEHOLD_NET_INCOME_ADDS, + HBAI_HOUSEHOLD_NET_INCOME_SUBTRACTS, + ) + + partition = load_partition(default_partition_path()) + in_partition = {h.variable for h in partition.heads} | { + d.variable for d in partition.double_counts + } + hbai = set(HBAI_HOUSEHOLD_NET_INCOME_ADDS) | set( + HBAI_HOUSEHOLD_NET_INCOME_SUBTRACTS + ) + for variable in HBAI_ONLY_COMPONENTS: + assert variable in hbai and variable not in in_partition, variable + assert hbai_holds_any({variable: ("x", 1.0)}) + # HBAI holds the financial-year rebate, not the calendar-year head. + assert not hbai_holds_any({"energy_bills_rebate": ("x", 1.0)}) + + +def test_an_hbai_only_component_from_few_records_is_suppressed_and_left_out(): + """Sol review 3 of #2726: with HBAI counting the rebate by financial year + under its own name, suppressing the calendar-year head left the HBAI + total, deciles, poverty and the Gini holding a payment from one record.""" + rebate = _first(1, 400.0) + rec, households = _hbai_only_year(rebate) + published = _published(rec) + assert published["household.hbai_energy_bills_rebate"] is None + assert published["household.hbai_household_net_income"] == N * 10_000.0 + assert rec.metrics["household.hbai_household_net_income"]["excludes"]["2026"] == { + "household.hbai_energy_bills_rebate": 1 + } + assert "2026" not in households + for record in rec.metrics.values(): + if record["section"] in ("deciles", "poverty", "poverty_count", "inequality"): + assert record["values"]["2026"] is None, record["id"] + + +def test_an_hbai_only_component_from_ten_records_is_published_and_counted(): + rebate = _first(10, 400.0) + rec, households = _hbai_only_year(rebate) + published = _published(rec) + assert published["household.hbai_energy_bills_rebate"] == 4_000.0 + assert published["household.hbai_household_net_income"] == N * 10_000.0 + 4_000.0 + assert "excludes" not in rec.metrics["household.hbai_household_net_income"] + assert "2026" in households + + +@settings(max_examples=30, deadline=None) +@given(award=st.floats(1.0, 1e5), other=st.floats(1.0, 1e5)) +def test_no_published_figure_depends_on_a_suppressed_hbai_only_award(award, other): + """For all awards: two years that differ only in one household's + financial-year rebate publish the same figures, decile incomes and check + results.""" + rec_1, hh_1 = _hbai_only_year(_first(1, award)) + rec_2, hh_2 = _hbai_only_year(_first(1, other)) + first, second = _published(rec_1), _published(rec_2) + assert first.keys() == second.keys() + for metric_id, value in first.items(): + assert value == second[metric_id] or ( + value is not None and np.isnan(value) and np.isnan(second[metric_id]) + ), metric_id + assert hh_1.keys() == hh_2.keys() + assert [(c["id"], c["status"], c["details"]) for c in rec_1.checks.values()] == [ + (c["id"], c["status"], c["details"]) for c in rec_2.checks.values() + ] diff --git a/policyengine_uk/tests/policy/baseline/gov/treasury/energy_bills_rebate/energy_bills_rebate.yaml b/policyengine_uk/tests/policy/baseline/gov/treasury/energy_bills_rebate/energy_bills_rebate.yaml index 72c0694ef2..a709590ecb 100644 --- a/policyengine_uk/tests/policy/baseline/gov/treasury/energy_bills_rebate/energy_bills_rebate.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/treasury/energy_bills_rebate/energy_bills_rebate.yaml @@ -129,3 +129,69 @@ output: hbai_energy_bills_rebate: 550 hbai_benefits: 0 + +# The financial year's nation picks the scheme for all its months, even +# where the household's nation differs in the next model year (or isn't set +# for it: country, unlike region, does not carry over to the next year). +- name: Case 8, Northern Ireland given for 2022 only still gets its January 2023 credit. + period: 2022 + input: + people: + adult: + age: 40 + benunits: + unit: + members: [adult] + would_claim_uc: false + households: + household: + members: [adult] + country: NORTHERN_IRELAND + council_tax_band: E + council_tax: 0 + output: + hbai_energy_bills_rebate: 400 + +- name: Case 9, a move to Northern Ireland in 2023-24 leaves 2022-23 on the Great Britain scheme. + period: 2022 + input: + people: + adult: + age: 40 + benunits: + unit: + members: [adult] + would_claim_uc: false + households: + household: + members: [adult] + country: + 2022: ENGLAND + 2023: NORTHERN_IRELAND + council_tax_band: E + council_tax: 0 + output: + ebr_energy_bills_credit: 199 + hbai_energy_bills_rebate: 400 + +- name: Case 10, a move from Northern Ireland in 2023-24 leaves 2022-23 on Northern Ireland's scheme. + period: 2022 + input: + people: + adult: + age: 40 + benunits: + unit: + members: [adult] + would_claim_uc: false + households: + household: + members: [adult] + country: + 2022: NORTHERN_IRELAND + 2023: ENGLAND + council_tax_band: E + council_tax: 0 + output: + ebr_energy_bills_credit: 0 + hbai_energy_bills_rebate: 400 diff --git a/policyengine_uk/tests/test_energy_bills_rebate_hbai_properties.py b/policyengine_uk/tests/test_energy_bills_rebate_hbai_properties.py index e582cb52b6..f9dbd48035 100644 --- a/policyengine_uk/tests/test_energy_bills_rebate_hbai_properties.py +++ b/policyengine_uk/tests/test_energy_bills_rebate_hbai_properties.py @@ -20,7 +20,8 @@ bands A to D. 4. HBAI counts the credit by financial year: in 2022 it is the household's eligible months of the October 2022 to March 2023 schedule (Northern - Ireland's January 2023 payment), and in every other year nil. + Ireland's January 2023 payment) of the nation it lives in that financial + year, whatever its nation the next year, and in every other year nil. 5. Financial and calendar years count the same payments: over 2021 to 2024, hbai_energy_bills_rebate and energy_bills_rebate add up to the same total. """ @@ -56,6 +57,7 @@ household = st.tuples( st.sampled_from(BANDS), st.sampled_from(COUNTRIES), + st.sampled_from(COUNTRIES), # the nation from 2023, if it moves st.sampled_from([0.0, 6_000.0, 18_000.0, 45_000.0]), # employment income st.booleans(), # claims Universal Credit st.lists(st.booleans(), min_size=24, max_size=24), # EBSS months eligible @@ -70,10 +72,11 @@ def eligible(months, month): return months[(year - 2022) % 2 * 12 + number - 1] -def simulation(drawn, years, without_rebate=False): +def simulation(drawn, years, without_rebate=False, moves=False): """One household per drawn case, with the same circumstances in each of - ``years``.""" - bands, countries, pay, claims_uc, months = zip(*drawn) + ``years``, except that with ``moves`` it lives in its second drawn nation + from 2023.""" + bands, countries, next_countries, pay, claims_uc, months = zip(*drawn) def each_year(value): return {year: value for year in years} @@ -93,7 +96,10 @@ def each_year(value): f"household_{i}": { "members": [f"adult_{i}"], "council_tax_band": each_year(bands[i]), - "country": each_year(countries[i]), + "country": { + year: next_countries[i] if moves and year > 2022 else countries[i] + for year in years + }, "ebr_energy_bills_credit_eligible": { f"{year}-{month:02d}": eligible(months[i], f"{year}-{month:02d}") for year in years @@ -157,7 +163,7 @@ def test_the_council_tax_rebate_is_paid_once(drawn): @SETTINGS @given(households) def test_hbai_counts_the_credit_by_financial_year(drawn): - sim = simulation(drawn, range(2021, 2026)) + sim = simulation(drawn, range(2021, 2026), moves=True) expected = np.array( [ sum( @@ -169,7 +175,7 @@ def test_hbai_counts_the_credit_by_financial_year(drawn): ).items() if eligible(months, month) ) - for _, country, _, _, months in drawn + for _, country, _, _, _, months in drawn ] ) for year in YEARS: diff --git a/policyengine_uk/utils/aggregate_impact/README.md b/policyengine_uk/utils/aggregate_impact/README.md index 700fd0fe35..a6ba3c9b4b 100644 --- a/policyengine_uk/utils/aggregate_impact/README.md +++ b/policyengine_uk/utils/aggregate_impact/README.md @@ -32,6 +32,9 @@ Small cells (`privacy.py`): - A figure from fewer than 10 records is published as suppressed. - A total that holds one is rebuilt from its other components and marked †. - Poverty, inequality and deciles are withheld while HBAI income holds one. + Parts of HBAI income outside the partition that few records may hold + (`measure.HBAI_ONLY_COMPONENTS`, such as the Energy Bills Rebate by + financial year) are counted and suppressed like heads. - A reform's figures for a year are withheld when they would come from a small cell. diff --git a/policyengine_uk/utils/aggregate_impact/measure.py b/policyengine_uk/utils/aggregate_impact/measure.py index 192a512422..5833ad1769 100644 --- a/policyengine_uk/utils/aggregate_impact/measure.py +++ b/policyengine_uk/utils/aggregate_impact/measure.py @@ -32,6 +32,17 @@ "hbai_household_net_income": "Household net income (HBAI definition)", } +# Parts of HBAI income outside the partition that, like a head, few records +# may hold. Each is published with a head's small-cell protection on its own +# records, and in a year it is suppressed HBAI income, its deciles, poverty +# and inequality leave it out as they do a suppressed head. +# hbai_energy_bills_rebate is the Energy Bills Rebate head timed by +# financial year, so a year can hold it in few records while the head holds +# none, or the reverse. +HBAI_ONLY_COMPONENTS = { + "hbai_energy_bills_rebate": "Energy Bills Rebate in HBAI income", +} + class Recorder: """Collects metric values and check outcomes for one measurement.""" @@ -557,6 +568,22 @@ def measure_year( values != 0, weights[entity_of(sim, dc.variable)] ), ) + hbai_only_hh = {} + for variable, label in HBAI_ONLY_COMPONENTS.items(): + if variable not in tbs_variables: + continue + entity = entity_of(sim, variable) + values = array(sim, variable, year).astype(np.float64) + hbai_only_hh[variable] = to_household(sim, values, entity) + rec.metric( + f"household.{variable}", + label, + "household_totals", + "gbp", + year, + float(np.sum(hw * hbai_only_hh[variable])), + records=_weighted_records(values != 0, weights[entity]), + ) hidden = suppressed_components(rec, partition, year) # Households holding a suppressed component: no check verdict or detail # may depend on them this year. @@ -564,7 +591,7 @@ def measure_year( if hidden: holding = np.zeros(len(hw), dtype=bool) for variable in hidden: - holding |= {**head_hh, **double_count_hh}[variable] != 0 + holding |= {**head_hh, **double_count_hh, **hbai_only_hh}[variable] != 0 active = active_parameters(sim, partition, year) # Geographic exclusivity, on the data. A declared double count is netted @@ -936,8 +963,9 @@ def _exclude(record: dict, key: str, excludes: Mapping) -> None: def suppressed_components(rec: "Recorder", partition: Partition, year: int) -> dict: - """``{variable: (metric id, raw value)}`` for each head or declared double - count suppressed in ``year``.""" + """``{variable: (metric id, raw value)}`` for each head, declared double + count or part of HBAI income outside the partition (HBAI_ONLY_COMPONENTS) + suppressed in ``year``.""" key = str(year) out = {} for head in partition.heads: @@ -948,6 +976,10 @@ def suppressed_components(rec: "Recorder", partition: Partition, year: int) -> d metric_id = f"double_counts.{dc.variable}" if (metric_id, key) in rec.hidden: out[dc.variable] = (metric_id, rec.hidden[(metric_id, key)]) + for variable in HBAI_ONLY_COMPONENTS: + metric_id = f"household.{variable}" + if (metric_id, key) in rec.hidden: + out[variable] = (metric_id, rec.hidden[(metric_id, key)]) return out diff --git a/policyengine_uk/utils/aggregate_impact/privacy.py b/policyengine_uk/utils/aggregate_impact/privacy.py index 80a6c32e75..37fb88693f 100644 --- a/policyengine_uk/utils/aggregate_impact/privacy.py +++ b/policyengine_uk/utils/aggregate_impact/privacy.py @@ -8,7 +8,10 @@ A total that contains a suppressed head or double count is rebuilt that year from its other components (``measure.rebuild_protected``), and so are the -household incomes for deciles, so no published total includes it. Checks +household incomes for deciles, so no published total includes it. Parts of +HBAI income outside the partition that few records may hold +(``measure.HBAI_ONLY_COMPONENTS``) are counted and suppressed like heads, and +HBAI income leaves them out in the same way. Checks whose verdict would depend on its values leave out the households holding it (identities, the HBAI reproduction, overlap guards) or aren't run that year (the sign check of the suppressed head). A reform's costing and target change diff --git a/policyengine_uk/variables/gov/treasury/energy_bills_rebate/hbai_energy_bills_rebate.py b/policyengine_uk/variables/gov/treasury/energy_bills_rebate/hbai_energy_bills_rebate.py index a49e52c032..fce90d877d 100644 --- a/policyengine_uk/variables/gov/treasury/energy_bills_rebate/hbai_energy_bills_rebate.py +++ b/policyengine_uk/variables/gov/treasury/energy_bills_rebate/hbai_energy_bills_rebate.py @@ -1,9 +1,7 @@ from policyengine_uk.model_api import * - -# The months of the financial year that starts in April of the model year. -FINANCIAL_YEAR_MONTHS = [(0, month) for month in range(4, 13)] + [ - (1, month) for month in range(1, 4) -] +from policyengine_uk.variables.gov.treasury.energy_bills_rebate.monthly_ebr_energy_bills_credit import ( + ebss_credit, +) class hbai_energy_bills_rebate(Variable): @@ -13,7 +11,9 @@ class hbai_energy_bills_rebate(Variable): "Council Tax Rebate and the Energy Bills Support Scheme credit paid " "from April of the year to the next March. HBAI counts the whole " "October 2022 to March 2023 credit in FYE 2023. energy_bills_rebate " - "sums the credit by calendar year instead." + "sums the credit by calendar year instead. The household's nation " + "is the financial year's, so the January to March instalments follow " + "the scheme of the year they belong to." ) entity = Household definition_period = YEAR @@ -22,12 +22,13 @@ class hbai_energy_bills_rebate(Variable): reference = "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" def formula(household, period, parameters): + country = household("country", period) credit = 0 - for years_ahead, month in FINANCIAL_YEAR_MONTHS: - credit = credit + household( - "monthly_ebr_energy_bills_credit", - f"{period.start.year + years_ahead}-{month:02d}", - ) + # April of the year to March of the next. + for months_after_january in range(3, 15): + month = period.first_month.offset(months_after_january, MONTH) + eligible = household("ebr_energy_bills_credit_eligible", month) + credit = credit + eligible * ebss_credit(parameters, month, country) annual_top_up = parameters( period ).gov.treasury.energy_bills_rebate.energy_bills_credit diff --git a/policyengine_uk/variables/gov/treasury/energy_bills_rebate/monthly_ebr_energy_bills_credit.py b/policyengine_uk/variables/gov/treasury/energy_bills_rebate/monthly_ebr_energy_bills_credit.py index 2509be974e..ef2d4f42f0 100644 --- a/policyengine_uk/variables/gov/treasury/energy_bills_rebate/monthly_ebr_energy_bills_credit.py +++ b/policyengine_uk/variables/gov/treasury/energy_bills_rebate/monthly_ebr_energy_bills_credit.py @@ -18,11 +18,15 @@ class monthly_ebr_energy_bills_credit(Variable): ) def formula(household, period, parameters): - ebr = parameters(period).gov.treasury.energy_bills_rebate - country = household("country", period.this_year) - is_northern_ireland = country == country.possible_values.NORTHERN_IRELAND - return where( - is_northern_ireland, - ebr.energy_bills_credit_northern_ireland, - ebr.energy_bills_credit_monthly, - ) + return ebss_credit(parameters, period, household("country", period.this_year)) + + +def ebss_credit(parameters, month, country): + """The main-scheme credit for ``month`` to a household in ``country``.""" + ebr = parameters(month).gov.treasury.energy_bills_rebate + is_northern_ireland = country == country.possible_values.NORTHERN_IRELAND + return where( + is_northern_ireland, + ebr.energy_bills_credit_northern_ireland, + ebr.energy_bills_credit_monthly, + ) From 0deb0bbf02677bf123499cfc29661dd818ad562f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 21:00:55 -0400 Subject: [PATCH 7/8] Address review r4: count the calendar-year rebate in HBAI income Review r4 showed that a second, financial-year figure for the 2022 Energy Bills Rebate lets published calendar-year and financial-year totals, or a reform change, give back a suppressed small-cell award. Withdraw that copy instead of adding cross-window suppression: - Delete hbai_energy_bills_rebate and the ebss_credit helper; restore monthly_ebr_energy_bills_credit, the aggregate-impact measure, privacy notes, README and measure tests to main. - HBAI income adds energy_bills_rebate itself, which the report already protects as a fiscal-partition head. - Restore the r1 rebate YAML and property tests, and note the calendar-year sum (GBP 349 in 2022 and GBP 201 in 2023 against HBAI's GBP 550 in 2022-23) in the docs, the HBAI list and the YAML header. The docs also state how a one-year country input selects the scheme. Whether to sum the EBSS and EPG by financial year is a methodology choice across household net income, gov spending and HBAI, outside this PR. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2724.fixed.md | 2 +- .../gov/treasury/cost-of-living-payments.md | 36 +++--- .../test_aggregate_impact_measure.py | 94 -------------- .../energy_bills_rebate.yaml | 118 ++---------------- ...est_energy_bills_rebate_hbai_properties.py | 107 +++------------- .../utils/aggregate_impact/README.md | 3 - .../utils/aggregate_impact/measure.py | 38 +----- .../utils/aggregate_impact/privacy.py | 5 +- .../hbai_energy_bills_rebate.py | 35 ------ .../monthly_ebr_energy_bills_credit.py | 20 ++- .../income/hbai_household_net_income.py | 6 +- 11 files changed, 69 insertions(+), 395 deletions(-) delete mode 100644 policyengine_uk/variables/gov/treasury/energy_bills_rebate/hbai_energy_bills_rebate.py diff --git a/changelog.d/2724.fixed.md b/changelog.d/2724.fixed.md index f0df337638..b4673125c4 100644 --- a/changelog.d/2724.fixed.md +++ b/changelog.d/2724.fixed.md @@ -1 +1 @@ -Counted the 2022 Energy Bills Rebate (the Energy Bills Support Scheme credit and the £150 Council Tax Rebate) in HBAI household net income as miscellaneous income, by financial year as DWP's HBAI does (all £400 of the credit in 2022-23), and paid the one-off Council Tax Rebate in 2022-23 only rather than again in 2023-24. +Counted the 2022 Energy Bills Rebate (the Energy Bills Support Scheme credit and the £150 Council Tax Rebate) in HBAI household net income as miscellaneous income, as DWP's HBAI does, and paid the one-off Council Tax Rebate in 2022-23 only rather than again in 2023-24. 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 ad375b3619..653e93464b 100644 --- a/docs/book/programs/gov/treasury/cost-of-living-payments.md +++ b/docs/book/programs/gov/treasury/cost-of-living-payments.md @@ -13,9 +13,9 @@ Announced by the Chancellor in February 2022 to help households with the spike in domestic energy prices. Two strands: - **£150 Council Tax Rebate** for households in Council Tax bands A–D in - England, a one-off in 2022-23. Eligibility was fixed on 1 April 2022 and - billing authorities made every payment by 30 September 2022. Equivalent - payments were made by the devolved administrations. + England, a one-off in 2022-23. Households qualified on their band on + 1 April 2022, and billing authorities made every payment by 30 September + 2022. The devolved administrations made equivalent payments. - **Energy Bills Support Scheme**, a £400 discount on domestic electricity bills in Great Britain, paid in six monthly instalments from October 2022 to March 2023 (£66 for two months, then £67). Announced in February 2022 @@ -40,18 +40,24 @@ PolicyEngine models these via: `ebr_energy_bills_credit` sums the monthly instalments within each calendar year, so a Great Britain household on the scheme throughout gets £199 in -2022 and £201 in 2023. `energy_bills_rebate`, its sum with the Council Tax -Rebate, counts in household net income and government spending that way. - -HBAI counts both strands by financial year, all in FYE 2023, and classifies -them as miscellaneous income, not state support; DWP imputes receipt -because the FRS did not ask about them. So `hbai_household_net_income` adds -`hbai_energy_bills_rebate`, which sums the credit from April of the year to -the next March, on the scheme of the nation the household lives in that -financial year: the whole £400 (Northern Ireland's January 2023 payment -included) in 2022-23 and nothing in 2023-24. It is not in `hbai_benefits`. -No FRS income field behind `miscellaneous_income` holds the rebates, so -they are not counted twice. +2022 and £201 in 2023, while HBAI counts the whole £400 in 2022-23. Each +month takes the scheme of the household's `country` in that calendar year. +A situation that sets Northern Ireland for 2022 only therefore gets the +Great Britain credit from January to March 2023, because `country` is not +carried forward and defaults to England. + +HBAI classifies both strands as miscellaneous income, not state support, +and DWP imputes receipt because the FRS did not ask about them. So +`energy_bills_rebate` is in `hbai_household_net_income` but not in +`hbai_benefits`. No FRS income field behind `miscellaneous_income` holds +the rebates, so the model counts them once. + +HBAI income takes the same calendar-year sum as household net income and +government spending. A Great Britain household in bands A to D on the +scheme throughout gets £349 in 2022 and £201 in 2023, where HBAI counts +£550 in 2022-23. `epg_subsidy` sums the Energy Price Guarantee by calendar +year in the same way. Summing either by financial year would move all +three measures together, so the model keeps one timing for now. ## Cost-of-Living Payments (2022-23 and 2023-24) diff --git a/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_measure.py b/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_measure.py index 565538e5fc..20636f0dad 100644 --- a/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_measure.py +++ b/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_measure.py @@ -1096,97 +1096,3 @@ def guard(units): assert shown in text and "NO LONGER contained" in text if units == 3: assert "0.43" not in text - - -def _hbai_only_year(rebate): - """A year whose only HBAI income outside market income is the Energy - Bills Rebate counted by financial year, a part of HBAI income outside the - partition.""" - market = np.full(N, 10_000.0) - before = market + rebate - return _measured_year( - { - "hbai_energy_bills_rebate": rebate, - "employment_income": market, - "household_market_income": market, - "household_net_income": market, - "hbai_household_net_income_before_reset_to_zero": before, - "hbai_household_net_income": before, - } - ) - - -def test_hbai_only_components_are_hbai_parts_outside_the_partition(): - from policyengine_uk.utils.aggregate_impact.measure import ( - HBAI_ONLY_COMPONENTS, - hbai_holds_any, - ) - from policyengine_uk.utils.aggregate_impact.partition import ( - default_partition_path, - load_partition, - ) - from policyengine_uk.variables.household.income.hbai_household_net_income import ( - HBAI_HOUSEHOLD_NET_INCOME_ADDS, - HBAI_HOUSEHOLD_NET_INCOME_SUBTRACTS, - ) - - partition = load_partition(default_partition_path()) - in_partition = {h.variable for h in partition.heads} | { - d.variable for d in partition.double_counts - } - hbai = set(HBAI_HOUSEHOLD_NET_INCOME_ADDS) | set( - HBAI_HOUSEHOLD_NET_INCOME_SUBTRACTS - ) - for variable in HBAI_ONLY_COMPONENTS: - assert variable in hbai and variable not in in_partition, variable - assert hbai_holds_any({variable: ("x", 1.0)}) - # HBAI holds the financial-year rebate, not the calendar-year head. - assert not hbai_holds_any({"energy_bills_rebate": ("x", 1.0)}) - - -def test_an_hbai_only_component_from_few_records_is_suppressed_and_left_out(): - """Sol review 3 of #2726: with HBAI counting the rebate by financial year - under its own name, suppressing the calendar-year head left the HBAI - total, deciles, poverty and the Gini holding a payment from one record.""" - rebate = _first(1, 400.0) - rec, households = _hbai_only_year(rebate) - published = _published(rec) - assert published["household.hbai_energy_bills_rebate"] is None - assert published["household.hbai_household_net_income"] == N * 10_000.0 - assert rec.metrics["household.hbai_household_net_income"]["excludes"]["2026"] == { - "household.hbai_energy_bills_rebate": 1 - } - assert "2026" not in households - for record in rec.metrics.values(): - if record["section"] in ("deciles", "poverty", "poverty_count", "inequality"): - assert record["values"]["2026"] is None, record["id"] - - -def test_an_hbai_only_component_from_ten_records_is_published_and_counted(): - rebate = _first(10, 400.0) - rec, households = _hbai_only_year(rebate) - published = _published(rec) - assert published["household.hbai_energy_bills_rebate"] == 4_000.0 - assert published["household.hbai_household_net_income"] == N * 10_000.0 + 4_000.0 - assert "excludes" not in rec.metrics["household.hbai_household_net_income"] - assert "2026" in households - - -@settings(max_examples=30, deadline=None) -@given(award=st.floats(1.0, 1e5), other=st.floats(1.0, 1e5)) -def test_no_published_figure_depends_on_a_suppressed_hbai_only_award(award, other): - """For all awards: two years that differ only in one household's - financial-year rebate publish the same figures, decile incomes and check - results.""" - rec_1, hh_1 = _hbai_only_year(_first(1, award)) - rec_2, hh_2 = _hbai_only_year(_first(1, other)) - first, second = _published(rec_1), _published(rec_2) - assert first.keys() == second.keys() - for metric_id, value in first.items(): - assert value == second[metric_id] or ( - value is not None and np.isnan(value) and np.isnan(second[metric_id]) - ), metric_id - assert hh_1.keys() == hh_2.keys() - assert [(c["id"], c["status"], c["details"]) for c in rec_1.checks.values()] == [ - (c["id"], c["status"], c["details"]) for c in rec_2.checks.values() - ] diff --git a/policyengine_uk/tests/policy/baseline/gov/treasury/energy_bills_rebate/energy_bills_rebate.yaml b/policyengine_uk/tests/policy/baseline/gov/treasury/energy_bills_rebate/energy_bills_rebate.yaml index a709590ecb..53b0915e0d 100644 --- a/policyengine_uk/tests/policy/baseline/gov/treasury/energy_bills_rebate/energy_bills_rebate.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/treasury/energy_bills_rebate/energy_bills_rebate.yaml @@ -20,11 +20,11 @@ # Each household below has no other income, does not claim Universal Credit # and has no council tax to pay, so its HBAI income is the rebate alone. The # Great Britain credit is £66 in October and November 2022 and £67 a month -# from December 2022 to March 2023. energy_bills_rebate sums it by calendar -# year (£199 and £201); HBAI counts it by financial year, all £400 in -# 2022-23, through hbai_energy_bills_rebate. +# from December 2022 to March 2023, summed by calendar year (£199 and £201). +# DWP's HBAI counts all £400 in 2022-23; the model keeps the calendar-year +# sum, as in household net income (docs: cost-of-living-payments.md). -- name: Case 3, a band C household's 2022-23 rebates are HBAI income in full. +- name: Case 3, a band C household's 2022 rebates are HBAI income. period: 2022 input: people: @@ -42,10 +42,9 @@ output: ebr_council_tax_rebate: 150 ebr_energy_bills_credit: 199 - hbai_energy_bills_rebate: 150 + 400 - hbai_household_net_income: 150 + 400 + hbai_household_net_income: 150 + 199 -- name: Case 4, the January to March 2023 instalments belong to 2022-23, not 2023-24. +- name: Case 4, in 2023 only the credit's remaining instalments are paid, and counted. period: 2023 input: people: @@ -63,34 +62,9 @@ output: ebr_council_tax_rebate: 0 energy_bills_rebate: 201 - hbai_energy_bills_rebate: 0 - hbai_household_net_income: 0 + hbai_household_net_income: 201 -# Northern Ireland's £400 credit is one payment in January 2023. Band E -# keeps out the Council Tax Rebate, which the model pays by band in every -# nation. -- name: Case 5, Northern Ireland's January 2023 credit is 2022-23 HBAI income. - period: 2022 - input: - people: - adult: - age: 40 - benunits: - unit: - members: [adult] - would_claim_uc: false - households: - household: - members: [adult] - region: NORTHERN_IRELAND - council_tax_band: E - council_tax: 0 - output: - energy_bills_rebate: 0 - hbai_energy_bills_rebate: 400 - hbai_household_net_income: 400 - -- name: Case 6, in 2023 Northern Ireland's credit counts by calendar year but not in HBAI. +- name: Case 5, Northern Ireland's January 2023 credit is HBAI income. period: 2023 input: people: @@ -103,15 +77,13 @@ households: household: members: [adult] - region: NORTHERN_IRELAND - council_tax_band: E + country: NORTHERN_IRELAND council_tax: 0 output: energy_bills_rebate: 400 - hbai_energy_bills_rebate: 0 - hbai_household_net_income: 0 + hbai_household_net_income: 400 -- name: Case 7, the rebates are miscellaneous income, not HBAI state support. +- name: Case 6, the rebates are miscellaneous income, not HBAI state support. period: 2022 input: people: @@ -127,71 +99,5 @@ council_tax_band: A council_tax: 0 output: - hbai_energy_bills_rebate: 550 + energy_bills_rebate: 349 hbai_benefits: 0 - -# The financial year's nation picks the scheme for all its months, even -# where the household's nation differs in the next model year (or isn't set -# for it: country, unlike region, does not carry over to the next year). -- name: Case 8, Northern Ireland given for 2022 only still gets its January 2023 credit. - period: 2022 - input: - people: - adult: - age: 40 - benunits: - unit: - members: [adult] - would_claim_uc: false - households: - household: - members: [adult] - country: NORTHERN_IRELAND - council_tax_band: E - council_tax: 0 - output: - hbai_energy_bills_rebate: 400 - -- name: Case 9, a move to Northern Ireland in 2023-24 leaves 2022-23 on the Great Britain scheme. - period: 2022 - input: - people: - adult: - age: 40 - benunits: - unit: - members: [adult] - would_claim_uc: false - households: - household: - members: [adult] - country: - 2022: ENGLAND - 2023: NORTHERN_IRELAND - council_tax_band: E - council_tax: 0 - output: - ebr_energy_bills_credit: 199 - hbai_energy_bills_rebate: 400 - -- name: Case 10, a move from Northern Ireland in 2023-24 leaves 2022-23 on Northern Ireland's scheme. - period: 2022 - input: - people: - adult: - age: 40 - benunits: - unit: - members: [adult] - would_claim_uc: false - households: - household: - members: [adult] - country: - 2022: NORTHERN_IRELAND - 2023: ENGLAND - council_tax_band: E - council_tax: 0 - output: - ebr_energy_bills_credit: 0 - hbai_energy_bills_rebate: 400 diff --git a/policyengine_uk/tests/test_energy_bills_rebate_hbai_properties.py b/policyengine_uk/tests/test_energy_bills_rebate_hbai_properties.py index f9dbd48035..64de39ae84 100644 --- a/policyengine_uk/tests/test_energy_bills_rebate_hbai_properties.py +++ b/policyengine_uk/tests/test_energy_bills_rebate_hbai_properties.py @@ -4,26 +4,18 @@ Ireland) and the £150 Council Tax Rebate as miscellaneous income, not state support (HBAI quality and methodology report FYE 2023, "Treatment of cost of living and wider support schemes in the HBAI income estimates for FYE -2023"), and counts both in FYE 2023. The Council Tax Rebate was a one-off: -billing authorities made every payment by 30 September 2022 (Council Tax -Rebate 2022-23 billing authority guidance, paragraph 7). The Great Britain -credit ran from October 2022 to March 2023, and Northern Ireland's was one -payment in January 2023, so all of it falls in the 2022-23 financial year. +2023"). The Council Tax Rebate was a one-off: billing authorities made every +payment by 30 September 2022 (Council Tax Rebate 2022-23 billing authority +guidance, paragraph 7). Invariants, each checked over many generated households in one reusable simulation: 1. HBAI household net income before the reset to zero rises by exactly the - household's hbai_energy_bills_rebate, in every year, nation and band. + household's energy_bills_rebate, in every year, nation and band. 2. The rebate is miscellaneous income: hbai_benefits does not change with it. 3. The Council Tax Rebate is paid once: in 2022 only, at £150, and only in bands A to D. -4. HBAI counts the credit by financial year: in 2022 it is the household's - eligible months of the October 2022 to March 2023 schedule (Northern - Ireland's January 2023 payment) of the nation it lives in that financial - year, whatever its nation the next year, and in every other year nil. -5. Financial and calendar years count the same payments: over 2021 to 2024, - hbai_energy_bills_rebate and energy_bills_rebate add up to the same total. """ import numpy as np @@ -43,40 +35,20 @@ COUNTRIES = ["ENGLAND", "SCOTLAND", "WALES", "NORTHERN_IRELAND"] HOUSEHOLDS = 16 -# The enacted schedule, written out here rather than read from parameters. -GREAT_BRITAIN_CREDIT = { - "2022-10": 66, - "2022-11": 66, - "2022-12": 67, - "2023-01": 67, - "2023-02": 67, - "2023-03": 67, -} -NORTHERN_IRELAND_CREDIT = {"2023-01": 400} - household = st.tuples( st.sampled_from(BANDS), st.sampled_from(COUNTRIES), - st.sampled_from(COUNTRIES), # the nation from 2023, if it moves st.sampled_from([0.0, 6_000.0, 18_000.0, 45_000.0]), # employment income st.booleans(), # claims Universal Credit - st.lists(st.booleans(), min_size=24, max_size=24), # EBSS months eligible + st.lists(st.booleans(), min_size=12, max_size=12), # EBSS months eligible ) households = st.lists(household, min_size=HOUSEHOLDS, max_size=HOUSEHOLDS) -def eligible(months, month): - """Whether the drawn household qualifies in ``month`` ("YYYY-MM"): - the first 12 draws are 2022's months, the rest 2023's.""" - year, number = (int(part) for part in month.split("-")) - return months[(year - 2022) % 2 * 12 + number - 1] - - -def simulation(drawn, years, without_rebate=False, moves=False): +def simulation(drawn, years, without_rebate=False): """One household per drawn case, with the same circumstances in each of - ``years``, except that with ``moves`` it lives in its second drawn nation - from 2023.""" - bands, countries, next_countries, pay, claims_uc, months = zip(*drawn) + ``years``.""" + bands, countries, pay, claims_uc, months = zip(*drawn) def each_year(value): return {year: value for year in years} @@ -96,12 +68,9 @@ def each_year(value): f"household_{i}": { "members": [f"adult_{i}"], "council_tax_band": each_year(bands[i]), - "country": { - year: next_countries[i] if moves and year > 2022 else countries[i] - for year in years - }, + "country": each_year(countries[i]), "ebr_energy_bills_credit_eligible": { - f"{year}-{month:02d}": eligible(months[i], f"{year}-{month:02d}") + f"{year}-{month:02d}": months[i][month - 1] for year in years for month in range(1, 13) }, @@ -110,7 +79,7 @@ def each_year(value): } if without_rebate: for values in households.values(): - values["hbai_energy_bills_rebate"] = each_year(0) + values["energy_bills_rebate"] = each_year(0) return Simulation( situation={"people": people, "benunits": benunits, "households": households} ) @@ -126,10 +95,9 @@ def each_year(value): @SETTINGS @given(households, st.sampled_from(YEARS)) def test_hbai_income_rises_by_exactly_the_rebate(drawn, year): - # The financial year reads January to March of the next calendar year. - with_rebate = simulation(drawn, [year, year + 1]) - without = simulation(drawn, [year, year + 1], without_rebate=True) - rebate = with_rebate.calculate("hbai_energy_bills_rebate", year) + with_rebate = simulation(drawn, [year]) + without = simulation(drawn, [year], without_rebate=True) + rebate = with_rebate.calculate("energy_bills_rebate", year) gain = with_rebate.calculate( "hbai_household_net_income_before_reset_to_zero", year ) - without.calculate("hbai_household_net_income_before_reset_to_zero", year) @@ -160,53 +128,16 @@ def test_the_council_tax_rebate_is_paid_once(drawn): assert (total <= 150).all() -@SETTINGS -@given(households) -def test_hbai_counts_the_credit_by_financial_year(drawn): - sim = simulation(drawn, range(2021, 2026), moves=True) - expected = np.array( - [ - sum( - amount - for month, amount in ( - NORTHERN_IRELAND_CREDIT - if country == "NORTHERN_IRELAND" - else GREAT_BRITAIN_CREDIT - ).items() - if eligible(months, month) - ) - for _, country, _, _, _, months in drawn - ] - ) - for year in YEARS: - credit = sim.calculate("hbai_energy_bills_rebate", year) - sim.calculate( - "ebr_council_tax_rebate", year - ) - np.testing.assert_allclose(credit, expected if year == 2022 else 0, atol=0.01) - - -@SETTINGS -@given(households) -def test_financial_and_calendar_years_count_the_same_payments(drawn): - sim = simulation(drawn, range(2021, 2026)) - by_financial_year = sum( - sim.calculate("hbai_energy_bills_rebate", year) for year in YEARS - ) - by_calendar_year = sum(sim.calculate("energy_bills_rebate", year) for year in YEARS) - np.testing.assert_allclose(by_financial_year, by_calendar_year, atol=0.01) - - def test_the_rebate_is_counted_once_and_not_as_state_support(): - """HBAI adds the financial-year rebate once, and not the calendar-year - rebate or its parts as well; it and the Warm Home Discount are - miscellaneous income, outside hbai_benefits.""" + """HBAI adds energy_bills_rebate once, and not its parts as well; it and + the Warm Home Discount are miscellaneous income, outside hbai_benefits.""" from policyengine_uk import CountryTaxBenefitSystem system = CountryTaxBenefitSystem() parts = system.variables["energy_bills_rebate"].adds assert set(parts) == {"ebr_council_tax_rebate", "ebr_energy_bills_credit"} - assert HBAI_HOUSEHOLD_NET_INCOME_ADDS.count("hbai_energy_bills_rebate") == 1 - assert not ({"energy_bills_rebate", *parts} & set(HBAI_HOUSEHOLD_NET_INCOME_ADDS)) + assert HBAI_HOUSEHOLD_NET_INCOME_ADDS.count("energy_bills_rebate") == 1 + assert not set(parts) & set(HBAI_HOUSEHOLD_NET_INCOME_ADDS) state_support = system.variables["hbai_benefits"].adds - for miscellaneous_income in ["hbai_energy_bills_rebate", "warm_home_discount"]: + for miscellaneous_income in ["energy_bills_rebate", "warm_home_discount"]: assert miscellaneous_income not in state_support diff --git a/policyengine_uk/utils/aggregate_impact/README.md b/policyengine_uk/utils/aggregate_impact/README.md index a6ba3c9b4b..700fd0fe35 100644 --- a/policyengine_uk/utils/aggregate_impact/README.md +++ b/policyengine_uk/utils/aggregate_impact/README.md @@ -32,9 +32,6 @@ Small cells (`privacy.py`): - A figure from fewer than 10 records is published as suppressed. - A total that holds one is rebuilt from its other components and marked †. - Poverty, inequality and deciles are withheld while HBAI income holds one. - Parts of HBAI income outside the partition that few records may hold - (`measure.HBAI_ONLY_COMPONENTS`, such as the Energy Bills Rebate by - financial year) are counted and suppressed like heads. - A reform's figures for a year are withheld when they would come from a small cell. diff --git a/policyengine_uk/utils/aggregate_impact/measure.py b/policyengine_uk/utils/aggregate_impact/measure.py index 5833ad1769..192a512422 100644 --- a/policyengine_uk/utils/aggregate_impact/measure.py +++ b/policyengine_uk/utils/aggregate_impact/measure.py @@ -32,17 +32,6 @@ "hbai_household_net_income": "Household net income (HBAI definition)", } -# Parts of HBAI income outside the partition that, like a head, few records -# may hold. Each is published with a head's small-cell protection on its own -# records, and in a year it is suppressed HBAI income, its deciles, poverty -# and inequality leave it out as they do a suppressed head. -# hbai_energy_bills_rebate is the Energy Bills Rebate head timed by -# financial year, so a year can hold it in few records while the head holds -# none, or the reverse. -HBAI_ONLY_COMPONENTS = { - "hbai_energy_bills_rebate": "Energy Bills Rebate in HBAI income", -} - class Recorder: """Collects metric values and check outcomes for one measurement.""" @@ -568,22 +557,6 @@ def measure_year( values != 0, weights[entity_of(sim, dc.variable)] ), ) - hbai_only_hh = {} - for variable, label in HBAI_ONLY_COMPONENTS.items(): - if variable not in tbs_variables: - continue - entity = entity_of(sim, variable) - values = array(sim, variable, year).astype(np.float64) - hbai_only_hh[variable] = to_household(sim, values, entity) - rec.metric( - f"household.{variable}", - label, - "household_totals", - "gbp", - year, - float(np.sum(hw * hbai_only_hh[variable])), - records=_weighted_records(values != 0, weights[entity]), - ) hidden = suppressed_components(rec, partition, year) # Households holding a suppressed component: no check verdict or detail # may depend on them this year. @@ -591,7 +564,7 @@ def measure_year( if hidden: holding = np.zeros(len(hw), dtype=bool) for variable in hidden: - holding |= {**head_hh, **double_count_hh, **hbai_only_hh}[variable] != 0 + holding |= {**head_hh, **double_count_hh}[variable] != 0 active = active_parameters(sim, partition, year) # Geographic exclusivity, on the data. A declared double count is netted @@ -963,9 +936,8 @@ def _exclude(record: dict, key: str, excludes: Mapping) -> None: def suppressed_components(rec: "Recorder", partition: Partition, year: int) -> dict: - """``{variable: (metric id, raw value)}`` for each head, declared double - count or part of HBAI income outside the partition (HBAI_ONLY_COMPONENTS) - suppressed in ``year``.""" + """``{variable: (metric id, raw value)}`` for each head or declared double + count suppressed in ``year``.""" key = str(year) out = {} for head in partition.heads: @@ -976,10 +948,6 @@ def suppressed_components(rec: "Recorder", partition: Partition, year: int) -> d metric_id = f"double_counts.{dc.variable}" if (metric_id, key) in rec.hidden: out[dc.variable] = (metric_id, rec.hidden[(metric_id, key)]) - for variable in HBAI_ONLY_COMPONENTS: - metric_id = f"household.{variable}" - if (metric_id, key) in rec.hidden: - out[variable] = (metric_id, rec.hidden[(metric_id, key)]) return out diff --git a/policyengine_uk/utils/aggregate_impact/privacy.py b/policyengine_uk/utils/aggregate_impact/privacy.py index 37fb88693f..80a6c32e75 100644 --- a/policyengine_uk/utils/aggregate_impact/privacy.py +++ b/policyengine_uk/utils/aggregate_impact/privacy.py @@ -8,10 +8,7 @@ A total that contains a suppressed head or double count is rebuilt that year from its other components (``measure.rebuild_protected``), and so are the -household incomes for deciles, so no published total includes it. Parts of -HBAI income outside the partition that few records may hold -(``measure.HBAI_ONLY_COMPONENTS``) are counted and suppressed like heads, and -HBAI income leaves them out in the same way. Checks +household incomes for deciles, so no published total includes it. Checks whose verdict would depend on its values leave out the households holding it (identities, the HBAI reproduction, overlap guards) or aren't run that year (the sign check of the suppressed head). A reform's costing and target change diff --git a/policyengine_uk/variables/gov/treasury/energy_bills_rebate/hbai_energy_bills_rebate.py b/policyengine_uk/variables/gov/treasury/energy_bills_rebate/hbai_energy_bills_rebate.py deleted file mode 100644 index fce90d877d..0000000000 --- a/policyengine_uk/variables/gov/treasury/energy_bills_rebate/hbai_energy_bills_rebate.py +++ /dev/null @@ -1,35 +0,0 @@ -from policyengine_uk.model_api import * -from policyengine_uk.variables.gov.treasury.energy_bills_rebate.monthly_ebr_energy_bills_credit import ( - ebss_credit, -) - - -class hbai_energy_bills_rebate(Variable): - label = "Energy Bills Rebate in HBAI income" - documentation = ( - "The Energy Bills Rebate by financial year, as HBAI counts it: the " - "Council Tax Rebate and the Energy Bills Support Scheme credit paid " - "from April of the year to the next March. HBAI counts the whole " - "October 2022 to March 2023 credit in FYE 2023. energy_bills_rebate " - "sums the credit by calendar year instead. The household's nation " - "is the financial year's, so the January to March instalments follow " - "the scheme of the year they belong to." - ) - entity = Household - definition_period = YEAR - value_type = float - unit = GBP - reference = "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" - - def formula(household, period, parameters): - country = household("country", period) - credit = 0 - # April of the year to March of the next. - for months_after_january in range(3, 15): - month = period.first_month.offset(months_after_january, MONTH) - eligible = household("ebr_energy_bills_credit_eligible", month) - credit = credit + eligible * ebss_credit(parameters, month, country) - annual_top_up = parameters( - period - ).gov.treasury.energy_bills_rebate.energy_bills_credit - return household("ebr_council_tax_rebate", period) + credit + annual_top_up diff --git a/policyengine_uk/variables/gov/treasury/energy_bills_rebate/monthly_ebr_energy_bills_credit.py b/policyengine_uk/variables/gov/treasury/energy_bills_rebate/monthly_ebr_energy_bills_credit.py index ef2d4f42f0..2509be974e 100644 --- a/policyengine_uk/variables/gov/treasury/energy_bills_rebate/monthly_ebr_energy_bills_credit.py +++ b/policyengine_uk/variables/gov/treasury/energy_bills_rebate/monthly_ebr_energy_bills_credit.py @@ -18,15 +18,11 @@ class monthly_ebr_energy_bills_credit(Variable): ) def formula(household, period, parameters): - return ebss_credit(parameters, period, household("country", period.this_year)) - - -def ebss_credit(parameters, month, country): - """The main-scheme credit for ``month`` to a household in ``country``.""" - ebr = parameters(month).gov.treasury.energy_bills_rebate - is_northern_ireland = country == country.possible_values.NORTHERN_IRELAND - return where( - is_northern_ireland, - ebr.energy_bills_credit_northern_ireland, - ebr.energy_bills_credit_monthly, - ) + ebr = parameters(period).gov.treasury.energy_bills_rebate + country = household("country", period.this_year) + is_northern_ireland = country == country.possible_values.NORTHERN_IRELAND + return where( + is_northern_ireland, + ebr.energy_bills_credit_northern_ireland, + ebr.energy_bills_credit_monthly, + ) 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 4277857b80..75af27e693 100644 --- a/policyengine_uk/variables/household/income/hbai_household_net_income.py +++ b/policyengine_uk/variables/household/income/hbai_household_net_income.py @@ -67,9 +67,11 @@ # Ireland) and the £150 Council Tax Rebate as miscellaneous income, not # state support. DWP imputes receipt, since the FRS did not ask about # them, so no FRS income field behind miscellaneous_income holds them. - # HBAI counts them by financial year, all in FYE 2023: + # HBAI counts the whole credit in FYE 2023; the model sums it by + # calendar year here as in household net income (see + # 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 - "hbai_energy_bills_rebate", + "energy_bills_rebate", # Reference for tax-free-childcare: https://assets.publishing.service.gov.uk/media/5e7b191886650c744175d08b/households-below-average-income-1994-1995-2018-2019.pdf ] From 9d4f7591b378a37e7a43eab88821da11f3b18f01 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 21:09:50 -0400 Subject: [PATCH 8/8] Address review r5: the EPG's timing does not reach HBAI income epg_subsidy counts in household net income and government spending but not in HBAI income, so only the Energy Bills Support Scheme's timing would move all three measures. Correct the docs page. Co-Authored-By: Claude Opus 5.5 --- .../book/programs/gov/treasury/cost-of-living-payments.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 653e93464b..3aea349205 100644 --- a/docs/book/programs/gov/treasury/cost-of-living-payments.md +++ b/docs/book/programs/gov/treasury/cost-of-living-payments.md @@ -55,9 +55,11 @@ the rebates, so the model counts them once. HBAI income takes the same calendar-year sum as household net income and government spending. A Great Britain household in bands A to D on the scheme throughout gets £349 in 2022 and £201 in 2023, where HBAI counts -£550 in 2022-23. `epg_subsidy` sums the Energy Price Guarantee by calendar -year in the same way. Summing either by financial year would move all -three measures together, so the model keeps one timing for now. +£550 in 2022-23. Summing the credit by financial year would move all three +measures together. `epg_subsidy` sums the Energy Price Guarantee by +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. ## Cost-of-Living Payments (2022-23 and 2023-24)