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) // GET /api/tariffs — reference data needed by meters/readings/reports pages too app.get('/api/tariffs', async (req) => { const { category_id } = req.query const params = [] let where = '' if (category_id) { params.push(category_id); where = 'WHERE t.category_id = $1' } const { rows } = await pool.query( `SELECT t.*, c.name AS category_name, (SELECT COUNT(*)::int FROM tariff_rate_windows w WHERE w.tariff_id = t.id) AS window_count FROM tariffs t JOIN meter_categories c ON c.id = t.category_id ${where} ORDER BY t.effective_from DESC`, params ) return rows }) app.get('/api/tariffs/:id', async (req, reply) => { const { rows } = await pool.query( `SELECT t.*, c.name AS category_name FROM tariffs t JOIN meter_categories c ON c.id = t.category_id WHERE t.id = $1`, [req.params.id] ) if (!rows.length) return reply.status(404).send({ error: 'Tariff not found' }) const { rows: windows } = await pool.query( 'SELECT * FROM tariff_rate_windows WHERE tariff_id = $1 ORDER BY sort_order, id', [req.params.id] ) return { ...rows[0], windows } }) // POST /api/tariffs — create tariff + its rate windows in one call. // Non-TOU tariff: send a single window (label 'Standard') covering all hours. app.post('/api/tariffs', { preHandler: requireCap('tariffs') }, async (req, reply) => { const b = req.body || {} if (!b.category_id || !b.name || !b.effective_from) { return reply.status(400).send({ error: 'category_id, name and effective_from required' }) } 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 { await client.query('BEGIN') const { rows } = await client.query( `INSERT INTO tariffs (category_id, name, supplier, effective_from, effective_to, standing_charge_pence_per_day, ccl_rate_pence_per_unit, ccl_exempt, vat_rate_pct, is_time_of_use, fallback_split_pct) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING *`, [ 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.vat_rate_pct ?? 20, b.is_time_of_use === true, JSON.stringify(b.fallback_split_pct || {}), ] ) const tariff = rows[0] for (const [i, w] of b.windows.entries()) { await client.query( `INSERT INTO tariff_rate_windows (tariff_id, label, start_time, end_time, days_of_week, unit_rate_pence_per_unit, sort_order) VALUES ($1,$2,$3,$4,$5,$6,$7)`, [tariff.id, w.label, w.start_time || null, w.end_time || null, w.days_of_week || null, w.unit_rate_pence_per_unit, w.sort_order ?? i] ) } await client.query('COMMIT') return tariff } catch (err) { await client.query('ROLLBACK') throw err } finally { client.release() } }) app.patch('/api/tariffs/:id', { preHandler: requireCap('tariffs') }, async (req, reply) => { const { rows: existing } = await pool.query('SELECT * FROM tariffs WHERE id = $1', [req.params.id]) 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, vat_rate_pct = $8, is_time_of_use = $9, fallback_split_pct = $10 WHERE id = $11 RETURNING *`, [ b.name ?? t.name, b.supplier !== undefined ? b.supplier : t.supplier, b.effective_from ?? t.effective_from, b.effective_to !== undefined ? b.effective_to : t.effective_to, b.standing_charge_pence_per_day ?? t.standing_charge_pence_per_day, b.ccl_rate_pence_per_unit !== undefined ? b.ccl_rate_pence_per_unit : t.ccl_rate_pence_per_unit, b.ccl_exempt ?? t.ccl_exempt, b.vat_rate_pct ?? t.vat_rate_pct, b.is_time_of_use ?? t.is_time_of_use, JSON.stringify(b.fallback_split_pct ?? t.fallback_split_pct), req.params.id, ] ) return rows[0] }) // PUT /api/tariffs/:id/windows — replace the whole rate-window set (simpler // than per-window CRUD for a "rate window editor" saved as one form) app.put('/api/tariffs/:id/windows', { preHandler: requireCap('tariffs') }, async (req, reply) => { const { rows: existing } = await pool.query('SELECT id FROM tariffs WHERE id = $1', [req.params.id]) if (!existing.length) return reply.status(404).send({ error: 'Tariff not found' }) const windows = req.body?.windows if (!Array.isArray(windows) || windows.length === 0) { return reply.status(400).send({ error: 'At least one rate window is required' }) } const client = await pool.connect() try { await client.query('BEGIN') await client.query('DELETE FROM tariff_rate_windows WHERE tariff_id = $1', [req.params.id]) for (const [i, w] of windows.entries()) { await client.query( `INSERT INTO tariff_rate_windows (tariff_id, label, start_time, end_time, days_of_week, unit_rate_pence_per_unit, sort_order) VALUES ($1,$2,$3,$4,$5,$6,$7)`, [req.params.id, w.label, w.start_time || null, w.end_time || null, w.days_of_week || null, w.unit_rate_pence_per_unit, w.sort_order ?? i] ) } await client.query('COMMIT') } catch (err) { await client.query('ROLLBACK') throw err } finally { client.release() } const { rows } = await pool.query( 'SELECT * FROM tariff_rate_windows WHERE tariff_id = $1 ORDER BY sort_order, id', [req.params.id] ) return rows }) // POST /api/meters/:id/assign-tariff — close the current open meter_tariffs // row (if any) and open a new one from effective_from app.post('/api/meters/:id/assign-tariff', { preHandler: requireCap('tariffs') }, async (req, reply) => { const { tariff_id, effective_from } = req.body || {} if (!tariff_id || !effective_from) return reply.status(400).send({ error: 'tariff_id and effective_from required' }) const { rows: meter } = await pool.query('SELECT id FROM meters WHERE id = $1', [req.params.id]) if (!meter.length) return reply.status(404).send({ error: 'Meter not found' }) const client = await pool.connect() try { await client.query('BEGIN') await client.query( `UPDATE meter_tariffs SET effective_to = ($1::date - INTERVAL '1 day') WHERE meter_id = $2 AND effective_to IS NULL`, [effective_from, req.params.id] ) const { rows } = await client.query( `INSERT INTO meter_tariffs (meter_id, tariff_id, effective_from) VALUES ($1,$2,$3) RETURNING *`, [req.params.id, tariff_id, effective_from] ) await client.query('COMMIT') return rows[0] } catch (err) { await client.query('ROLLBACK') throw err } finally { client.release() } }) }