diff --git a/backend/src/lib/cost-calc.js b/backend/src/lib/cost-calc.js index 2731509..6e110f7 100644 --- a/backend/src/lib/cost-calc.js +++ b/backend/src/lib/cost-calc.js @@ -162,7 +162,7 @@ export function computeCost({ tariff, windows, consumption, daysInPeriod }) { if (tariff.is_time_of_use && windows.length > 1 && tariff.fallback_split_pct && Object.keys(tariff.fallback_split_pct).length) { split = {} for (const [key, pct] of Object.entries(tariff.fallback_split_pct)) { - const win = windows.find(w => w.label.toLowerCase().includes(key.toLowerCase())) + const win = windows.find(w => w.label.toLowerCase() === key.toLowerCase()) if (!win) continue const share = consumption * (Number(pct) / 100) const cost = share * Number(win.unit_rate_pence_per_unit) diff --git a/backend/src/routes/tariffs.js b/backend/src/routes/tariffs.js index 3c9285e..c3d919b 100644 --- a/backend/src/routes/tariffs.js +++ b/backend/src/routes/tariffs.js @@ -1,6 +1,16 @@ import { requireAuth, requireCap } from '../auth.js' import { pool } from '../db.js' +// Mirrors computeCost()'s condition in cost-calc.js — the split is only actually +// applied (and only actually needs to sum to 100) once there's more than one +// window to divide consumption across. +function splitPctError(isTimeOfUse, fallbackSplitPct, windowCount) { + if (!isTimeOfUse || windowCount <= 1 || !fallbackSplitPct || !Object.keys(fallbackSplitPct).length) return null + const total = Object.values(fallbackSplitPct).reduce((s, v) => s + Number(v), 0) + if (Math.round(total) !== 100) return `Fallback split must total 100% (currently ${total}%)` + return null +} + export async function tariffRoutes(app) { app.addHook('preHandler', requireAuth) @@ -45,6 +55,8 @@ export async function tariffRoutes(app) { if (!Array.isArray(b.windows) || b.windows.length === 0) { return reply.status(400).send({ error: 'At least one rate window is required' }) } + const splitErr = splitPctError(b.is_time_of_use === true, b.fallback_split_pct, b.windows.length) + if (splitErr) return reply.status(400).send({ error: splitErr }) const client = await pool.connect() try { @@ -84,6 +96,19 @@ export async function tariffRoutes(app) { if (!existing.length) return reply.status(404).send({ error: 'Tariff not found' }) const t = existing[0] const b = req.body || {} + + const isTimeOfUse = b.is_time_of_use ?? t.is_time_of_use + const fallbackSplitPct = b.fallback_split_pct ?? t.fallback_split_pct + let windowCount + if (Array.isArray(b.windows)) { + windowCount = b.windows.length + } else { + const { rows: wc } = await pool.query('SELECT COUNT(*)::int AS n FROM tariff_rate_windows WHERE tariff_id = $1', [req.params.id]) + windowCount = wc[0].n + } + const splitErr = splitPctError(isTimeOfUse, fallbackSplitPct, windowCount) + if (splitErr) return reply.status(400).send({ error: splitErr }) + const { rows } = await pool.query( `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, diff --git a/frontend/src/pages/Tariffs.tsx b/frontend/src/pages/Tariffs.tsx index a4c9a62..da803d9 100644 --- a/frontend/src/pages/Tariffs.tsx +++ b/frontend/src/pages/Tariffs.tsx @@ -93,6 +93,10 @@ export default function Tariffs() { async function save() { if (!form) return if (!form.name.trim() || !form.category_id) { setError('Name and category are required'); return } + if (form.is_time_of_use && form.windows.length > 1) { + const total = form.windows.reduce((s, w) => s + (Number(w.split_pct) || 0), 0) + if (Math.round(total) !== 100) { setError(`Fallback split must total 100% (currently ${total}%)`); return } + } setSaving(true) setError(null) try { @@ -271,7 +275,13 @@ export default function Tariffs() {
- +
diff --git a/seed-app.js b/seed-app.js index b7938c4..c467dbf 100644 --- a/seed-app.js +++ b/seed-app.js @@ -10,7 +10,7 @@ const pool = new Pool({ connectionString: process.env.DATABASE_URL }) await pool.query(` INSERT INTO apps (slug, name, description, base_path, icon, theme_color, category, internal_host, internal_port) - VALUES ('utilities', 'Utilities', 'Meter readings, tariffs and energy cost tracking', '/utilities', 'Zap', '#1e6091', 'Operations', '10.10.10.127', 3080) + VALUES ('utilities', 'Utilities', 'Meter readings, tariffs and energy cost tracking', '/utilities', 'Zap', '#1e6091', 'Hotel', '10.10.10.127', 3080) ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description,