Add Nuclear RAB levy, metering charge and DUoS catch-all to tariff cost model

The June electricity bill showed £252.72 (7.5% of the bill) in charges the
cost model didn't account for: Nuclear RAB Levy, Metering Charge, and DUoS
Availability/Excess/Reactive. RAB levy and metering charge are modeled
directly (same shape as CCL / standing charge); the DUoS charges need kVA
capacity and kVArh reactive energy that only the supplier's own meter reads,
so they're covered by one adjustable "other network charges" £/month field
to update manually from each bill.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-28 13:07:20 +00:00
parent f1ce0f06d8
commit 3b261a4d8d
10 changed files with 158 additions and 15 deletions

View file

@ -42,6 +42,11 @@ export async function initDb() {
CREATE INDEX IF NOT EXISTS meters_category_idx ON meters (category_id); CREATE INDEX IF NOT EXISTS meters_category_idx ON meters (category_id);
CREATE INDEX IF NOT EXISTS meters_parent_idx ON meters (parent_meter_id); CREATE INDEX IF NOT EXISTS meters_parent_idx ON meters (parent_meter_id);
-- set on any meter fed by an MQTT-publishing device (e.g. an ESPHome sensor's
-- state topic); NULL for manually-read meters, which this never touches
ALTER TABLE meters ADD COLUMN IF NOT EXISTS mqtt_topic TEXT;
CREATE UNIQUE INDEX IF NOT EXISTS meters_mqtt_topic_uidx ON meters (mqtt_topic) WHERE mqtt_topic IS NOT NULL;
-- fallback_split_pct e.g. {"day":60,"night":40} used to split one cumulative -- fallback_split_pct e.g. {"day":60,"night":40} used to split one cumulative
-- reading across TOU rate windows when there's no device data (v1: manual only) -- reading across TOU rate windows when there's no device data (v1: manual only)
CREATE TABLE IF NOT EXISTS tariffs ( CREATE TABLE IF NOT EXISTS tariffs (
@ -62,6 +67,15 @@ export async function initDb() {
CREATE INDEX IF NOT EXISTS tariffs_category_idx ON tariffs (category_id); CREATE INDEX IF NOT EXISTS tariffs_category_idx ON tariffs (category_id);
-- second per-unit levy (e.g. Nuclear RAB Levy) alongside CCL, plus flat
-- monthly charges bills carry outside consumption/standing: a metering
-- charge and a catch-all for capacity-based network charges (DUoS
-- availability/excess/reactive) that need kVA/kVArh data this app doesn't
-- track entered as one adjustable £/month figure instead
ALTER TABLE tariffs ADD COLUMN IF NOT EXISTS rab_levy_rate_pence_per_unit NUMERIC(10,4);
ALTER TABLE tariffs ADD COLUMN IF NOT EXISTS metering_charge_pence_per_month NUMERIC(10,4) NOT NULL DEFAULT 0;
ALTER TABLE tariffs ADD COLUMN IF NOT EXISTS other_charges_pence_per_month NUMERIC(10,4) NOT NULL DEFAULT 0;
-- non-TOU tariff = single window covering all days/hours -- non-TOU tariff = single window covering all days/hours
CREATE TABLE IF NOT EXISTS tariff_rate_windows ( CREATE TABLE IF NOT EXISTS tariff_rate_windows (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
@ -105,6 +119,29 @@ export async function initDb() {
); );
CREATE INDEX IF NOT EXISTS readings_meter_date_idx ON readings (meter_id, reading_date DESC); CREATE INDEX IF NOT EXISTS readings_meter_date_idx ON readings (meter_id, reading_date DESC);
-- raw MQTT telemetry, kept briefly for visibility/debugging only NOT the
-- official reading log (that's the readings table); one row per device publish
CREATE TABLE IF NOT EXISTS meter_telemetry_raw (
id SERIAL PRIMARY KEY,
meter_id INT NOT NULL REFERENCES meters(id) ON DELETE CASCADE,
value NUMERIC(14,3) NOT NULL,
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS telemetry_raw_meter_time_idx ON meter_telemetry_raw (meter_id, recorded_at DESC);
-- hourly downsample of the above (last raw value seen in the hour, since the
-- telemetry value is a monotonic cumulative totalizer last-value-in-bucket,
-- not an average), independent retention from the raw table
CREATE TABLE IF NOT EXISTS meter_telemetry_hourly (
id SERIAL PRIMARY KEY,
meter_id INT NOT NULL REFERENCES meters(id) ON DELETE CASCADE,
bucket_start TIMESTAMPTZ NOT NULL,
value NUMERIC(14,3) NOT NULL,
sample_count INT NOT NULL DEFAULT 1,
UNIQUE (meter_id, bucket_start)
);
CREATE INDEX IF NOT EXISTS telemetry_hourly_meter_time_idx ON meter_telemetry_hourly (meter_id, bucket_start DESC);
`) `)
await seedDefaults() await seedDefaults()
@ -127,6 +164,9 @@ async function seedDefaults() {
const defaults = { const defaults = {
estimate_trailing_days: 30, // global default trailing-average window (days), 7 | 14 | 30 estimate_trailing_days: 30, // global default trailing-average window (days), 7 | 14 | 30
telemetry_raw_retention_hours: 336, // 14 days
telemetry_hourly_retention_days: 60,
telemetry_daily_schedule_time: '03:15',
} }
for (const [key, value] of Object.entries(defaults)) { for (const [key, value] of Object.entries(defaults)) {
await pool.query( await pool.query(

View file

@ -153,7 +153,10 @@ export async function getRateWindows(tariffId) {
// the Directors report's dry/wet forecast. // the Directors report's dry/wet forecast.
export function computeCost({ tariff, windows, consumption, daysInPeriod }) { export function computeCost({ tariff, windows, consumption, daysInPeriod }) {
if (!tariff || consumption == null) { if (!tariff || consumption == null) {
return { usage_cost_pence: 0, standing_cost_pence: 0, ccl_cost_pence: 0, subtotal_pence: 0, vat_pence: 0, total_pence: 0, split: null } return {
usage_cost_pence: 0, standing_cost_pence: 0, ccl_cost_pence: 0, rab_levy_cost_pence: 0,
metering_cost_pence: 0, other_charges_cost_pence: 0, subtotal_pence: 0, vat_pence: 0, total_pence: 0, split: null,
}
} }
let usageCostPence = 0 let usageCostPence = 0
@ -178,8 +181,17 @@ export function computeCost({ tariff, windows, consumption, daysInPeriod }) {
const cclCostPence = (!tariff.ccl_exempt && tariff.ccl_rate_pence_per_unit) const cclCostPence = (!tariff.ccl_exempt && tariff.ccl_rate_pence_per_unit)
? consumption * Number(tariff.ccl_rate_pence_per_unit) ? consumption * Number(tariff.ccl_rate_pence_per_unit)
: 0 : 0
const rabLevyCostPence = tariff.rab_levy_rate_pence_per_unit
? consumption * Number(tariff.rab_levy_rate_pence_per_unit)
: 0
// Flat monthly charges (metering charge, and a catch-all for DUoS
// capacity/reactive charges this app can't compute from meter data alone)
// pro-rated at 1/30th per day of the period, matching how suppliers apply
// them to a non-calendar-month billing period.
const meteringCostPence = Number(tariff.metering_charge_pence_per_month || 0) / 30 * daysInPeriod
const otherChargesCostPence = Number(tariff.other_charges_pence_per_month || 0) / 30 * daysInPeriod
const subtotalPence = usageCostPence + standingCostPence + cclCostPence const subtotalPence = usageCostPence + standingCostPence + cclCostPence + rabLevyCostPence + meteringCostPence + otherChargesCostPence
const vatPence = subtotalPence * (Number(tariff.vat_rate_pct) / 100) const vatPence = subtotalPence * (Number(tariff.vat_rate_pct) / 100)
const totalPence = subtotalPence + vatPence const totalPence = subtotalPence + vatPence
@ -187,6 +199,9 @@ export function computeCost({ tariff, windows, consumption, daysInPeriod }) {
usage_cost_pence: usageCostPence, usage_cost_pence: usageCostPence,
standing_cost_pence: standingCostPence, standing_cost_pence: standingCostPence,
ccl_cost_pence: cclCostPence, ccl_cost_pence: cclCostPence,
rab_levy_cost_pence: rabLevyCostPence,
metering_cost_pence: meteringCostPence,
other_charges_cost_pence: otherChargesCostPence,
subtotal_pence: subtotalPence, subtotal_pence: subtotalPence,
vat_pence: vatPence, vat_pence: vatPence,
total_pence: totalPence, total_pence: totalPence,
@ -194,7 +209,10 @@ export function computeCost({ tariff, windows, consumption, daysInPeriod }) {
} }
} }
const ZERO_TOTALS = { usage_cost_pence: 0, standing_cost_pence: 0, ccl_cost_pence: 0, subtotal_pence: 0, vat_pence: 0, total_pence: 0 } const ZERO_TOTALS = {
usage_cost_pence: 0, standing_cost_pence: 0, ccl_cost_pence: 0, rab_levy_cost_pence: 0,
metering_cost_pence: 0, other_charges_cost_pence: 0, subtotal_pence: 0, vat_pence: 0, total_pence: 0,
}
// Cost a single amount of consumption over [rangeStart, rangeEnd], splitting it // Cost a single amount of consumption over [rangeStart, rangeEnd], splitting it
// pro-rata (by day count) across every tariff segment active in that range. Used // pro-rata (by day count) across every tariff segment active in that range. Used

View file

@ -47,8 +47,14 @@ export async function estimateRoutes(app) {
usage_cost_pence: acc.usage_cost_pence + r.usage_cost_pence, usage_cost_pence: acc.usage_cost_pence + r.usage_cost_pence,
standing_cost_pence: acc.standing_cost_pence + r.standing_cost_pence, standing_cost_pence: acc.standing_cost_pence + r.standing_cost_pence,
ccl_cost_pence: acc.ccl_cost_pence + r.ccl_cost_pence, ccl_cost_pence: acc.ccl_cost_pence + r.ccl_cost_pence,
rab_levy_cost_pence: acc.rab_levy_cost_pence + r.rab_levy_cost_pence,
metering_cost_pence: acc.metering_cost_pence + r.metering_cost_pence,
other_charges_cost_pence: acc.other_charges_cost_pence + r.other_charges_cost_pence,
vat_pence: acc.vat_pence + r.vat_pence, vat_pence: acc.vat_pence + r.vat_pence,
}), { total_pence: 0, usage_cost_pence: 0, standing_cost_pence: 0, ccl_cost_pence: 0, vat_pence: 0 }) }), {
total_pence: 0, usage_cost_pence: 0, standing_cost_pence: 0, ccl_cost_pence: 0,
rab_levy_cost_pence: 0, metering_cost_pence: 0, other_charges_cost_pence: 0, vat_pence: 0,
})
return { period: { year, month, start, end }, global_default_window: globalDefault, meters: results, totals } return { period: { year, month, start, end }, global_default_window: globalDefault, meters: results, totals }
}) })

View file

@ -63,11 +63,17 @@ export async function internalRoutes(app) {
const { rows: categories } = await pool.query('SELECT * FROM meter_categories WHERE active = TRUE ORDER BY sort_order') const { rows: categories } = await pool.query('SELECT * FROM meter_categories WHERE active = TRUE ORDER BY sort_order')
const breakdown = [] const breakdown = []
const totals = { usage_cost_pence: 0, standing_cost_pence: 0, ccl_cost_pence: 0, vat_pence: 0, total_pence: 0, consumption: 0 } const totals = {
usage_cost_pence: 0, standing_cost_pence: 0, ccl_cost_pence: 0, rab_levy_cost_pence: 0,
metering_cost_pence: 0, other_charges_cost_pence: 0, vat_pence: 0, total_pence: 0, consumption: 0,
}
for (const cat of categories) { for (const cat of categories) {
const { rows: meters } = await pool.query('SELECT id FROM meters WHERE category_id = $1 AND active = TRUE', [cat.id]) const { rows: meters } = await pool.query('SELECT id FROM meters WHERE category_id = $1 AND active = TRUE', [cat.id])
const catTotals = { usage_cost_pence: 0, standing_cost_pence: 0, ccl_cost_pence: 0, vat_pence: 0, total_pence: 0, consumption: 0 } const catTotals = {
usage_cost_pence: 0, standing_cost_pence: 0, ccl_cost_pence: 0, rab_levy_cost_pence: 0,
metering_cost_pence: 0, other_charges_cost_pence: 0, vat_pence: 0, total_pence: 0, consumption: 0,
}
for (const m of meters) { for (const m of meters) {
const cost = await getMeterCostForPeriod(m.id, start, end) const cost = await getMeterCostForPeriod(m.id, start, end)
@ -75,6 +81,9 @@ export async function internalRoutes(app) {
catTotals.usage_cost_pence += cost.usage_cost_pence catTotals.usage_cost_pence += cost.usage_cost_pence
catTotals.standing_cost_pence += cost.standing_cost_pence catTotals.standing_cost_pence += cost.standing_cost_pence
catTotals.ccl_cost_pence += cost.ccl_cost_pence catTotals.ccl_cost_pence += cost.ccl_cost_pence
catTotals.rab_levy_cost_pence += cost.rab_levy_cost_pence
catTotals.metering_cost_pence += cost.metering_cost_pence
catTotals.other_charges_cost_pence += cost.other_charges_cost_pence
catTotals.vat_pence += cost.vat_pence catTotals.vat_pence += cost.vat_pence
catTotals.total_pence += cost.total_pence catTotals.total_pence += cost.total_pence
} }

View file

@ -37,9 +37,15 @@ export async function reportRoutes(app) {
usage_cost_pence: acc.usage_cost_pence + r.usage_cost_pence, usage_cost_pence: acc.usage_cost_pence + r.usage_cost_pence,
standing_cost_pence: acc.standing_cost_pence + r.standing_cost_pence, standing_cost_pence: acc.standing_cost_pence + r.standing_cost_pence,
ccl_cost_pence: acc.ccl_cost_pence + r.ccl_cost_pence, ccl_cost_pence: acc.ccl_cost_pence + r.ccl_cost_pence,
rab_levy_cost_pence: acc.rab_levy_cost_pence + r.rab_levy_cost_pence,
metering_cost_pence: acc.metering_cost_pence + r.metering_cost_pence,
other_charges_cost_pence: acc.other_charges_cost_pence + r.other_charges_cost_pence,
vat_pence: acc.vat_pence + r.vat_pence, vat_pence: acc.vat_pence + r.vat_pence,
total_pence: acc.total_pence + r.total_pence, total_pence: acc.total_pence + r.total_pence,
}), { consumption: 0, usage_cost_pence: 0, standing_cost_pence: 0, ccl_cost_pence: 0, vat_pence: 0, total_pence: 0 }) }), {
consumption: 0, usage_cost_pence: 0, standing_cost_pence: 0, ccl_cost_pence: 0,
rab_levy_cost_pence: 0, metering_cost_pence: 0, other_charges_cost_pence: 0, vat_pence: 0, total_pence: 0,
})
return { period: { start, end, isCurrent }, meters: results, totals } return { period: { start, end, isCurrent }, meters: results, totals }
}) })

