Split cost/estimate across mid-period tariff changes

Rate changes are often scheduled ahead of time or confirmed by finance
after the fact and backdated — assign-tariff already supported any
effective_from date, but cost-calc previously priced a whole period at
a single tariff (whichever was in force on the period-end date),
silently mispricing the days on the other side of a change.

getTariffSegments() finds every tariff assignment overlapping a date
range; getMeterCostForPeriod/getMeterEstimateForPeriod now split
consumption pro-rata by day count across segments and price each
against its own tariff, summing to the period total. Estimates project
the remaining days across a future-scheduled change the same way,
rather than assuming today's tariff holds for the rest of the period.

Also consolidates internal.js's cost/estimate routes (previously their
own duplicate calc) onto the same shared functions reports.js and
estimates.js use, so the Directors' report can't drift from the in-app
numbers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-26 17:43:45 +00:00
parent 4fc5230d79
commit 7ab048931f
5 changed files with 151 additions and 63 deletions

View file

@ -108,6 +108,37 @@ export async function getTariffForMeter(meterId, date) {
return open[0] || null
}
function toISODate(d) {
return new Date(d).toISOString().slice(0, 10)
}
// Every tariff assignment whose range overlaps [periodStart, periodEnd], clipped
// to the period and ordered chronologically. A rate change — scheduled ahead of
// time or entered late/backdated once finance confirms it — shows up here as two
// (or more) segments instead of a single tariff covering the whole period, so
// cost calculations split correctly at the change boundary rather than pricing
// the whole period at whichever tariff happens to apply on one edge date.
export async function getTariffSegments(meterId, periodStart, periodEnd) {
const { rows } = await pool.query(
`SELECT mt.effective_from AS mt_from, mt.effective_to AS mt_to, t.* FROM meter_tariffs mt
JOIN tariffs t ON t.id = mt.tariff_id
WHERE mt.meter_id = $1
AND mt.effective_from <= $3
AND (mt.effective_to IS NULL OR mt.effective_to >= $2)
ORDER BY mt.effective_from ASC`,
[meterId, periodStart, periodEnd]
)
return rows
.map(r => {
const from = toISODate(r.mt_from)
const to = r.mt_to ? toISODate(r.mt_to) : null
const segStart = from > periodStart ? from : periodStart
const segEnd = to && to < periodEnd ? to : periodEnd
return { ...r, seg_start: segStart, seg_end: segEnd }
})
.filter(s => s.seg_start <= s.seg_end)
}
export async function getRateWindows(tariffId) {
const { rows } = await pool.query(
'SELECT * FROM tariff_rate_windows WHERE tariff_id = $1 ORDER BY sort_order, id',
@ -163,16 +194,92 @@ export function computeCost({ tariff, windows, consumption, daysInPeriod }) {
}
}
// 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).
const ZERO_TOTALS = { usage_cost_pence: 0, standing_cost_pence: 0, ccl_cost_pence: 0, subtotal_pence: 0, vat_pence: 0, total_pence: 0 }
// Cost a single amount of consumption over [rangeStart, rangeEnd], splitting it
// pro-rata (by day count) across every tariff segment active in that range. Used
// both for a closed historical period and for a projected future range — in both
// cases we only have a total consumption figure (from readings or a trailing
// average), never a day-by-day breakdown, so a rate change inside the range is
// apportioned by day count rather than known per-day usage.
async function splitCostAcrossSegments(meterId, rangeStart, rangeEnd, consumption, asOfDate) {
const rangeDays = daysInclusive(rangeStart, rangeEnd)
const segments = await getTariffSegments(meterId, rangeStart, rangeEnd)
if (!segments.length) {
const tariff = await getTariffForMeter(meterId, asOfDate || rangeEnd)
const windows = tariff ? await getRateWindows(tariff.id) : []
const cost = computeCost({ tariff, windows, consumption, daysInPeriod: rangeDays })
return { tariff, windows, segments: [], rate_changed_mid_period: false, ...cost }
}
const dailyAvg = consumption == null ? null : consumption / rangeDays
const segmentResults = []
const agg = { ...ZERO_TOTALS }
for (const seg of segments) {
const segDays = daysInclusive(seg.seg_start, seg.seg_end)
const segConsumption = dailyAvg == null ? null : dailyAvg * segDays
const windows = await getRateWindows(seg.id)
const cost = computeCost({ tariff: seg, windows, consumption: segConsumption, daysInPeriod: segDays })
for (const k of Object.keys(agg)) agg[k] += cost[k]
segmentResults.push({
tariff_id: seg.id, tariff_name: seg.name,
seg_start: seg.seg_start, seg_end: seg.seg_end, days: segDays,
consumption: segConsumption, ...cost,
})
}
const lastSeg = segments[segments.length - 1]
const lastWindows = await getRateWindows(lastSeg.id)
const rateChanged = segments.length > 1
return {
tariff: lastSeg, windows: lastWindows, segments: segmentResults,
...agg, split: rateChanged ? null : segmentResults[0]?.split ?? null,
rate_changed_mid_period: rateChanged,
}
}
// Full cost breakdown for one meter over a period — fetches consumption from
// readings, then splits cost across any tariff change (scheduled ahead of time
// or backdated after the fact) that falls inside the period. `asOfDate` is only
// used as a fallback when the meter has no tariff-assignment history at all.
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 }
const costed = await splitCostAcrossSegments(meterId, periodStart, periodEnd, consumption, asOfDate || periodEnd)
return { consumption, has_data, first_reading: first, last_reading: last, days_in_period: daysInPeriod, ...costed }
}
// Estimate for the remainder of an open period: actual consumption to date +
// trailing-average projection for the remaining days, cost-split across any
// tariff segments covering the whole period — including a rate change already
// scheduled for later in the period, which a same-tariff-for-everything
// projection would otherwise miss.
export async function getMeterEstimateForPeriod(meterId, periodStart, periodEnd, windowDays) {
const today = toISODate(new Date())
const asOfDate = today < periodEnd ? today : periodEnd
const daysInPeriod = daysInclusive(periodStart, periodEnd)
const remainingDays = Math.max(daysBetween(asOfDate, periodEnd), 0)
const { daily_rate } = await getTrailingDailyRate(meterId, windowDays)
const { consumption: actualToDate, has_data } = await getPeriodConsumption(meterId, periodStart, asOfDate)
let projectedConsumption = null
if (daily_rate != null) {
projectedConsumption = (has_data ? actualToDate : 0) + daily_rate * remainingDays
} else if (has_data) {
projectedConsumption = actualToDate
}
const costed = await splitCostAcrossSegments(meterId, periodStart, periodEnd, projectedConsumption, asOfDate)
return {
trailing_window_days: windowDays, daily_rate,
actual_to_date: has_data ? actualToDate : null,
remaining_days: remainingDays, days_in_period: daysInPeriod,
projected_consumption: projectedConsumption, ...costed,
}
}
// [periodStart, periodEnd] for a YYYY-MM period string, or the current

View file

@ -1,44 +1,15 @@
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'
import { getMeterEstimateForPeriod, resolvePeriod } 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 })
const estimate = await getMeterEstimateForPeriod(meter.id, periodStart, periodEnd, windowDays)
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,
...estimate,
}
}

View file

@ -3,8 +3,8 @@
// 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,
getPeriodConsumption, getMeterCostForPeriod, getMeterEstimateForPeriod,
resolvePeriod, daysInclusive,
} from '../lib/cost-calc.js'
async function requireApiKey(req, reply) {
@ -70,11 +70,8 @@ export async function internalRoutes(app) {
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
const cost = await getMeterCostForPeriod(m.id, start, end)
if (cost.has_data) catTotals.consumption += cost.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
@ -92,10 +89,6 @@ export async function internalRoutes(app) {
// 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
@ -103,6 +96,7 @@ export async function internalRoutes(app) {
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 }
let remainingDays = 0
for (const cat of categories) {
const windowDays = cat.estimate_trailing_days || globalDefault
@ -111,18 +105,10 @@ export async function internalRoutes(app) {
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
const estimate = await getMeterEstimateForPeriod(m.id, start, end, windowDays)
remainingDays = estimate.remaining_days
if (estimate.projected_consumption != null) catConsumption += estimate.projected_consumption
catTotalPence += estimate.total_pence
}
breakdown.push({ category: cat.key, category_name: cat.name, unit_label: cat.unit_label, projected_consumption: catConsumption, total_pence: catTotalPence })