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>
This commit is contained in:
jtricerolph 2026-07-26 17:33:42 +00:00
commit 4fc5230d79
44 changed files with 10249 additions and 0 deletions

8
backend/Dockerfile Normal file
View file

@ -0,0 +1,8 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json .
RUN npm install --omit=dev
COPY src ./src
RUN mkdir -p /app/uploads
EXPOSE 3001
CMD ["node", "src/index.js"]

18
backend/package.json Normal file
View file

@ -0,0 +1,18 @@
{
"name": "hnf-utilities-backend",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
},
"dependencies": {
"@fastify/cookie": "^9.4.0",
"@fastify/cors": "^9.0.1",
"@fastify/multipart": "^8.3.0",
"@fastify/static": "^7.0.4",
"fastify": "^4.28.1",
"jose": "^5.9.6",
"pg": "^8.13.1"
}
}

57
backend/src/auth.js Normal file
View file

@ -0,0 +1,57 @@
import { jwtVerify } from 'jose'
import { isOnsite } from './ip-check.js'
const APP_SLUG = process.env.APP_SLUG || 'utilities'
const secret = new TextEncoder().encode(process.env.CENTRAL_AUTH_SECRET || '')
export async function requireAuth(request, reply) {
const token = request.cookies?.hnf_session
if (!token) return reply.status(401).send({ error: 'Not authenticated' })
let payload
try {
const { payload: p } = await jwtVerify(token, secret)
payload = p
} catch {
return reply.status(401).send({ error: 'Invalid session' })
}
if (!payload.apps?.includes(APP_SLUG)) {
return reply.status(403).send({ error: 'No permission for this app' })
}
if (!payload.offsite_allowed) {
const clientIP = request.headers['x-real-ip'] || request.ip
if (!(await isOnsite(clientIP))) {
return reply.status(403).send({ error: 'Access restricted to site network' })
}
}
const prefix = `${APP_SLUG}:`
let caps
if (Array.isArray(payload.caps)) {
caps = payload.caps.filter(c => c.startsWith(prefix)).map(c => c.slice(prefix.length))
} else {
// Legacy token — grant all non-settings caps until re-login
caps = ['readings', 'meters', 'tariffs', 'reports', 'estimates']
}
request.user = {
email: payload.sub,
name: payload.name,
is_admin: payload.is_admin ?? false,
caps,
}
}
export function hasCap(request, cap) {
return request.user?.is_admin === true || request.user?.caps?.includes(cap) === true
}
export function requireCap(cap) {
return async (request, reply) => {
if (!hasCap(request, cap)) {
return reply.status(403).send({ error: `Missing capability: ${cap}` })
}
}
}

142
backend/src/db.js Normal file
View file

@ -0,0 +1,142 @@
import pg from 'pg'
const { Pool } = pg
export const pool = new Pool({ connectionString: process.env.DATABASE_URL })
export async function initDb() {
await pool.query(`
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- electric / gas / oil / water extensible, seeded below
CREATE TABLE IF NOT EXISTS meter_categories (
id SERIAL PRIMARY KEY,
key TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
unit_label TEXT NOT NULL,
icon TEXT NOT NULL DEFAULT 'Zap',
sort_order INT NOT NULL DEFAULT 0,
active BOOLEAN NOT NULL DEFAULT TRUE,
estimate_trailing_days INT, -- per-category override of the global trailing-avg window
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- parent_meter_id enables sub-metering rollups (children sum vs parent reading)
CREATE TABLE IF NOT EXISTS meters (
id SERIAL PRIMARY KEY,
category_id INT NOT NULL REFERENCES meter_categories(id),
parent_meter_id INT REFERENCES meters(id) ON DELETE SET NULL,
name TEXT NOT NULL,
location TEXT,
serial_number TEXT,
image_path TEXT,
install_date DATE,
active BOOLEAN NOT NULL DEFAULT TRUE,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
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);
-- 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)
CREATE TABLE IF NOT EXISTS tariffs (
id SERIAL PRIMARY KEY,
category_id INT NOT NULL REFERENCES meter_categories(id),
name TEXT NOT NULL,
supplier TEXT,
effective_from DATE NOT NULL,
effective_to DATE,
standing_charge_pence_per_day NUMERIC(10,4) NOT NULL DEFAULT 0,
ccl_rate_pence_per_unit NUMERIC(10,4),
ccl_exempt BOOLEAN NOT NULL DEFAULT FALSE,
vat_rate_pct NUMERIC(5,2) NOT NULL DEFAULT 20,
is_time_of_use BOOLEAN NOT NULL DEFAULT FALSE,
fallback_split_pct JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS tariffs_category_idx ON tariffs (category_id);
-- non-TOU tariff = single window covering all days/hours
CREATE TABLE IF NOT EXISTS tariff_rate_windows (
id SERIAL PRIMARY KEY,
tariff_id INT NOT NULL REFERENCES tariffs(id) ON DELETE CASCADE,
label TEXT NOT NULL,
start_time TIME,
end_time TIME,
days_of_week INT[],
unit_rate_pence_per_unit NUMERIC(10,4) NOT NULL,
sort_order INT NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS rate_windows_tariff_idx ON tariff_rate_windows (tariff_id);
-- history of which tariff applied to a meter over time; one open row
-- (effective_to IS NULL) per meter at a time
CREATE TABLE IF NOT EXISTS meter_tariffs (
id SERIAL PRIMARY KEY,
meter_id INT NOT NULL REFERENCES meters(id) ON DELETE CASCADE,
tariff_id INT NOT NULL REFERENCES tariffs(id),
effective_from DATE NOT NULL,
effective_to DATE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS meter_tariffs_meter_idx ON meter_tariffs (meter_id);
-- one cumulative manual reading per meter (source='manual' in v1). Phase 2
-- ESPHome integration adds source='device' + interval_start/interval_end
-- deliberately left out of this migration until that integration is built.
CREATE TABLE IF NOT EXISTS readings (
id SERIAL PRIMARY KEY,
meter_id INT NOT NULL REFERENCES meters(id) ON DELETE CASCADE,
reading_value NUMERIC(14,3) NOT NULL,
reading_date DATE NOT NULL,
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
source TEXT NOT NULL DEFAULT 'manual',
recorded_by TEXT,
photo_path TEXT,
notes TEXT
);
CREATE INDEX IF NOT EXISTS readings_meter_date_idx ON readings (meter_id, reading_date DESC);
`)
await seedDefaults()
}
async function seedDefaults() {
const categories = [
{ key: 'electric', name: 'Electric', unit_label: 'kWh', icon: 'Zap', sort: 1 },
{ key: 'gas', name: 'Gas', unit_label: 'kWh', icon: 'Flame', sort: 2 },
{ key: 'oil', name: 'Oil', unit_label: 'L', icon: 'Droplet', sort: 3 },
{ key: 'water', name: 'Water', unit_label: 'm3', icon: 'Waves', sort: 4 },
]
for (const c of categories) {
await pool.query(
`INSERT INTO meter_categories (key, name, unit_label, icon, sort_order)
VALUES ($1, $2, $3, $4, $5) ON CONFLICT (key) DO NOTHING`,
[c.key, c.name, c.unit_label, c.icon, c.sort]
)
}
const defaults = {
estimate_trailing_days: 30, // global default trailing-average window (days), 7 | 14 | 30
}
for (const [key, value] of Object.entries(defaults)) {
await pool.query(
`INSERT INTO config (key, value) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING`,
[key, JSON.stringify(value)]
)
}
}
export async function getConfig() {
const { rows } = await pool.query('SELECT key, value FROM config')
return Object.fromEntries(rows.map(r => [r.key, r.value]))
}

