fix(tariff): forbid backdated effectiveFrom — versioning was retroactive
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
This commit is contained in:
@@ -62,12 +62,37 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
|
|||||||
if (problems.length) {
|
if (problems.length) {
|
||||||
return reply.code(400).send({ error: "invalid tariff structure", problems });
|
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 tariffId = ensureSiteTariff();
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
const row = {
|
const row = {
|
||||||
id,
|
id,
|
||||||
tariffId,
|
tariffId,
|
||||||
effectiveFrom: effectiveFrom ?? new Date().toISOString(),
|
effectiveFrom: effective,
|
||||||
currency,
|
currency,
|
||||||
structure: structure as unknown as Record<string, unknown>,
|
structure: structure as unknown as Record<string, unknown>,
|
||||||
createdBy: req.user?.username ?? null,
|
createdBy: req.user?.username ?? null,
|
||||||
|
|||||||
Reference in New Issue
Block a user