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

@ -162,7 +162,7 @@ export function computeCost({ tariff, windows, consumption, daysInPeriod }) {
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()))
const win = windows.find(w => w.label.toLowerCase() === key.toLowerCase())
if (!win) continue
const share = consumption * (Number(pct) / 100)
const cost = share * Number(win.unit_rate_pence_per_unit)

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,

View file

@ -93,6 +93,10 @@ export default function Tariffs() {
async function save() {
if (!form) return
if (!form.name.trim() || !form.category_id) { setError('Name and category are required'); return }
if (form.is_time_of_use && form.windows.length > 1) {
const total = form.windows.reduce((s, w) => s + (Number(w.split_pct) || 0), 0)
if (Math.round(total) !== 100) { setError(`Fallback split must total 100% (currently ${total}%)`); return }
}
setSaving(true)
setError(null)
try {
@ -271,7 +275,13 @@ export default function Tariffs() {
<div className="modal-actions">
<button className="btn" onClick={() => setForm(null)}>Cancel</button>
<button className="btn btn-primary" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
<button
className="btn btn-primary"
onClick={save}
disabled={saving || (form.is_time_of_use && form.windows.length > 1 && Math.round(splitTotal) !== 100)}
>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</div>
</div>

View file

@ -10,7 +10,7 @@ const pool = new Pool({ connectionString: process.env.DATABASE_URL })
await pool.query(`
INSERT INTO apps (slug, name, description, base_path, icon, theme_color, category, internal_host, internal_port)
VALUES ('utilities', 'Utilities', 'Meter readings, tariffs and energy cost tracking', '/utilities', 'Zap', '#1e6091', 'Operations', '10.10.10.127', 3080)
VALUES ('utilities', 'Utilities', 'Meter readings, tariffs and energy cost tracking', '/utilities', 'Zap', '#1e6091', 'Hotel', '10.10.10.127', 3080)
ON CONFLICT (slug) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,