53
backend/src/index.js Normal file
View file

@ -0,0 +1,53 @@
import Fastify from 'fastify'
import cookie from '@fastify/cookie'
import cors from '@fastify/cors'
import multipart from '@fastify/multipart'
import staticFiles from '@fastify/static'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
import { initDb } from './db.js'
import { categoryRoutes } from './routes/categories.js'
import { meterRoutes } from './routes/meters.js'
import { tariffRoutes } from './routes/tariffs.js'
import { readingRoutes } from './routes/readings.js'
import { reportRoutes } from './routes/reports.js'
import { estimateRoutes } from './routes/estimates.js'
import { internalRoutes } from './routes/internal.js'
import { settingsRoutes } from './routes/settings.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
const UPLOADS_DIR = join(__dirname, '..', 'uploads')
const app = Fastify({ logger: true, trustProxy: true })
const startedAt = Date.now()
await app.register(cookie)
await app.register(cors, {
origin: process.env.CORS_ORIGIN || false,
credentials: true,
})
await app.register(multipart, { limits: { fileSize: 10 * 1024 * 1024 } })
await app.register(staticFiles, {
root: UPLOADS_DIR,
prefix: '/api/uploads/',
decorateReply: false,
})
app.get('/health', async () => ({ status: 'healthy', version: process.env.BUILD_VERSION || String(startedAt) }))
await app.register(categoryRoutes)
await app.register(meterRoutes, { uploadsDir: UPLOADS_DIR })
await app.register(tariffRoutes)
await app.register(readingRoutes, { uploadsDir: UPLOADS_DIR })
await app.register(reportRoutes)
await app.register(estimateRoutes)
await app.register(internalRoutes)
await app.register(settingsRoutes)
try {
await initDb()
await app.listen({ port: 3001, host: '0.0.0.0' })
} catch (err) {
app.log.error(err)
process.exit(1)
}

80
backend/src/ip-check.js Normal file
View file

@ -0,0 +1,80 @@
import dns from 'dns/promises'
const raw = (process.env.OFFICE_IP_CHECK || 'disabled').trim()
const matchers = raw.split(',').map(s => s.trim()).filter(Boolean)
const TTL = 5 * 60 * 1000
const cache = new Map()
const PUBLIC_IP_URLS = [
'https://api.ipify.org',
'https://ifconfig.co/ip',
'https://icanhazip.com',
]
function normalizeIP(ip) {
return ip?.startsWith('::ffff:') ? ip.slice(7) : ip
}
function isIPv4(s) {
return /^\d{1,3}(\.\d{1,3}){3}$/.test(s)
}
function ipInCidr(ip, cidr) {
const [range, bits] = cidr.split('/')
if (!isIPv4(ip) || !isIPv4(range)) return false
const mask = ~(2 ** (32 - parseInt(bits)) - 1) >>> 0
const toInt = s => s.split('.').reduce((a, o) => (a << 8) + parseInt(o), 0) >>> 0
return (toInt(ip) & mask) === (toInt(range) & mask)
}
async function fetchPublicIP() {
for (const url of PUBLIC_IP_URLS) {
try {
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), 4000)
const res = await fetch(url, { signal: ctrl.signal })
clearTimeout(timer)
if (!res.ok) continue
const ip = (await res.text()).trim()
if (isIPv4(ip)) return ip
} catch {
// try next
}
}
return null
}
async function resolveDynamic(key, resolver) {
const hit = cache.get(key)
if (hit && Date.now() < hit.expiry) return hit.ip
const ip = await resolver()
if (ip) {
cache.set(key, { ip, expiry: Date.now() + TTL })
return ip
}
return hit ? hit.ip : null
}
export async function isOnsite(requestIP) {
if (matchers.length === 0 || matchers.includes('disabled')) return true
const ip = normalizeIP(requestIP)
if (!ip) return false
for (const m of matchers) {
if (m === 'auto') {
const pub = await resolveDynamic('auto', fetchPublicIP)
if (pub && ip === pub) return true
} else if (m.includes('/')) {
if (ipInCidr(ip, m)) return true
} else if (/[a-zA-Z]/.test(m)) {
const resolved = await resolveDynamic(m, async () => {
try { return (await dns.resolve4(m))[0] } catch { return null }
})
if (resolved && ip === resolved) return true
} else {
if (ip === m) return true
}
}
return false
}

