Rate windows were matched by substring (label.includes(key)), so overlapping labels like "Day"/"Weekday" could silently cross-match to the wrong rate. Switched to an exact match. The "split must total 100%" check was advisory-only in the UI and never validated server-side, letting a tariff save with a split that doesn't sum to 100% and permanently mis-cost that meter's usage. Enforced on both the API (POST/PATCH /api/tariffs) and the Save button. Also aligned the app's portal category to 'Hotel' (already correct in auth/src/db.js) here and in stack-init/install-stack.sh, which had both drifted to 'Operations'. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
200 lines
8.7 KiB
JavaScript
200 lines
8.7 KiB
JavaScript
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()
|
|
}
|
|
})
|
|
}
|