utilities/backend/src/routes/tariffs.js
jtricerolph 4fc5230d79 Initial commit — utilities app (meter readings, tariffs, cost tracking)
Fastify + pg backend, React/TS/Vite frontend. Categories (electric,
gas, oil, water), meters with sub-metering rollup, tariffs with
time-of-use rate windows, standing charges, Climate Change Levy and
VAT, manual reading entry, consumption/cost reports, period cost
estimates, and an API-key-gated /api/internal/* surface for the
reports app's Directors Report.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 17:33:42 +00:00

175 lines
7.4 KiB
JavaScript

import { requireAuth, requireCap } from '../auth.js'
import { pool } from '../db.js'
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 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 { 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()
}
})
}