View file

@ -0,0 +1,196 @@
// Shared cost-calculation helpers — single source of truth used by
// routes/reports.js, routes/estimates.js and routes/internal.js so the
// Directors' report and the in-app reports/estimates never disagree.
//
// All money values are handled in PENCE internally and only converted to
// pounds at the API boundary (frontend divides by 100 for display).
import { pool } from '../db.js'
export function daysBetween(a, b) {
return Math.round((new Date(b) - new Date(a)) / 86400000)
}
export function daysInclusive(start, end) {
return daysBetween(start, end) + 1
}
// Find the reading on/before `date` (closest before-or-on), and the reading
// on/after `date` (closest on-or-after) — used to bracket a period boundary
// when there isn't a reading taken on the exact day.
async function readingOnOrBefore(meterId, date) {
const { rows } = await pool.query(
`SELECT reading_value, reading_date FROM readings
WHERE meter_id = $1 AND reading_date <= $2
ORDER BY reading_date DESC, id DESC LIMIT 1`,
[meterId, date]
)
return rows[0] || null
}
async function readingOnOrAfter(meterId, date) {
const { rows } = await pool.query(
`SELECT reading_value, reading_date FROM readings
WHERE meter_id = $1 AND reading_date >= $2
ORDER BY reading_date ASC, id ASC LIMIT 1`,
[meterId, date]
)
return rows[0] || null
}
async function latestReading(meterId) {
const { rows } = await pool.query(
`SELECT reading_value, reading_date FROM readings
WHERE meter_id = $1 ORDER BY reading_date DESC, id DESC LIMIT 1`,
[meterId]
)
return rows[0] || null
}
// Consumption for a meter over [periodStart, periodEnd] (inclusive), bracketing
// the boundaries with the nearest available readings — meters get one manual
// cumulative reading, not necessarily one exactly on the period edge.
export async function getPeriodConsumption(meterId, periodStart, periodEnd) {
const first = (await readingOnOrAfter(meterId, periodStart)) || (await readingOnOrBefore(meterId, periodStart))
const last = await readingOnOrBefore(meterId, periodEnd)
if (!first || !last || last.reading_date <= first.reading_date) {
return { consumption: null, first, last, has_data: false }
}
const consumption = Number(last.reading_value) - Number(first.reading_value)
return { consumption, first, last, has_data: true }
}
// Trailing average daily consumption as of the meter's latest reading,
// looking back `windowDays` — used by the estimates page to project the
// remainder of the current period. Mirrors the "average of recent actuals"
// approach the Directors report uses for dry/wet forecasts.
export async function getTrailingDailyRate(meterId, windowDays) {
const latest = await latestReading(meterId)
if (!latest) return { daily_rate: null, latest }
const windowStart = new Date(latest.reading_date)
windowStart.setDate(windowStart.getDate() - windowDays)
const windowStartStr = windowStart.toISOString().slice(0, 10)
const before = await readingOnOrBefore(meterId, windowStartStr)
if (!before || before.reading_date >= latest.reading_date) {
return { daily_rate: null, latest }
}
const days = daysBetween(before.reading_date, latest.reading_date)
if (days <= 0) return { daily_rate: null, latest }
const consumption = Number(latest.reading_value) - Number(before.reading_value)
return { daily_rate: consumption / days, latest, window_start_reading: before }
}
// The tariff in force for a meter on a given date (falls back to the meter's
// currently-open tariff if `date` is in the future / no historical row matches).
export async function getTariffForMeter(meterId, date) {
const { rows } = await pool.query(
`SELECT t.* FROM meter_tariffs mt
JOIN tariffs t ON t.id = mt.tariff_id
WHERE mt.meter_id = $1
AND mt.effective_from <= $2
AND (mt.effective_to IS NULL OR mt.effective_to >= $2)
ORDER BY mt.effective_from DESC LIMIT 1`,
[meterId, date]
)
if (rows.length) return rows[0]
const { rows: open } = await pool.query(
`SELECT t.* FROM meter_tariffs mt
JOIN tariffs t ON t.id = mt.tariff_id
WHERE mt.meter_id = $1 AND mt.effective_to IS NULL
ORDER BY mt.effective_from DESC LIMIT 1`,
[meterId]
)
return open[0] || null
}
export async function getRateWindows(tariffId) {
const { rows } = await pool.query(
'SELECT * FROM tariff_rate_windows WHERE tariff_id = $1 ORDER BY sort_order, id',
[tariffId]
)
return rows
}
// Core layered calc: usage (split by fallback % across TOU windows, or single
// rate) + standing charge + CCL (electric/gas only, skipped if exempt) + VAT.
// Mirrors the usage + fixed costs + levy -> total layering already used by
// the Directors report's dry/wet forecast.
export function computeCost({ tariff, windows, consumption, daysInPeriod }) {
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 }
}
let usageCostPence = 0
let split = null
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()))
if (!win) continue
const share = consumption * (Number(pct) / 100)
const cost = share * Number(win.unit_rate_pence_per_unit)
split[win.label] = { share, rate: Number(win.unit_rate_pence_per_unit), cost_pence: cost }
usageCostPence += cost
}
} else {
const rate = windows.length ? Number(windows[0].unit_rate_pence_per_unit) : 0
usageCostPence = consumption * rate
}
const standingCostPence = Number(tariff.standing_charge_pence_per_day) * daysInPeriod
const cclCostPence = (!tariff.ccl_exempt && tariff.ccl_rate_pence_per_unit)
? consumption * Number(tariff.ccl_rate_pence_per_unit)
: 0
const subtotalPence = usageCostPence + standingCostPence + cclCostPence
const vatPence = subtotalPence * (Number(tariff.vat_rate_pct) / 100)
const totalPence = subtotalPence + vatPence
return {
usage_cost_pence: usageCostPence,
standing_cost_pence: standingCostPence,
ccl_cost_pence: cclCostPence,
subtotal_pence: subtotalPence,
vat_pence: vatPence,
total_pence: totalPence,
split,
}
}
// Full cost breakdown for one meter over a period — fetches tariff + windows
// + consumption and runs computeCost. `asOfDate` picks which historical
// tariff applies (defaults to periodEnd).
export async function getMeterCostForPeriod(meterId, periodStart, periodEnd, asOfDate) {
const { consumption, first, last, has_data } = await getPeriodConsumption(meterId, periodStart, periodEnd)
const tariff = await getTariffForMeter(meterId, asOfDate || periodEnd)
const windows = tariff ? await getRateWindows(tariff.id) : []
const daysInPeriod = daysInclusive(periodStart, periodEnd)
const cost = computeCost({ tariff, windows, consumption, daysInPeriod })
return { consumption, has_data, first_reading: first, last_reading: last, tariff, windows, days_in_period: daysInPeriod, ...cost }
}
// [periodStart, periodEnd] for a YYYY-MM period string, or the current
// (open) calendar month when period === 'current'.
export function resolvePeriod(period) {
const now = new Date()
let year, month
if (!period || period === 'current') {
year = now.getFullYear()
month = now.getMonth() + 1
} else {
const [y, m] = period.split('-').map(Number)
year = y
month = m
}
const start = `${year}-${String(month).padStart(2, '0')}-01`
const endDate = new Date(year, month, 0) // last day of month
const end = endDate.toISOString().slice(0, 10)
const isCurrent = year === now.getFullYear() && month === now.getMonth() + 1
return { year, month, start, end, isCurrent }
}

