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 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) { export async function getRateWindows(tariffId) {
const { rows } = await pool.query( const { rows } = await pool.query(
'SELECT * FROM tariff_rate_windows WHERE tariff_id = $1 ORDER BY sort_order, id', '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 const ZERO_TOTALS = { usage_cost_pence: 0, standing_cost_pence: 0, ccl_cost_pence: 0, subtotal_pence: 0, vat_pence: 0, total_pence: 0 }
// + consumption and runs computeCost. `asOfDate` picks which historical
// tariff applies (defaults to periodEnd). // 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) { export async function getMeterCostForPeriod(meterId, periodStart, periodEnd, asOfDate) {
const { consumption, first, last, has_data } = await getPeriodConsumption(meterId, periodStart, periodEnd) 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 daysInPeriod = daysInclusive(periodStart, periodEnd)
const cost = computeCost({ tariff, windows, consumption, daysInPeriod }) const costed = await splitCostAcrossSegments(meterId, periodStart, periodEnd, consumption, asOfDate || periodEnd)
return { consumption, has_data, first_reading: first, last_reading: last, tariff, windows, days_in_period: daysInPeriod, ...cost } 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 // [periodStart, periodEnd] for a YYYY-MM period string, or the current

View file

@ -1,44 +1,15 @@
import { requireAuth, requireCap } from '../auth.js' import { requireAuth, requireCap } from '../auth.js'
import { pool, getConfig } from '../db.js' import { pool, getConfig } from '../db.js'
import { import { getMeterEstimateForPeriod, resolvePeriod } from '../lib/cost-calc.js'
getPeriodConsumption, getTrailingDailyRate, getTariffForMeter, getRateWindows,
computeCost, resolvePeriod, daysInclusive, daysBetween,
} from '../lib/cost-calc.js'
const VALID_WINDOWS = [7, 14, 30] const VALID_WINDOWS = [7, 14, 30]
async function estimateForMeter(meter, periodStart, periodEnd, windowDays) { async function estimateForMeter(meter, periodStart, periodEnd, windowDays) {
const today = new Date().toISOString().slice(0, 10) const estimate = await getMeterEstimateForPeriod(meter.id, periodStart, periodEnd, windowDays)
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 { return {
meter_id: meter.id, meter_name: meter.name, meter_id: meter.id, meter_name: meter.name,
category_id: meter.category_id, category_name: meter.category_name, unit_label: meter.unit_label, category_id: meter.category_id, category_name: meter.category_name, unit_label: meter.unit_label,
trailing_window_days: windowDays, ...estimate,
daily_rate,
actual_to_date: has_data ? actualToDate : null,
remaining_days: remainingDays,
projected_consumption: projectedConsumption,
...cost,
} }
} }

View file

@ -3,8 +3,8 @@
// internal API pattern (X-API-Key header, static key from env). // internal API pattern (X-API-Key header, static key from env).
import { pool } from '../db.js' import { pool } from '../db.js'
import { import {
getPeriodConsumption, getTariffForMeter, getRateWindows, computeCost, getPeriodConsumption, getMeterCostForPeriod, getMeterEstimateForPeriod,
getTrailingDailyRate, resolvePeriod, daysInclusive, daysBetween, resolvePeriod, daysInclusive,
} from '../lib/cost-calc.js' } from '../lib/cost-calc.js'
async function requireApiKey(req, reply) { 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 } 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) { for (const m of meters) {
const { consumption, has_data } = await getPeriodConsumption(m.id, start, end) const cost = await getMeterCostForPeriod(m.id, start, end)
const tariff = await getTariffForMeter(m.id, end) if (cost.has_data) catTotals.consumption += cost.consumption
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.usage_cost_pence += cost.usage_cost_pence
catTotals.standing_cost_pence += cost.standing_cost_pence catTotals.standing_cost_pence += cost.standing_cost_pence
catTotals.ccl_cost_pence += cost.ccl_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 // GET /api/internal/estimate?period=current — projected cost for the open period
app.get('/api/internal/estimate', async (req) => { app.get('/api/internal/estimate', async (req) => {
const { start, end, year, month } = resolvePeriod(req.query.period) 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 { rows: config } = await pool.query("SELECT value FROM config WHERE key = 'estimate_trailing_days'")
const globalDefault = config[0]?.value ?? 30 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 { rows: categories } = await pool.query('SELECT * FROM meter_categories WHERE active = TRUE ORDER BY sort_order')
const breakdown = [] const breakdown = []
const totals = { total_pence: 0, projected_consumption: 0 } const totals = { total_pence: 0, projected_consumption: 0 }
let remainingDays = 0
for (const cat of categories) { for (const cat of categories) {
const windowDays = cat.estimate_trailing_days || globalDefault const windowDays = cat.estimate_trailing_days || globalDefault
@ -111,18 +105,10 @@ export async function internalRoutes(app) {
let catTotalPence = 0 let catTotalPence = 0
for (const m of meters) { for (const m of meters) {
const { daily_rate } = await getTrailingDailyRate(m.id, windowDays) const estimate = await getMeterEstimateForPeriod(m.id, start, end, windowDays)
const { consumption: actualToDate, has_data } = await getPeriodConsumption(m.id, start, asOfDate) remainingDays = estimate.remaining_days
let projected = null if (estimate.projected_consumption != null) catConsumption += estimate.projected_consumption
if (daily_rate != null) projected = (has_data ? actualToDate : 0) + daily_rate * remainingDays catTotalPence += estimate.total_pence
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 }) breakdown.push({ category: cat.key, category_name: cat.name, unit_label: cat.unit_label, projected_consumption: catConsumption, total_pence: catTotalPence })

View file

@ -76,7 +76,15 @@ export default function Reports() {
<tbody> <tbody>
{report.meters.map(m => ( {report.meters.map(m => (
<tr key={m.meter_id}> <tr key={m.meter_id}>
<td>{m.meter_name}{!m.has_data && <span className="badge badge-outline" style={{ marginLeft: 6 }}>no data</span>}</td> <td>
{m.meter_name}
{!m.has_data && <span className="badge badge-outline" style={{ marginLeft: 6 }}>no data</span>}
{m.rate_changed_mid_period && (
<span className="badge badge-tou" style={{ marginLeft: 6 }} title={m.segments.map(s => `${s.tariff_name}: ${s.seg_start} ${s.seg_end}`).join(', ')}>
rate changed
</span>
)}
</td>
<td>{m.category_name}</td> <td>{m.category_name}</td>
<td className="num">{formatUnits(m.consumption, m.unit_label)}</td> <td className="num">{formatUnits(m.consumption, m.unit_label)}</td>
<td className="num">{formatMoney(m.usage_cost_pence)}</td> <td className="num">{formatMoney(m.usage_cost_pence)}</td>

View file

@ -118,6 +118,20 @@ export interface Reading {
warning?: string | null warning?: string | null
} }
export interface CostSegment {
tariff_id: number
tariff_name: string
seg_start: string
seg_end: string
days: number
consumption: number | null
usage_cost_pence: number
standing_cost_pence: number
ccl_cost_pence: number
vat_pence: number
total_pence: number
}
export interface CostBreakdown { export interface CostBreakdown {
usage_cost_pence: number usage_cost_pence: number
standing_cost_pence: number standing_cost_pence: number
@ -126,6 +140,8 @@ export interface CostBreakdown {
vat_pence: number vat_pence: number
total_pence: number total_pence: number
split: Record<string, { share: number; rate: number; cost_pence: number }> | null split: Record<string, { share: number; rate: number; cost_pence: number }> | null
segments: CostSegment[]
rate_changed_mid_period: boolean
} }
export interface MeterCostRow extends CostBreakdown { export interface MeterCostRow extends CostBreakdown {