View file

@ -64,12 +64,14 @@ export async function tariffRoutes(app) {
const { rows } = await client.query( const { rows } = await client.query(
`INSERT INTO tariffs (category_id, name, supplier, effective_from, effective_to, `INSERT INTO tariffs (category_id, name, supplier, effective_from, effective_to,
standing_charge_pence_per_day, ccl_rate_pence_per_unit, ccl_exempt, standing_charge_pence_per_day, ccl_rate_pence_per_unit, ccl_exempt,
vat_rate_pct, is_time_of_use, fallback_split_pct) vat_rate_pct, is_time_of_use, fallback_split_pct,
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING *`, rab_levy_rate_pence_per_unit, metering_charge_pence_per_month, other_charges_pence_per_month)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) RETURNING *`,
[ [
b.category_id, b.name, b.supplier || null, b.effective_from, b.effective_to || null, b.category_id, b.name, b.supplier || null, b.effective_from, b.effective_to || null,
b.standing_charge_pence_per_day || 0, b.ccl_rate_pence_per_unit || null, b.ccl_exempt === true, b.standing_charge_pence_per_day || 0, b.ccl_rate_pence_per_unit || null, b.ccl_exempt === true,
b.vat_rate_pct ?? 20, b.is_time_of_use === true, JSON.stringify(b.fallback_split_pct || {}), b.vat_rate_pct ?? 20, b.is_time_of_use === true, JSON.stringify(b.fallback_split_pct || {}),
b.rab_levy_rate_pence_per_unit || null, b.metering_charge_pence_per_month || 0, b.other_charges_pence_per_month || 0,
] ]
) )
const tariff = rows[0] const tariff = rows[0]
@ -112,8 +114,10 @@ export async function tariffRoutes(app) {
const { rows } = await pool.query( const { rows } = await pool.query(
`UPDATE tariffs SET name = $1, supplier = $2, effective_from = $3, effective_to = $4, `UPDATE tariffs SET name = $1, supplier = $2, effective_from = $3, effective_to = $4,
standing_charge_pence_per_day = $5, ccl_rate_pence_per_unit = $6, ccl_exempt = $7, standing_charge_pence_per_day = $5, ccl_rate_pence_per_unit = $6, ccl_exempt = $7,
vat_rate_pct = $8, is_time_of_use = $9, fallback_split_pct = $10 vat_rate_pct = $8, is_time_of_use = $9, fallback_split_pct = $10,
WHERE id = $11 RETURNING *`, rab_levy_rate_pence_per_unit = $11, metering_charge_pence_per_month = $12,
other_charges_pence_per_month = $13
WHERE id = $14 RETURNING *`,
[ [
b.name ?? t.name, b.name ?? t.name,
b.supplier !== undefined ? b.supplier : t.supplier, b.supplier !== undefined ? b.supplier : t.supplier,
@ -125,6 +129,9 @@ export async function tariffRoutes(app) {
b.vat_rate_pct ?? t.vat_rate_pct, b.vat_rate_pct ?? t.vat_rate_pct,
b.is_time_of_use ?? t.is_time_of_use, b.is_time_of_use ?? t.is_time_of_use,
JSON.stringify(b.fallback_split_pct ?? t.fallback_split_pct), JSON.stringify(b.fallback_split_pct ?? t.fallback_split_pct),
b.rab_levy_rate_pence_per_unit !== undefined ? b.rab_levy_rate_pence_per_unit : t.rab_levy_rate_pence_per_unit,
b.metering_charge_pence_per_month ?? t.metering_charge_pence_per_month,
b.other_charges_pence_per_month ?? t.other_charges_pence_per_month,
req.params.id, req.params.id,
] ]
) )

View file

@ -64,7 +64,7 @@ export default function Estimates() {
<> <>
<div className="info-banner"> <div className="info-banner">
Projected cost for the current open period ({report.period.start} to {report.period.end}) trailing average Projected cost for the current open period ({report.period.start} to {report.period.end}) trailing average
daily consumption × remaining days, plus standing charge, CCL and VAT for the full period. daily consumption × remaining days, plus standing charge, CCL, RAB levy, fixed extras and VAT for the full period.
</div> </div>
<div className="stats-strip"> <div className="stats-strip">
@ -72,6 +72,8 @@ export default function Estimates() {
<div className="stat-box"><div className="stat-value">{formatMoney(report.totals.usage_cost_pence)}</div><div className="stat-label">Usage</div></div> <div className="stat-box"><div className="stat-value">{formatMoney(report.totals.usage_cost_pence)}</div><div className="stat-label">Usage</div></div>
<div className="stat-box"><div className="stat-value">{formatMoney(report.totals.standing_cost_pence)}</div><div className="stat-label">Standing</div></div> <div className="stat-box"><div className="stat-value">{formatMoney(report.totals.standing_cost_pence)}</div><div className="stat-label">Standing</div></div>
<div className="stat-box"><div className="stat-value">{formatMoney(report.totals.ccl_cost_pence)}</div><div className="stat-label">CCL</div></div> <div className="stat-box"><div className="stat-value">{formatMoney(report.totals.ccl_cost_pence)}</div><div className="stat-label">CCL</div></div>
<div className="stat-box"><div className="stat-value">{formatMoney(report.totals.rab_levy_cost_pence)}</div><div className="stat-label">RAB Levy</div></div>
<div className="stat-box"><div className="stat-value">{formatMoney(report.totals.metering_cost_pence + report.totals.other_charges_cost_pence)}</div><div className="stat-label">Fixed extras</div></div>
<div className="stat-box"><div className="stat-value">{formatMoney(report.totals.vat_pence)}</div><div className="stat-label">VAT</div></div> <div className="stat-box"><div className="stat-value">{formatMoney(report.totals.vat_pence)}</div><div className="stat-label">VAT</div></div>
</div> </div>

View file

@ -57,6 +57,8 @@ export default function Reports() {
<div className="stat-box"><div className="stat-value">{formatMoney(report.totals.usage_cost_pence)}</div><div className="stat-label">Usage</div></div> <div className="stat-box"><div className="stat-value">{formatMoney(report.totals.usage_cost_pence)}</div><div className="stat-label">Usage</div></div>
<div className="stat-box"><div className="stat-value">{formatMoney(report.totals.standing_cost_pence)}</div><div className="stat-label">Standing</div></div> <div className="stat-box"><div className="stat-value">{formatMoney(report.totals.standing_cost_pence)}</div><div className="stat-label">Standing</div></div>
<div className="stat-box"><div className="stat-value">{formatMoney(report.totals.ccl_cost_pence)}</div><div className="stat-label">CCL</div></div> <div className="stat-box"><div className="stat-value">{formatMoney(report.totals.ccl_cost_pence)}</div><div className="stat-label">CCL</div></div>
<div className="stat-box"><div className="stat-value">{formatMoney(report.totals.rab_levy_cost_pence)}</div><div className="stat-label">RAB Levy</div></div>
<div className="stat-box"><div className="stat-value">{formatMoney(report.totals.metering_cost_pence + report.totals.other_charges_cost_pence)}</div><div className="stat-label">Fixed extras</div></div>
<div className="stat-box"><div className="stat-value">{formatMoney(report.totals.vat_pence)}</div><div className="stat-label">VAT</div></div> <div className="stat-box"><div className="stat-value">{formatMoney(report.totals.vat_pence)}</div><div className="stat-label">VAT</div></div>
</div> </div>
@ -70,6 +72,7 @@ export default function Reports() {
<tr> <tr>
<th>Meter</th><th>Category</th><th className="num">Consumption</th> <th>Meter</th><th>Category</th><th className="num">Consumption</th>
<th className="num">Usage</th><th className="num">Standing</th><th className="num">CCL</th> <th className="num">Usage</th><th className="num">Standing</th><th className="num">CCL</th>
<th className="num">RAB Levy</th><th className="num">Fixed extras</th>
<th className="num">VAT</th><th className="num">Total</th> <th className="num">VAT</th><th className="num">Total</th>
</tr> </tr>
</thead> </thead>
@ -90,6 +93,8 @@ export default function Reports() {
<td className="num">{formatMoney(m.usage_cost_pence)}</td> <td className="num">{formatMoney(m.usage_cost_pence)}</td>
<td className="num">{formatMoney(m.standing_cost_pence)}</td> <td className="num">{formatMoney(m.standing_cost_pence)}</td>
<td className="num">{formatMoney(m.ccl_cost_pence)}</td> <td className="num">{formatMoney(m.ccl_cost_pence)}</td>
<td className="num">{formatMoney(m.rab_levy_cost_pence)}</td>
<td className="num">{formatMoney(m.metering_cost_pence + m.other_charges_cost_pence)}</td>
<td className="num">{formatMoney(m.vat_pence)}</td> <td className="num">{formatMoney(m.vat_pence)}</td>
<td className="num">{formatMoney(m.total_pence)}</td> <td className="num">{formatMoney(m.total_pence)}</td>
</tr> </tr>
@ -100,6 +105,8 @@ export default function Reports() {
<td className="num">{formatMoney(report.totals.usage_cost_pence)}</td> <td className="num">{formatMoney(report.totals.usage_cost_pence)}</td>
<td className="num">{formatMoney(report.totals.standing_cost_pence)}</td> <td className="num">{formatMoney(report.totals.standing_cost_pence)}</td>
<td className="num">{formatMoney(report.totals.ccl_cost_pence)}</td> <td className="num">{formatMoney(report.totals.ccl_cost_pence)}</td>
<td className="num">{formatMoney(report.totals.rab_levy_cost_pence)}</td>
<td className="num">{formatMoney(report.totals.metering_cost_pence + report.totals.other_charges_cost_pence)}</td>
<td className="num">{formatMoney(report.totals.vat_pence)}</td> <td className="num">{formatMoney(report.totals.vat_pence)}</td>
<td className="num">{formatMoney(report.totals.total_pence)}</td> <td className="num">{formatMoney(report.totals.total_pence)}</td>
</tr> </tr>

View file

@ -16,6 +16,7 @@ const emptyWindow = (label: string, sort: number): WindowForm => ({
const emptyForm = { const emptyForm = {
id: 0, category_id: 0, name: '', supplier: '', effective_from: new Date().toISOString().slice(0, 10), id: 0, category_id: 0, name: '', supplier: '', effective_from: new Date().toISOString().slice(0, 10),
effective_to: '', standing_charge_pence_per_day: 0, ccl_rate_pence_per_unit: '' as number | '', ccl_exempt: false, effective_to: '', standing_charge_pence_per_day: 0, ccl_rate_pence_per_unit: '' as number | '', ccl_exempt: false,
rab_levy_rate_pence_per_unit: '' as number | '', metering_charge_pence_per_month: 0, other_charges_pence_per_month: 0,
vat_rate_pct: 20, is_time_of_use: false, windows: [emptyWindow('Standard', 0)], vat_rate_pct: 20, is_time_of_use: false, windows: [emptyWindow('Standard', 0)],
} }
@ -55,7 +56,11 @@ export default function Tariffs() {
effective_from: full.effective_from.slice(0, 10), effective_to: full.effective_to ? full.effective_to.slice(0, 10) : '', effective_from: full.effective_from.slice(0, 10), effective_to: full.effective_to ? full.effective_to.slice(0, 10) : '',
standing_charge_pence_per_day: Number(full.standing_charge_pence_per_day), standing_charge_pence_per_day: Number(full.standing_charge_pence_per_day),
ccl_rate_pence_per_unit: full.ccl_rate_pence_per_unit != null ? Number(full.ccl_rate_pence_per_unit) : '', ccl_rate_pence_per_unit: full.ccl_rate_pence_per_unit != null ? Number(full.ccl_rate_pence_per_unit) : '',
ccl_exempt: full.ccl_exempt, vat_rate_pct: Number(full.vat_rate_pct), is_time_of_use: full.is_time_of_use, ccl_exempt: full.ccl_exempt,
rab_levy_rate_pence_per_unit: full.rab_levy_rate_pence_per_unit != null ? Number(full.rab_levy_rate_pence_per_unit) : '',
metering_charge_pence_per_month: Number(full.metering_charge_pence_per_month || 0),
other_charges_pence_per_month: Number(full.other_charges_pence_per_month || 0),
vat_rate_pct: Number(full.vat_rate_pct), is_time_of_use: full.is_time_of_use,
windows: windows.length ? windows : [emptyWindow('Standard', 0)], windows: windows.length ? windows : [emptyWindow('Standard', 0)],
}) })
} catch (err) { } catch (err) {
@ -109,7 +114,11 @@ export default function Tariffs() {
effective_from: form.effective_from, effective_to: form.effective_to || null, effective_from: form.effective_from, effective_to: form.effective_to || null,
standing_charge_pence_per_day: form.standing_charge_pence_per_day, standing_charge_pence_per_day: form.standing_charge_pence_per_day,
ccl_rate_pence_per_unit: form.ccl_rate_pence_per_unit === '' ? null : form.ccl_rate_pence_per_unit, ccl_rate_pence_per_unit: form.ccl_rate_pence_per_unit === '' ? null : form.ccl_rate_pence_per_unit,
ccl_exempt: form.ccl_exempt, vat_rate_pct: form.vat_rate_pct, is_time_of_use: form.is_time_of_use, ccl_exempt: form.ccl_exempt,
rab_levy_rate_pence_per_unit: form.rab_levy_rate_pence_per_unit === '' ? null : form.rab_levy_rate_pence_per_unit,
metering_charge_pence_per_month: form.metering_charge_pence_per_month,
other_charges_pence_per_month: form.other_charges_pence_per_month,
vat_rate_pct: form.vat_rate_pct, is_time_of_use: form.is_time_of_use,
fallback_split_pct, windows, fallback_split_pct, windows,
} }
if (form.id) { if (form.id) {
@ -150,7 +159,7 @@ export default function Tariffs() {
<div className="table-wrap"> <div className="table-wrap">
<table className="data"> <table className="data">
<thead> <thead>
<tr><th>Name</th><th>Category</th><th>Supplier</th><th>From</th><th>To</th><th>Type</th><th className="num">Standing/day</th><th></th></tr> <tr><th>Name</th><th>Category</th><th>Supplier</th><th>From</th><th>To</th><th>Type</th><th className="num">Standing/day</th><th className="num">Fixed/month</th><th></th></tr>
</thead> </thead>
<tbody> <tbody>
{tariffs.map(t => ( {tariffs.map(t => (
@ -162,6 +171,7 @@ export default function Tariffs() {
<td>{t.effective_to ? new Date(t.effective_to).toLocaleDateString('en-GB') : <span className="badge badge-outline">open</span>}</td> <td>{t.effective_to ? new Date(t.effective_to).toLocaleDateString('en-GB') : <span className="badge badge-outline">open</span>}</td>
<td>{t.is_time_of_use ? <span className="badge badge-tou">TOU</span> : 'Standard'}</td> <td>{t.is_time_of_use ? <span className="badge badge-tou">TOU</span> : 'Standard'}</td>
<td className="num">{formatMoney(Number(t.standing_charge_pence_per_day))}</td> <td className="num">{formatMoney(Number(t.standing_charge_pence_per_day))}</td>
<td className="num">{formatMoney(Number(t.metering_charge_pence_per_month) + Number(t.other_charges_pence_per_month))}</td>
<td>{t.window_count} window{t.window_count === 1 ? '' : 's'}</td> <td>{t.window_count} window{t.window_count === 1 ? '' : 's'}</td>
</tr> </tr>
))} ))}
@ -227,6 +237,29 @@ export default function Tariffs() {
Climate Change Levy exempt Climate Change Levy exempt
</label> </label>
<div className="field-row">
<div className="field">
<label>Nuclear RAB levy (pence/unit)</label>
<input type="number" step="0.0001" value={form.rab_levy_rate_pence_per_unit}
onChange={e => setForm({ ...form, rab_levy_rate_pence_per_unit: e.target.value === '' ? '' : parseFloat(e.target.value) })} />
</div>
<div className="field">
<label>Metering charge (£/month)</label>
<input type="number" step="0.01" value={form.metering_charge_pence_per_month / 100}
onChange={e => setForm({ ...form, metering_charge_pence_per_month: (parseFloat(e.target.value) || 0) * 100 })} />
</div>
<div className="field">
<label>Other network charges (£/month)</label>
<input type="number" step="0.01" value={form.other_charges_pence_per_month / 100}
onChange={e => setForm({ ...form, other_charges_pence_per_month: (parseFloat(e.target.value) || 0) * 100 })} />
</div>
</div>
<div className="muted" style={{ marginTop: -8, marginBottom: 14, fontSize: '0.85em' }}>
"Other" is a flat catch-all for DUoS availability/excess/reactive charges these bill against contracted
kVA capacity and kVArh reactive energy from the supplier's own meter, not anything read on-site, so
update it from each bill rather than trying to compute it.
</div>
<label className="field-check" style={{ marginBottom: 14 }}> <label className="field-check" style={{ marginBottom: 14 }}>
<input type="checkbox" checked={form.is_time_of_use} onChange={e => toggleTou(e.target.checked)} /> <input type="checkbox" checked={form.is_time_of_use} onChange={e => toggleTou(e.target.checked)} />
Time-of-use tariff (split cost across rate windows using a fallback %) Time-of-use tariff (split cost across rate windows using a fallback %)

View file

@ -95,6 +95,9 @@ export interface Tariff {
standing_charge_pence_per_day: number standing_charge_pence_per_day: number
ccl_rate_pence_per_unit: number | null ccl_rate_pence_per_unit: number | null
ccl_exempt: boolean ccl_exempt: boolean
rab_levy_rate_pence_per_unit: number | null
metering_charge_pence_per_month: number
other_charges_pence_per_month: number
vat_rate_pct: number vat_rate_pct: number
is_time_of_use: boolean is_time_of_use: boolean
fallback_split_pct: Record<string, number> fallback_split_pct: Record<string, number>
@ -128,6 +131,9 @@ export interface CostSegment {
usage_cost_pence: number usage_cost_pence: number
standing_cost_pence: number standing_cost_pence: number
ccl_cost_pence: number ccl_cost_pence: number
rab_levy_cost_pence: number
metering_cost_pence: number
other_charges_cost_pence: number
vat_pence: number vat_pence: number
total_pence: number total_pence: number
} }
@ -136,6 +142,9 @@ export interface CostBreakdown {
usage_cost_pence: number usage_cost_pence: number
standing_cost_pence: number standing_cost_pence: number
ccl_cost_pence: number ccl_cost_pence: number
rab_levy_cost_pence: number
metering_cost_pence: number
other_charges_cost_pence: number
subtotal_pence: number subtotal_pence: number
vat_pence: number vat_pence: number
total_pence: number total_pence: number
@ -164,6 +173,9 @@ export interface ConsumptionCostReport {
usage_cost_pence: number usage_cost_pence: number
standing_cost_pence: number standing_cost_pence: number
ccl_cost_pence: number ccl_cost_pence: number
rab_levy_cost_pence: number
metering_cost_pence: number
other_charges_cost_pence: number
vat_pence: number vat_pence: number
total_pence: number total_pence: number
} }
@ -214,6 +226,9 @@ export interface EstimateReport {
usage_cost_pence: number usage_cost_pence: number
standing_cost_pence: number standing_cost_pence: number
ccl_cost_pence: number ccl_cost_pence: number
rab_levy_cost_pence: number
metering_cost_pence: number
other_charges_cost_pence: number
vat_pence: number vat_pence: number
} }
} }