View file

@ -0,0 +1,48 @@
import { requireAuth, requireCap } from '../auth.js'
import { pool } from '../db.js'
export async function categoryRoutes(app) {
app.addHook('preHandler', requireAuth)
// GET /api/categories — reference data needed by most pages, no extra cap
app.get('/api/categories', async () => {
const { rows } = await pool.query('SELECT * FROM meter_categories ORDER BY sort_order, name')
return rows
})
app.post('/api/categories', { preHandler: requireCap('meters') }, async (req, reply) => {
const b = req.body || {}
if (!b.key || !b.name || !b.unit_label) return reply.status(400).send({ error: 'key, name and unit_label required' })
try {
const { rows } = await pool.query(
`INSERT INTO meter_categories (key, name, unit_label, icon, sort_order)
VALUES ($1,$2,$3,$4,$5) RETURNING *`,
[b.key, b.name, b.unit_label, b.icon || 'Zap', b.sort_order || 0]
)
return rows[0]
} catch (err) {
if (err.code === '23505') return reply.status(409).send({ error: 'Category key already exists' })
throw err
}
})
app.patch('/api/categories/:id', { preHandler: requireCap('meters') }, async (req, reply) => {
const { rows: existing } = await pool.query('SELECT * FROM meter_categories WHERE id = $1', [req.params.id])
if (!existing.length) return reply.status(404).send({ error: 'Category not found' })
const c = existing[0]
const b = req.body || {}
const { rows } = await pool.query(
`UPDATE meter_categories SET name = $1, unit_label = $2, icon = $3, sort_order = $4, active = $5
WHERE id = $6 RETURNING *`,
[
b.name ?? c.name,
b.unit_label ?? c.unit_label,
b.icon ?? c.icon,
b.sort_order ?? c.sort_order,
b.active ?? c.active,
req.params.id,
]
)
return rows[0]
})
}

View file

