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:
parent
f1ce0f06d8
commit
3b261a4d8d
10 changed files with 158 additions and 15 deletions
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 %)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue