From c9a2ef81a9a36eb106b1d116c074b00fd883cc8f Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Thu, 18 Jun 2026 16:58:36 +0200 Subject: [PATCH] =?UTF-8?q?fix(tariff):=20forbid=20backdated=20effectiveFr?= =?UTF-8?q?om=20=E2=80=94=20versioning=20was=20retroactive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Version selection is "latest tariff_version with effectiveFrom <= entry time", but the publish handler accepted ANY effectiveFrom (defaulting to now). So an admin could publish a version with a backdated effectiveFrom and silently reprice sessions that had already entered — the retroactive rewrite the versioning exists to prevent. Pricing itself was sound (quote resolves by entry time; payment records tariffVersionId, freezing completed sessions); the leak was the publish side only. Reject effectiveFrom earlier than now (60s skew tolerance); future-dated (scheduling a price change) stays allowed; bad ISO -> 400. Combined with entry-time selection this is structural: once a car has entered, no later publish can reprice it. Did not pin tariffVersionId onto vehicle_entry (not needed). Verified 5/5 via inject against a copy of the live DB. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V --- apps/server/src/routes/tariffs.ts | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/apps/server/src/routes/tariffs.ts b/apps/server/src/routes/tariffs.ts index 8b7b433..d43947e 100644 --- a/apps/server/src/routes/tariffs.ts +++ b/apps/server/src/routes/tariffs.ts @@ -62,12 +62,37 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise if (problems.length) { return reply.code(400).send({ error: "invalid tariff structure", problems }); } + + // effectiveFrom must NOT be in the past. A version is selected by + // "latest effectiveFrom <= entry time", so a backdated effectiveFrom would + // retroactively reprice already-entered sessions — exactly the immutability + // the versioning exists to prevent (wiki/concepts/tariff.md). So we forbid + // backdating: a new version applies only from publish (now) forward; a future + // effectiveFrom (scheduling a price change) is allowed. A small skew tolerance + // absorbs client/server clock drift + request round-trip. Once a car has + // entered, no later publish can reprice it (no effectiveFrom can predate it). + const now = Date.now(); + const SKEW_MS = 60_000; // 1 min: clock skew + round-trip slack + let effective = new Date().toISOString(); + if (effectiveFrom != null) { + const t = Date.parse(effectiveFrom); + if (Number.isNaN(t)) { + return reply.code(400).send({ error: "effectiveFrom must be a valid ISO-8601 timestamp" }); + } + if (t < now - SKEW_MS) { + return reply.code(400).send({ + error: "effectiveFrom cannot be in the past — backdating a tariff would retroactively reprice entered sessions", + }); + } + effective = new Date(t).toISOString(); + } + const tariffId = ensureSiteTariff(); const id = randomUUID(); const row = { id, tariffId, - effectiveFrom: effectiveFrom ?? new Date().toISOString(), + effectiveFrom: effective, currency, structure: structure as unknown as Record, createdBy: req.user?.username ?? null,