@ -0,0 +1,98 @@
import { requireAuth, requireCap } from '../auth.js'
import { pool, getConfig } from '../db.js'
import {
getPeriodConsumption, getTrailingDailyRate, getTariffForMeter, getRateWindows,
computeCost, resolvePeriod, daysInclusive, daysBetween,
} from '../lib/cost-calc.js'
const VALID_WINDOWS = [7, 14, 30]
async function estimateForMeter(meter, periodStart, periodEnd, windowDays) {
const today = new Date().toISOString().slice(0, 10)
const asOfDate = today < periodEnd ? today : periodEnd
const { daily_rate } = await getTrailingDailyRate(meter.id, windowDays)
const { consumption: actualToDate, has_data } = await getPeriodConsumption(meter.id, periodStart, asOfDate)
const daysInPeriod = daysInclusive(periodStart, periodEnd)
const remainingDays = Math.max(daysBetween(asOfDate, periodEnd), 0)
let projectedConsumption = null
if (daily_rate != null) {
const base = has_data ? actualToDate : 0
projectedConsumption = base + daily_rate * remainingDays
} else if (has_data) {
// No trailing rate available (too few readings) — fall back to actuals only
projectedConsumption = actualToDate
}
const tariff = await getTariffForMeter(meter.id, asOfDate)
const windows = tariff ? await getRateWindows(tariff.id) : []
const cost = computeCost({ tariff, windows, consumption: projectedConsumption, daysInPeriod })
return {
meter_id: meter.id, meter_name: meter.name,
category_id: meter.category_id, category_name: meter.category_name, unit_label: meter.unit_label,
trailing_window_days: windowDays,
daily_rate,
actual_to_date: has_data ? actualToDate : null,
remaining_days: remainingDays,
projected_consumption: projectedConsumption,
...cost,
}
}
export async function estimateRoutes(app) {
app.addHook('preHandler', requireAuth)
app.addHook('preHandler', requireCap('estimates'))
// GET /api/estimates?period=current&category_id= — projected cost for the open period
app.get('/api/estimates', async (req) => {
const { start, end, year, month } = resolvePeriod(req.query.period)
const { category_id } = req.query
const config = await getConfig()
const globalDefault = config.estimate_trailing_days || 30
const conditions = ['m.active = TRUE']
const params = []
if (category_id) { params.push(category_id); conditions.push(`m.category_id = $${params.length}`) }
params.push(globalDefault)
const { rows: meters } = await pool.query(
`SELECT m.id, m.name, m.category_id, c.name AS category_name, c.unit_label,
COALESCE(c.estimate_trailing_days, $${params.length}) AS trailing_days
FROM meters m JOIN meter_categories c ON c.id = m.category_id
WHERE ${conditions.join(' AND ')}`,
params
)
const results = []
for (const m of meters) {
const windowDays = VALID_WINDOWS.includes(m.trailing_days) ? m.trailing_days : globalDefault
results.push(await estimateForMeter(m, start, end, windowDays))
}
const totals = results.reduce((acc, r) => ({
total_pence: acc.total_pence + r.total_pence,
usage_cost_pence: acc.usage_cost_pence + r.usage_cost_pence,
standing_cost_pence: acc.standing_cost_pence + r.standing_cost_pence,
ccl_cost_pence: acc.ccl_cost_pence + r.ccl_cost_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 })
return { period: { year, month, start, end }, global_default_window: globalDefault, meters: results, totals }
})
// PATCH /api/estimates/category/:id — set/clear the per-category trailing-window override
app.patch('/api/estimates/category/:id', async (req, reply) => {
const { estimate_trailing_days } = req.body || {}
if (estimate_trailing_days !== null && !VALID_WINDOWS.includes(Number(estimate_trailing_days))) {
return reply.status(400).send({ error: 'estimate_trailing_days must be 7, 14, 30 or null' })
}
const { rows } = await pool.query(
'UPDATE meter_categories SET estimate_trailing_days = $1 WHERE id = $2 RETURNING *',
[estimate_trailing_days, req.params.id]
)
if (!rows.length) return reply.status(404).send({ error: 'Category not found' })
return rows[0]
})
}

View file

@ -0,0 +1,135 @@
// API-key-gated routes for other stack apps (currently `reports`, for the
// Directors' report) — not JWT/cookie auth. Mirrors the forecasting <-> reports
// internal API pattern (X-API-Key header, static key from env).
import { pool } from '../db.js'
import {
getPeriodConsumption, getTariffForMeter, getRateWindows, computeCost,
getTrailingDailyRate, resolvePeriod, daysInclusive, daysBetween,
} from '../lib/cost-calc.js'
async function requireApiKey(req, reply) {
const key = req.headers['x-api-key']
if (!process.env.UTILITIES_API_KEY || key !== process.env.UTILITIES_API_KEY) {
return reply.status(401).send({ error: 'Invalid or missing API key' })
}
}
async function resolveCategoryFilter(category) {
if (!category) return null
const { rows } = await pool.query(
'SELECT id FROM meter_categories WHERE key = $1 OR id::text = $1',
[category]
)
return rows[0]?.id ?? null
}
export async function internalRoutes(app) {
app.addHook('preHandler', requireApiKey)
// GET /api/internal/readings?period=YYYY-MM&category= — raw reading summary
app.get('/api/internal/readings', async (req) => {
const { start, end } = resolvePeriod(req.query.period)
const categoryId = await resolveCategoryFilter(req.query.category)
const conditions = ['m.active = TRUE']
const params = []
if (categoryId) { params.push(categoryId); conditions.push(`m.category_id = $${params.length}`) }
const { rows: meters } = await pool.query(
`SELECT m.id, m.name, m.category_id, c.key AS category_key, c.name AS category_name, c.unit_label
FROM meters m JOIN meter_categories c ON c.id = m.category_id
WHERE ${conditions.join(' AND ')}
ORDER BY c.sort_order, m.name`,
params
)
const readings = []
for (const m of meters) {
const { consumption, first, last, has_data } = await getPeriodConsumption(m.id, start, end)
readings.push({
meter_id: m.id, meter_name: m.name,
category: m.category_key, category_name: m.category_name, unit_label: m.unit_label,
first_reading: first, last_reading: last, consumption, has_data,
})
}
return { period: { start, end }, readings }
})
// GET /api/internal/costs?period=YYYY-MM — cost breakdown by category
app.get('/api/internal/costs', async (req) => {
const { start, end } = resolvePeriod(req.query.period)
const daysInPeriod = daysInclusive(start, end)
const { rows: categories } = await pool.query('SELECT * FROM meter_categories WHERE active = TRUE ORDER BY sort_order')
const breakdown = []
const totals = { usage_cost_pence: 0, standing_cost_pence: 0, ccl_cost_pence: 0, vat_pence: 0, total_pence: 0, consumption: 0 }
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 catTotals = { usage_cost_pence: 0, standing_cost_pence: 0, ccl_cost_pence: 0, vat_pence: 0, total_pence: 0, consumption: 0 }
for (const m of meters) {
const { consumption, has_data } = await getPeriodConsumption(m.id, start, end)
const tariff = await getTariffForMeter(m.id, end)
const windows = tariff ? await getRateWindows(tariff.id) : []
const cost = computeCost({ tariff, windows, consumption, daysInPeriod })
if (has_data) catTotals.consumption += consumption
catTotals.usage_cost_pence += cost.usage_cost_pence
catTotals.standing_cost_pence += cost.standing_cost_pence
catTotals.ccl_cost_pence += cost.ccl_cost_pence
catTotals.vat_pence += cost.vat_pence
catTotals.total_pence += cost.total_pence
}
breakdown.push({ category: cat.key, category_name: cat.name, unit_label: cat.unit_label, ...catTotals })
for (const k of Object.keys(totals)) totals[k] += catTotals[k]
}
return { period: { start, end, days_in_period: daysInPeriod }, categories: breakdown, totals }
})
// GET /api/internal/estimate?period=current — projected cost for the open period
app.get('/api/internal/estimate', async (req) => {
const { start, end, year, month } = resolvePeriod(req.query.period)
const today = new Date().toISOString().slice(0, 10)
const asOfDate = today < end ? today : end
const daysInPeriod = daysInclusive(start, end)
const remainingDays = Math.max(daysBetween(asOfDate, end), 0)
const { rows: config } = await pool.query("SELECT value FROM config WHERE key = 'estimate_trailing_days'")
const globalDefault = config[0]?.value ?? 30
const { rows: categories } = await pool.query('SELECT * FROM meter_categories WHERE active = TRUE ORDER BY sort_order')
const breakdown = []
const totals = { total_pence: 0, projected_consumption: 0 }
for (const cat of categories) {
const windowDays = cat.estimate_trailing_days || globalDefault
const { rows: meters } = await pool.query('SELECT id FROM meters WHERE category_id = $1 AND active = TRUE', [cat.id])
let catConsumption = 0
let catTotalPence = 0
for (const m of meters) {
const { daily_rate } = await getTrailingDailyRate(m.id, windowDays)
const { consumption: actualToDate, has_data } = await getPeriodConsumption(m.id, start, asOfDate)
let projected = null
if (daily_rate != null) projected = (has_data ? actualToDate : 0) + daily_rate * remainingDays
else if (has_data) projected = actualToDate
const tariff = await getTariffForMeter(m.id, asOfDate)
const windows = tariff ? await getRateWindows(tariff.id) : []
const cost = computeCost({ tariff, windows, consumption: projected, daysInPeriod })
if (projected != null) catConsumption += projected
catTotalPence += cost.total_pence
}
breakdown.push({ category: cat.key, category_name: cat.name, unit_label: cat.unit_label, projected_consumption: catConsumption, total_pence: catTotalPence })
totals.projected_consumption += catConsumption
totals.total_pence += catTotalPence
}
return { period: { year, month, start, end, remaining_days: remainingDays }, categories: breakdown, totals }
})
}

View file

@ -0,0 +1,164 @@
import { requireAuth, requireCap } from '../auth.js'
import { pool } from '../db.js'
import { createWriteStream } from 'fs'
import { mkdir, unlink } from 'fs/promises'
import { randomUUID } from 'crypto'
import { extname, join } from 'path'
const ALLOWED_IMAGES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']
export async function meterRoutes(app, opts) {
const UPLOADS_DIR = opts.uploadsDir
app.addHook('preHandler', requireAuth)
// GET /api/meters — list with category + parent name + latest reading
app.get('/api/meters', async (req) => {
const { category_id, active } = req.query
const conditions = []
const params = []
if (category_id) { params.push(category_id); conditions.push(`m.category_id = $${params.length}`) }
if (active !== undefined) { params.push(active === 'true'); conditions.push(`m.active = $${params.length}`) }
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''
const { rows } = await pool.query(
`SELECT m.*, c.name AS category_name, c.key AS category_key, c.unit_label,
p.name AS parent_name,
lr.reading_value AS latest_reading_value, lr.reading_date AS latest_reading_date
FROM meters m
JOIN meter_categories c ON c.id = m.category_id
LEFT JOIN meters p ON p.id = m.parent_meter_id
LEFT JOIN LATERAL (
SELECT reading_value, reading_date FROM readings
WHERE meter_id = m.id ORDER BY reading_date DESC, id DESC LIMIT 1
) lr ON TRUE
${where}
ORDER BY c.sort_order, m.name`,
params
)
return rows
})
// GET /api/meters/:id — detail: meter + children + tariff history + recent readings
app.get('/api/meters/:id', async (req, reply) => {
const { rows } = await pool.query(
`SELECT m.*, c.name AS category_name, c.key AS category_key, c.unit_label, p.name AS parent_name
FROM meters m
JOIN meter_categories c ON c.id = m.category_id
LEFT JOIN meters p ON p.id = m.parent_meter_id
WHERE m.id = $1`,
[req.params.id]
)
if (!rows.length) return reply.status(404).send({ error: 'Meter not found' })
const { rows: children } = await pool.query(
`SELECT m.id, m.name,
lr.reading_value AS latest_reading_value, lr.reading_date AS latest_reading_date
FROM meters m
LEFT JOIN LATERAL (
SELECT reading_value, reading_date FROM readings
WHERE meter_id = m.id ORDER BY reading_date DESC, id DESC LIMIT 1
) lr ON TRUE
WHERE m.parent_meter_id = $1 ORDER BY m.name`,
[req.params.id]
)
const { rows: tariffHistory } = await pool.query(
`SELECT mt.id, mt.effective_from, mt.effective_to, t.id AS tariff_id, t.name AS tariff_name, t.supplier
FROM meter_tariffs mt JOIN tariffs t ON t.id = mt.tariff_id
WHERE mt.meter_id = $1 ORDER BY mt.effective_from DESC`,
[req.params.id]
)
const { rows: readings } = await pool.query(
`SELECT * FROM readings WHERE meter_id = $1 ORDER BY reading_date DESC, id DESC LIMIT 20`,
[req.params.id]
)
return { ...rows[0], children, tariff_history: tariffHistory, recent_readings: readings }
})
app.post('/api/meters', { preHandler: requireCap('meters') }, async (req, reply) => {
const b = req.body || {}
if (!b.name || !b.category_id) return reply.status(400).send({ error: 'name and category_id required' })
const { rows } = await pool.query(
`INSERT INTO meters (category_id, parent_meter_id, name, location, serial_number, install_date, notes)
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
[b.category_id, b.parent_meter_id || null, b.name, b.location || null, b.serial_number || null, b.install_date || null, b.notes || null]
)
return rows[0]
})
app.patch('/api/meters/:id', { preHandler: requireCap('meters') }, async (req, reply) => {
const { rows: existing } = await pool.query('SELECT * FROM meters WHERE id = $1', [req.params.id])
if (!existing.length) return reply.status(404).send({ error: 'Meter not found' })
const m = existing[0]
const b = req.body || {}
if (b.parent_meter_id && Number(b.parent_meter_id) === Number(req.params.id)) {
return reply.status(400).send({ error: 'A meter cannot be its own parent' })
}
const { rows } = await pool.query(
`UPDATE meters SET category_id = $1, parent_meter_id = $2, name = $3, location = $4,
serial_number = $5, install_date = $6, notes = $7, active = $8
WHERE id = $9 RETURNING *`,
[
b.category_id ?? m.category_id,
b.parent_meter_id !== undefined ? b.parent_meter_id : m.parent_meter_id,
b.name ?? m.name,
b.location !== undefined ? b.location : m.location,
b.serial_number !== undefined ? b.serial_number : m.serial_number,
b.install_date !== undefined ? b.install_date : m.install_date,
b.notes !== undefined ? b.notes : m.notes,
b.active ?? m.active,
req.params.id,
]
)
return rows[0]
})
// POST /api/meters/:id/image — multipart upload, cashup-style pattern
app.post('/api/meters/:id/image', { preHandler: requireCap('meters') }, async (req, reply) => {
const meterId = parseInt(req.params.id)
const { rows } = await pool.query('SELECT * FROM meters WHERE id = $1', [meterId])
if (!rows.length) return reply.status(404).send({ error: 'Meter not found' })
const fileData = await req.file()
if (!fileData) return reply.status(400).send({ error: 'No file uploaded' })
if (!ALLOWED_IMAGES.includes(fileData.mimetype)) {
return reply.status(400).send({ error: 'Only JPEG, PNG and WebP images are allowed' })
}
const ext = extname(fileData.filename) || '.jpg'
const filename = randomUUID() + ext
const dir = join(UPLOADS_DIR, 'meters', String(meterId))
await mkdir(dir, { recursive: true })
const dest = createWriteStream(join(dir, filename))
for await (const chunk of fileData.file) dest.write(chunk)
await new Promise(r => dest.end(r))
// Replace any previous image
if (rows[0].image_path) {
await unlink(join(UPLOADS_DIR, rows[0].image_path)).catch(() => {})
}
const filePath = `/meters/${meterId}/${filename}`
const { rows: updated } = await pool.query(
'UPDATE meters SET image_path = $1 WHERE id = $2 RETURNING *',
[filePath, meterId]
)
return updated[0]
})
app.delete('/api/meters/:id/image', { preHandler: requireCap('meters') }, async (req, reply) => {
const { rows } = await pool.query('SELECT * FROM meters WHERE id = $1', [req.params.id])
if (!rows.length) return reply.status(404).send({ error: 'Meter not found' })
if (rows[0].image_path) {
await unlink(join(UPLOADS_DIR, rows[0].image_path)).catch(() => {})
}
const { rows: updated } = await pool.query(
'UPDATE meters SET image_path = NULL WHERE id = $1 RETURNING *',
[req.params.id]
)
return updated[0]
})
}

View file

@ -0,0 +1,111 @@
import { requireAuth, requireCap } from '../auth.js'
import { pool } from '../db.js'
import { createWriteStream } from 'fs'
import { mkdir, unlink } from 'fs/promises'
import { randomUUID } from 'crypto'
import { extname, join } from 'path'
const ALLOWED_IMAGES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']
export async function readingRoutes(app, opts) {
const UPLOADS_DIR = opts.uploadsDir
app.addHook('preHandler', requireAuth)
// GET /api/readings?meter_id=&from=&to=&limit=
app.get('/api/readings', { preHandler: requireCap('readings') }, async (req) => {
const { meter_id, from, to, limit } = req.query
const conditions = []
const params = []
if (meter_id) { params.push(meter_id); conditions.push(`r.meter_id = $${params.length}`) }
if (from) { params.push(from); conditions.push(`r.reading_date >= $${params.length}`) }
if (to) { params.push(to); conditions.push(`r.reading_date <= $${params.length}`) }
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''
params.push(Math.min(parseInt(limit) || 100, 500))
const { rows } = await pool.query(
`SELECT r.*, m.name AS meter_name, c.unit_label
FROM readings r
JOIN meters m ON m.id = r.meter_id
JOIN meter_categories c ON c.id = m.category_id
${where}
ORDER BY r.reading_date DESC, r.id DESC
LIMIT $${params.length}`,
params
)
return rows
})
app.post('/api/readings', { preHandler: requireCap('readings') }, async (req, reply) => {
const b = req.body || {}
if (!b.meter_id || b.reading_value === undefined || !b.reading_date) {
return reply.status(400).send({ error: 'meter_id, reading_value and reading_date required' })
}
const { rows: meter } = await pool.query('SELECT id FROM meters WHERE id = $1', [b.meter_id])
if (!meter.length) return reply.status(404).send({ error: 'Meter not found' })
const { rows: prev } = await pool.query(
`SELECT reading_value, reading_date FROM readings
WHERE meter_id = $1 ORDER BY reading_date DESC, id DESC LIMIT 1`,
[b.meter_id]
)
const rollback = prev.length && b.reading_date >= prev[0].reading_date && Number(b.reading_value) < Number(prev[0].reading_value)
const { rows } = await pool.query(
`INSERT INTO readings (meter_id, reading_value, reading_date, source, recorded_by, notes)
VALUES ($1,$2,$3,'manual',$4,$5) RETURNING *`,
[b.meter_id, b.reading_value, b.reading_date, req.user.email, b.notes || null]
)
return { ...rows[0], warning: rollback ? 'Reading is lower than the previous one — check for a meter rollover or entry error' : null }
})
app.patch('/api/readings/:id', { preHandler: requireCap('readings') }, async (req, reply) => {
const { rows: existing } = await pool.query('SELECT * FROM readings WHERE id = $1', [req.params.id])
if (!existing.length) return reply.status(404).send({ error: 'Reading not found' })
const r = existing[0]
const b = req.body || {}
const { rows } = await pool.query(
`UPDATE readings SET reading_value = $1, reading_date = $2, notes = $3 WHERE id = $4 RETURNING *`,
[b.reading_value ?? r.reading_value, b.reading_date ?? r.reading_date, b.notes !== undefined ? b.notes : r.notes, req.params.id]
)
return rows[0]
})
app.delete('/api/readings/:id', { preHandler: requireCap('readings') }, async (req, reply) => {
const { rows } = await pool.query('SELECT * FROM readings WHERE id = $1', [req.params.id])
if (!rows.length) return reply.status(404).send({ error: 'Reading not found' })
if (rows[0].photo_path) await unlink(join(UPLOADS_DIR, rows[0].photo_path)).catch(() => {})
await pool.query('DELETE FROM readings WHERE id = $1', [req.params.id])
return { ok: true }
})
// POST /api/readings/:id/photo — meter-dial photo evidence for a reading
app.post('/api/readings/:id/photo', { preHandler: requireCap('readings') }, async (req, reply) => {
const readingId = parseInt(req.params.id)
const { rows } = await pool.query('SELECT * FROM readings WHERE id = $1', [readingId])
if (!rows.length) return reply.status(404).send({ error: 'Reading not found' })
const fileData = await req.file()
if (!fileData) return reply.status(400).send({ error: 'No file uploaded' })
if (!ALLOWED_IMAGES.includes(fileData.mimetype)) {
return reply.status(400).send({ error: 'Only JPEG, PNG and WebP images are allowed' })
}
const ext = extname(fileData.filename) || '.jpg'
const filename = randomUUID() + ext
const dir = join(UPLOADS_DIR, 'readings', String(readingId))
await mkdir(dir, { recursive: true })
const dest = createWriteStream(join(dir, filename))
for await (const chunk of fileData.file) dest.write(chunk)
await new Promise(r => dest.end(r))
if (rows[0].photo_path) await unlink(join(UPLOADS_DIR, rows[0].photo_path)).catch(() => {})
const filePath = `/readings/${readingId}/${filename}`
const { rows: updated } = await pool.query(
'UPDATE readings SET photo_path = $1 WHERE id = $2 RETURNING *',
[filePath, readingId]
)
return updated[0]
})
}

View file

@ -0,0 +1,89 @@
import { requireAuth, requireCap } from '../auth.js'
import { pool } from '../db.js'
import { getMeterCostForPeriod, resolvePeriod } from '../lib/cost-calc.js'
export async function reportRoutes(app) {
app.addHook('preHandler', requireAuth)
app.addHook('preHandler', requireCap('reports'))
// GET /api/reports/consumption-cost?period=YYYY-MM&category_id= — per-meter
// consumption + full cost breakdown for the period (defaults to current month)
app.get('/api/reports/consumption-cost', async (req) => {
const { start, end, isCurrent } = resolvePeriod(req.query.period)
const { category_id } = req.query
const params = []
let where = ''
if (category_id) { params.push(category_id); where = 'WHERE m.category_id = $1' }
const { rows: meters } = await pool.query(
`SELECT m.id, m.name, m.category_id, c.name AS category_name, c.unit_label
FROM meters m JOIN meter_categories c ON c.id = m.category_id
${where} ORDER BY c.sort_order, m.name`,
params
)
const results = []
for (const m of meters) {
const cost = await getMeterCostForPeriod(m.id, start, end)
results.push({
meter_id: m.id, meter_name: m.name,
category_id: m.category_id, category_name: m.category_name, unit_label: m.unit_label,
...cost,
})
}
const totals = results.reduce((acc, r) => ({
consumption: acc.consumption + (r.consumption || 0),
usage_cost_pence: acc.usage_cost_pence + r.usage_cost_pence,
standing_cost_pence: acc.standing_cost_pence + r.standing_cost_pence,
ccl_cost_pence: acc.ccl_cost_pence + r.ccl_cost_pence,
vat_pence: acc.vat_pence + r.vat_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 })
return { period: { start, end, isCurrent }, meters: results, totals }
})
// GET /api/reports/rollup?period=YYYY-MM — every parent meter vs sum of its
// children over the same period, flagged when children exceed the parent
app.get('/api/reports/rollup', async (req) => {
const { start, end } = resolvePeriod(req.query.period)
const { rows: parents } = await pool.query(
`SELECT DISTINCT m.id, m.name, c.unit_label
FROM meters m
JOIN meter_categories c ON c.id = m.category_id
WHERE EXISTS (SELECT 1 FROM meters child WHERE child.parent_meter_id = m.id)`
)
const results = []
for (const p of parents) {
const { rows: children } = await pool.query('SELECT id, name FROM meters WHERE parent_meter_id = $1', [p.id])
const parentCost = await getMeterCostForPeriod(p.id, start, end)
let childSum = 0
let childHasData = false
const childBreakdown = []
for (const c of children) {
const cc = await getMeterCostForPeriod(c.id, start, end)
if (cc.has_data) { childSum += cc.consumption; childHasData = true }
childBreakdown.push({ meter_id: c.id, meter_name: c.name, consumption: cc.consumption, has_data: cc.has_data })
}
const anomaly = parentCost.has_data && childHasData && childSum > parentCost.consumption
results.push({
parent_meter_id: p.id,
parent_meter_name: p.name,
unit_label: p.unit_label,
parent_consumption: parentCost.consumption,
parent_has_data: parentCost.has_data,
children: childBreakdown,
child_sum: childHasData ? childSum : null,
anomaly,
})
}
return { period: { start, end }, rollups: results }
})
}

View file

@ -0,0 +1,27 @@
import { requireAuth, requireCap } from '../auth.js'
import { pool } from '../db.js'
export async function settingsRoutes(app) {
app.addHook('preHandler', requireAuth)
// GET /api/config — all config keys as a flat object (readable by anyone in
// the app since the estimates page needs the global default too)
app.get('/api/config', async () => {
const { rows } = await pool.query('SELECT key, value FROM config ORDER BY key')
return Object.fromEntries(rows.map(r => [r.key, r.value]))
})
// PUT /api/config/:key — update a single config key
app.put('/api/config/:key', { preHandler: requireCap('settings') }, async (req, reply) => {
const { key } = req.params
const { value } = req.body || {}
if (value === undefined) return reply.status(400).send({ error: 'value required' })
await pool.query(
`INSERT INTO config (key, value, updated_at) VALUES ($1, $2, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()`,
[key, JSON.stringify(value)]
)
return { ok: true }
})
}

View file

@ -0,0 +1,175 @@
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()
}
})
}