Fix TOU rate-window mismatch and enforce fallback-split validation

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>
This commit is contained in:
jtricerolph 2026-07-28 12:59:19 +00:00
parent 7ab048931f
commit f1ce0f06d8
4 changed files with 38 additions and 3 deletions

View file

@ -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,