energy.bill-calculate
Electricity or gas bill from two meter reads: energy, standing charge, Climate Change Levy and VAT.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 15 tests, run in TypeScript, Python and Rust.
What it does
A GB electricity or gas bill from the reads at the start and end of the period, as a supplier's bill sets it out:
1. **Units**: closing read less opening read, allowing for the register rolling over (`energy.meter-advance`). 2. **kWh**: electricity meters read kWh; gas units are converted with the calorific value and the 1.02264 correction factor (`energy.gas-kwh`), kept to the watt-hour. 3. **Energy charge**: kWh x unit rate, rounded once to a penny by `mode`. 4. **Standing charge**: days x daily charge, rounded once by `mode`. The days are those between the two read dates (`dates.days-between`). 5. **Climate Change Levy**, unless the supply is for qualifying use (`energy.climate-change-levy`, at the rate on the closing read date). 6. **VAT** on the sum of those three lines, CCL included (the levy is part of the price for VAT), via `finance.tax.add-vat` at the rate for `vatCategory` on the closing read date, rounded half-up.
For example
calculate_energy_bill(fuel electricity, opening read …, closing read …, meter digits 5, gas unit —, calorific value —, unit rate 26,371, standing charge 52,143, qualifying use true, vat category REDUCE…)→ units 350, watt hours 350,000, days 30, energy charge £92.30, standing charge £15.64, climate change levy £0.00, net £107.94, vat £5.40, vat basis points 5%, total £113.34 a domestic electricity month at the January 2026 cap rates less VAT, 5% VAT, no levycalculate_energy_bill(fuel electricity, opening read …, closing read …, meter digits 5, gas unit —, calorific value —, unit rate 26,371, standing charge 52,143, qualifying use true, vat category REDUCE…)→ units 350, watt hours 350,000, days 30, energy charge £92.29, standing charge £15.64, climate change levy £0.00, net £107.93, vat £5.40, vat basis points 5%, total £113.33 the same bill rounding its lines down; VAT still rounds half-upcalculate_energy_bill(fuel electricity, opening read …, closing read …, meter digits 5, gas unit —, calorific value —, unit rate 25,000, standing charge 60,000, qualifying use false, vat category STAND…)→ units 2,000, watt hours 2,000,000, days 30, energy charge £500.00, standing charge £18.00, climate change levy £16.02, net £534.02, vat £106.80, vat basis points 20%, total £640.82 a business electricity month: CCL at 0.801p/kWh and 20% VAT on top of it
The function
The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.
def calculate_energy_bill(input: EnergyBillInput, mode: RoundingMode) -> EnergyBill
| input | EnergyBillInput | the reads, the tariff and how the supply is taxed |
| mode | RoundingMode | how the energy, standing charge and levy lines round to a whole penny |
| returns | EnergyBill |
The types it declares, generated into your project
BillFuel = Literal["electricity", "gas"]
@dataclass(frozen=True)
class MeterRead:
"""One meter read."""
date: str
#: the register: kWh for electricity, whole units for gas
value: int
@dataclass(frozen=True)
class EnergyBillInput:
"""Everything a GB electricity or gas bill is calculated from."""
fuel: BillFuel
opening_read: MeterRead
#: lower than openingRead means the meter rolled over
closing_read: MeterRead
#: whole-unit digits on the register, 1 to 15
meter_digits: int
#: gas only: cubic-metres or hundreds-cubic-feet
gas_unit: Optional[GasVolumeUnit]
#: gas only: average for the period, thousandths of a MJ/m³
calorific_value: Optional[int]
#: excluding VAT, thousandths of a penny per kWh: 24.567p is 24567
unit_rate: int
#: excluding VAT, thousandths of a penny per day
standing_charge: int
#: domestic, charity non-business or de minimis: no Climate Change Levy
qualifying_use: bool
#: finance.tax.vat-rate category for GB: REDUCED (5%), STANDARD (20%), ZERO
vat_category: str
@dataclass(frozen=True)
class EnergyBill:
"""The bill, line by line, in GBP."""
#: meter advance in the meter's own units
units: int
#: energy billed, thousandths of a kWh
watt_hours: int
#: days between the read dates
days: int
energy_charge: Money
standing_charge: Money
#: zero for a qualifying-use supply
climate_change_levy: Money
#: the three lines above
net: Money
vat: Money
vat_basis_points: int
total: Money
Your code names it in one line, in the file that uses it
from fune.energy.bill_calculate import calculate_energy_bill # energy.bill-calculate@^1
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
from .dates_days_between import days_between ← from dates.days-between ^1.0.0 · built alongside by fune
from .energy_bill_calculate_types import EnergyBill, EnergyBillInput
from .energy_climate_change_levy import climate_change_levy ← from energy.climate-change-levy ^1.0.0 · built alongside by fune
from .energy_gas_kwh import gas_kwh ← from energy.gas-kwh ^1.0.0 · built alongside by fune
from .energy_meter_advance import meter_advance ← from energy.meter-advance ^1.0.0 · built alongside by fune
from .finance_tax_add_vat import add_vat ← from finance.tax.add-vat ^1.0.0 · built alongside by fune
from .math_round_div import RoundingMode, round_div ← from math.round-div ^1.0.0 · built alongside by fune
from .money_amount import money ← from money.amount ^1.0.0 · built alongside by fune
# watt-hours x thousandths of a penny per kWh is millionths of a penny.
DIVISOR = 1_000_000
MAX_SAFE = 9007199254740991
def _check_rate(what: str, value: object) -> None:
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
raise ValueError("%s must be a whole number of thousandths of a penny, 0 or more, received %s" % (what, value))
def _exact(product: int) -> int:
if product > MAX_SAFE:
raise ValueError("energy charge too large to calculate exactly")
return product
def calculate_energy_bill(input: EnergyBillInput, mode: RoundingMode) -> EnergyBill:
"""A GB electricity or gas bill from two reads: energy at the unit rate,
standing charge by the day, Climate Change Levy unless the supply is for
qualifying use, and VAT on the lot at the caller's category.
"""
if input.fuel not in ("electricity", "gas"):
raise ValueError('unknown fuel "%s": expected electricity or gas' % (input.fuel,))
_check_rate("unitRate", input.unit_rate)
_check_rate("standingCharge", input.standing_charge)
units = meter_advance(input.opening_read.value, input.closing_read.value, input.meter_digits)
days = days_between(input.opening_read.date, input.closing_read.date)
if days < 0:
raise ValueError(
"the closing read must not be dated before the opening read, received %s and %s"
% (input.opening_read.date, input.closing_read.date)
)
if input.fuel == "gas":
if input.gas_unit is None or input.calorific_value is None:
raise ValueError("a gas bill needs gasUnit and calorificValue")
watt_hours = gas_kwh(units, input.gas_unit, input.calorific_value).watt_hours
else:
if input.gas_unit is not None or input.calorific_value is not None:
raise ValueError("gasUnit and calorificValue are for gas bills only")
watt_hours = units * 1000
energy = round_div(_exact(watt_hours * input.unit_rate), DIVISOR, mode)
standing = round_div(_exact(days * input.standing_charge), 1000, mode)
ccl = climate_change_levy(input.fuel, watt_hours, input.closing_read.date, input.qualifying_use, mode).levy.minor
vat = add_vat(money(energy + standing + ccl, "GBP"), "GB", input.vat_category, input.closing_read.date)
return EnergyBill(
units=units,
watt_hours=watt_hours,
days=days,
energy_charge=money(energy, "GBP"),
standing_charge=money(standing, "GBP"),
climate_change_levy=money(ccl, "GBP"),
net=vat.net,
vat=vat.tax,
vat_basis_points=vat.basis_points,
total=vat.gross,
)Install
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 7 dependencies, pins them in fune.lock, downloads only the Python package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:
fune add energy.bill-calculate
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./energy.bill-calculate-1.0.0-python.fune, or fetch it from a terminal with fune pull energy.bill-calculate@1.0.0:python.
The whole function, every language, is one file too: energy.bill-calculate-1.0.0.fune, 30,441 bytes, sha256 26df26352e87e74ee45faaeb0d4476e1f3adee5cf54461214c2330a9c1c38f4e. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
# fune: before energy.bill-calculate
after — your function gets the result and the arguments, and returns the final result.
# fune: after energy.bill-calculate
replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.
# fune: replace dates.days-between in energy.bill-calculate
# fune: replace energy.climate-change-levy in energy.bill-calculate
# fune: replace energy.gas-kwh in energy.bill-calculate
# fune: replace energy.meter-advance in energy.bill-calculate
# fune: replace finance.tax.add-vat in energy.bill-calculate
# fune: replace math.round-div in energy.bill-calculate
# fune: replace money.amount in energy.bill-calculate
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show energy.bill-calculate --steps.
# fune: step energy.bill-calculate after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
| Case | Arguments | Expected | |
|---|---|---|---|
| a domestic electricity month at the January 2026 cap rates less VAT, 5% VAT, no levy | fuel electricity, opening read …, closing read …, meter digits 5, gas unit —, calorific value —, unit rate 26,371, standing charge 52,143, qualifying use true, vat category REDUCE… | → | units 350, watt hours 350,000, days 30, energy charge £92.30, standing charge £15.64, climate change levy £0.00, net £107.94, vat £5.40, vat basis points 5%, total £113.34 |
| the same bill rounding its lines down; VAT still rounds half-up | fuel electricity, opening read …, closing read …, meter digits 5, gas unit —, calorific value —, unit rate 26,371, standing charge 52,143, qualifying use true, vat category REDUCE… | → | units 350, watt hours 350,000, days 30, energy charge £92.29, standing charge £15.64, climate change levy £0.00, net £107.93, vat £5.40, vat basis points 5%, total £113.33 |
| a business electricity month: CCL at 0.801p/kWh and 20% VAT on top of it | fuel electricity, opening read …, closing read …, meter digits 5, gas unit —, calorific value —, unit rate 25,000, standing charge 60,000, qualifying use false, vat category STAND… | → | units 2,000, watt hours 2,000,000, days 30, energy charge £500.00, standing charge £18.00, climate change levy £16.02, net £534.02, vat £106.80, vat basis points 20%, total £640.82 |
| a domestic gas month on a metric meter at 39.5 MJ/m³ | fuel gas, opening read …, closing read …, meter digits 5, gas unit cubic-metres, calorific value 39,500, unit rate 5,990, standing charge 32,410, qualifying use true, vat category… | → | units 100, watt hours 1,122,063, days 30, energy charge £67.21, standing charge £9.72, climate change levy £0.00, net £76.93, vat £3.85, vat basis points 5%, total £80.78 |
| a business gas month on an imperial meter that rolled over, at the 2023 gas levy rate | fuel gas, opening read …, closing read …, meter digits 4, gas unit hundreds-cubic-feet, calorific value 39,000, unit rate 4,000, standing charge 30,000, qualifying use false, vat … | → | units 20, watt hours 627,049, days 30, energy charge £25.08, standing charge £9.00, climate change levy £4.21, net £38.29, vat £7.66, vat basis points 20%, total £45.95 |
| domestic electricity in October 2026 under the temporary zero rate | fuel electricity, opening read …, closing read …, meter digits 5, gas unit —, calorific value —, unit rate 26,320, standing charge 54,830, qualifying use true, vat category ZERO, … | → | units 300, watt hours 300,000, days 30, energy charge £78.96, standing charge £16.45, climate change levy £0.00, net £95.41, vat £0.00, vat basis points 0%, total £95.41 |
| a de minimis business supply: 5% VAT and no levy | fuel electricity, opening read …, closing read …, meter digits 5, gas unit —, calorific value —, unit rate 25,000, standing charge 40,000, qualifying use true, vat category REDUCE… | → | units 900, watt hours 900,000, days 30, energy charge £225.00, standing charge £12.00, climate change levy £0.00, net £237.00, vat £11.85, vat basis points 5%, total £248.85 |
| reads on the same day with no use: no standing days, nothing to pay | fuel electricity, opening read …, closing read …, meter digits 5, gas unit —, calorific value —, unit rate 25,000, standing charge 40,000, qualifying use false, vat category STAND… | → | units 0, watt hours 0, days 0, energy charge £0.00, standing charge £0.00, climate change levy £0.00, net £0.00, vat £0.00, vat basis points 20%, total £0.00 |
| a gas bill without a calorific value is refused | fuel gas, opening read …, closing read …, meter digits 5, gas unit cubic-metres, calorific value —, unit rate 5,990, standing charge 32,410, qualifying use true, vat category REDU… | → | error: a gas bill needs gasUnit and calorificValue |
| gas details on an electricity bill are refused | fuel electricity, opening read …, closing read …, meter digits 5, gas unit cubic-metres, calorific value 39,500, unit rate 26,371, standing charge 52,143, qualifying use true, vat… | → | error: gasUnit and calorificValue are for gas bills only |
Show the other 5 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a closing read dated before the opening read is refused | fuel electricity, opening read …, closing read …, meter digits 5, gas unit —, calorific value —, unit rate 26,371, standing charge 52,143, qualifying use true, vat category REDUCE… | → | error: the closing read must not be dated before the opening read |
| an unknown VAT category is refused by the VAT table | fuel electricity, opening read …, closing read …, meter digits 5, gas unit —, calorific value —, unit rate 26,371, standing charge 52,143, qualifying use true, vat category DOMEST… | → | error: no VAT rule for GB/DOMESTIC on 2026-01-31 |
| a negative unit rate is refused | fuel electricity, opening read …, closing read …, meter digits 5, gas unit —, calorific value —, unit rate -1, standing charge 52,143, qualifying use true, vat category REDUCED, h… | → | error: unitRate must be a whole number of thousandths of a penny |
| an unknown fuel is refused | fuel oil, opening read …, closing read …, meter digits 5, gas unit —, calorific value —, unit rate 26,371, standing charge 52,143, qualifying use true, vat category REDUCED, half-… | → | error: unknown fuel "oil" |
| a business supply dated before the earliest levy rate is refused | fuel electricity, opening read …, closing read …, meter digits 5, gas unit —, calorific value —, unit rate 25,000, standing charge 40,000, qualifying use false, vat category STAND… | → | error: no Climate Change Levy rate for electricity on 2023-03-01 |
More from the author
## VAT: the category is the caller's
`finance.tax.vat-rate` has no energy-specific category, and this capability does not add one: it takes the GB category from the caller and looks the rate up by date, so it follows the rate table rather than hard-coding 5%.
- **REDUCED (5%)**: fuel and power for qualifying use: domestic, charity non-business, and business supplies within the de minimis limits (33 kWh a day of electricity, 145 kWh a day of gas). - **STANDARD (20%)**: other business supplies. - **ZERO**: domestic electricity in Great Britain from 1 October 2026 to 31 March 2027, under the temporary zero rate. `finance.tax.vat-rate` does not yet carry that as a dated rule for REDUCED, so pass ZERO for a qualifying electricity supply in that window; gas stays REDUCED.
`qualifyingUse` is asked separately because it also decides the Climate Change Levy, and because the same qualifying supply can be REDUCED or ZERO depending on the fuel and date. Keep the two consistent: a qualifying supply is REDUCED or ZERO, a non-qualifying one STANDARD.
## Rates
Unit rate and standing charge are **excluding VAT**, in thousandths of a penny, as business tariffs are quoted. Ofgem's price cap figures (`energy.price-cap`) include VAT; divide by 1.05 first.
## Not covered
A single rate for the whole period: split a bill across a tariff change, a CCL rate change (1 April) or a VAT change into two calls. Economy 7 and half-hourly pricing are `energy.tariff-time-of-use`.
## Sources
- HMRC, "VAT on fuel and power (VAT Notice 701/19)", https://www.gov.uk/guidance/vat-on-fuel-and-power-notice-70119 (qualifying use, de minimis limits; paragraph 2.8 on the Climate Change Levy). - HM Treasury / HMRC, "Temporary zero rate of VAT for domestic electricity in Great Britain", https://www.gov.uk/government/publications/temporary-zero-rate-of-vat-for-domestic-electricity-in-great-britain - The capabilities this one uses carry their own sources.
Files
| Path | Bytes |
|---|---|
| README.md | 2,955 |
| impl/python.py | 3,031 |
| impl/rust.rs | 5,413 |
| impl/typescript.ts | 3,053 |
| vectors.json | 9,150 |