feat(tariff): V2 — legacy-parity pricing (time-of-day, category, seasonal, flat)
Bring the legacy ParkSQL2017 pricing BREADTH onto our engine while keeping
integer-minor-unit money + immutable signed versions (rejecting legacy's
float money / mutable rows). TariffStructure becomes a discriminated union:
V1 = the original bare ladder (UNCHANGED, verbatim algorithm, golden-
regression-tested against the live version); V2 = {version:2, tz, shared
knobs, defaultCard, windowedCards[]} where each card is flat OR a block
ladder and may be scoped by wall-clock hour window / day-of-week / date
range / vehicle category.
computeFeeV2 prices by stepping one increment at a time, advancing the
ladder by ELAPSED minutes (continuous) while selecting the active card by
WALL-CLOCK time in the version's FROZEN tz. Decisions: tz is a per-site
setting (site_config.timezone, default Europe/Tirane) stamped server-side
into each version on publish — never the host clock (reproducibility);
default-card cap governs a mixed day; precedence = specificity
(date>dow>hour) -> priority -> name (total, order-independent), validation
rejects ambiguous ties; category = a card FIELD, frozen in the signed
vehicle_entry payload (site_config.default_vehicle_category default), read
at both pricing call-sites.
Composer: default card front-and-centre (flat/ladder toggle), tiers under
an "Advanced" disclosure; emits BARE V1 when no tiers (back-compat). DB:
migrations 0005 (timezone) + 0006 (default_vehicle_category). Stood up
vitest in @parking/shared (was zero tests on the ledger-feeding fee fn);
36 tests incl. golden V1 regression, happy-hour/overnight/dow/flat/category/
cap edges, precedence shuffle-invariance, Europe/Tirane DST determinism,
validation matrix — all green. No event-chain change.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
type TicketData,
|
||||
type TicketHeader,
|
||||
} from "@parking/devices";
|
||||
import { DEFAULT_VEHICLE_CATEGORY } from "@parking/shared";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type { DeviceInputEvent } from "./device-events.js";
|
||||
import { getOccupancy } from "./occupancy.js";
|
||||
@@ -113,12 +114,24 @@ export class EntryFlow {
|
||||
}
|
||||
|
||||
// 2. SIGN the vehicle_entry — BEFORE the relay fires (the core invariant).
|
||||
// `category` is FROZEN here (in the signed payload) so the tariff prices and
|
||||
// later reprices the same way at exit. Today every transient takes the SITE
|
||||
// default category (operator policy, site_config.default_vehicle_category;
|
||||
// falls back to the shared DEFAULT_VEHICLE_CATEGORY). Per-relay capture (a
|
||||
// "bus lane" relay, mirroring how direction is per-relay in device-resolve.ts)
|
||||
// is the future seam — source it from `resolved` then. A V1/no-category tariff
|
||||
// ignores it; only V2 category cards consult it.
|
||||
const cfg = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
const category =
|
||||
cfg?.defaultVehicleCategory && cfg.defaultVehicleCategory.length > 0
|
||||
? cfg.defaultVehicleCategory
|
||||
: DEFAULT_VEHICLE_CATEGORY;
|
||||
await this.#log.append({
|
||||
type: "vehicle_entry",
|
||||
direction: "entry",
|
||||
source: "ticket",
|
||||
identity: ticketId,
|
||||
payload: { sessionRef: ticketId, ticketPrinted: true },
|
||||
payload: { sessionRef: ticketId, ticketPrinted: true, category },
|
||||
occurredAt: issuedAt,
|
||||
});
|
||||
|
||||
|
||||
@@ -424,7 +424,10 @@ export class ExitFlow {
|
||||
const tv = this.#tariffVersionFor(entry.occurredAt);
|
||||
if (tv) {
|
||||
const structure = tv.structure as unknown as TariffStructure;
|
||||
const fee = computeFee(entry.occurredAt, new Date().toISOString(), structure);
|
||||
// Same frozen-at-entry category the pay station uses, so the free-grace
|
||||
// check agrees with the booth quote for V2 category tariffs.
|
||||
const category = (entry.payload as { category?: string } | null)?.category;
|
||||
const fee = computeFee(entry.occurredAt, new Date().toISOString(), structure, category);
|
||||
if (fee === 0) {
|
||||
freeGrace = {
|
||||
tariffVersionId: tv.id,
|
||||
|
||||
@@ -106,7 +106,11 @@ export class PayStation {
|
||||
if (!tv) throw new NoTariffError();
|
||||
const structure = tv.structure as unknown as TariffStructure;
|
||||
|
||||
const amountMinor = computeFee(entry.occurredAt, new Date().toISOString(), structure);
|
||||
// Category was frozen in the signed vehicle_entry payload — pricing AND repricing
|
||||
// both read it from there, so a V2 category tariff yields the same amount at the
|
||||
// booth and at exit. Absent (legacy/V1) ⇒ undefined ⇒ category-agnostic pricing.
|
||||
const category = (entry.payload as { category?: string } | null)?.category;
|
||||
const amountMinor = computeFee(entry.occurredAt, new Date().toISOString(), structure, category);
|
||||
return {
|
||||
identity,
|
||||
enteredAt: entry.occurredAt,
|
||||
|
||||
@@ -15,6 +15,10 @@ const TEXT_FIELDS = [
|
||||
"address",
|
||||
"phone",
|
||||
"email",
|
||||
// IANA timezone for tariff wall-clock windows (copied into each published version).
|
||||
"timezone",
|
||||
// Default vehicle/customer category frozen onto each transient entry.
|
||||
"defaultVehicleCategory",
|
||||
] as const;
|
||||
type TextField = (typeof TEXT_FIELDS)[number];
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { desc, eq, tariffVersions, tariffs, type Db } from "@parking/db";
|
||||
import { validateTariffStructure, type TariffStructure } from "@parking/shared";
|
||||
import { desc, eq, siteConfig, tariffVersions, tariffs, type Db } from "@parking/db";
|
||||
import { isTariffV2, validateTariffStructure, type TariffStructure } from "@parking/shared";
|
||||
import { requireRole } from "../auth.js";
|
||||
|
||||
/** Default site timezone for wall-clock tariff windows when none is configured. */
|
||||
const DEFAULT_TZ = "Europe/Tirane";
|
||||
|
||||
// Tariff composer API — the admin builds + edits the rate card at runtime. Tariffs
|
||||
// are EFFECTIVE-DATED IMMUTABLE VERSIONS: editing publishes a new version, never
|
||||
// mutates one; a session reprices against the version in force at its entry, and
|
||||
@@ -58,7 +61,17 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
|
||||
if (!currency || typeof currency !== "string" || currency.length < 3) {
|
||||
return reply.code(400).send({ error: "currency (ISO 4217) required" });
|
||||
}
|
||||
const problems = validateTariffStructure(structure);
|
||||
// For a windowed (V2) structure, stamp the wall-clock timezone from SITE config
|
||||
// (not the client) BEFORE validating — so the frozen tz is authoritative and the
|
||||
// validation that requires tz passes. A V1 (bare) structure is left untouched.
|
||||
let toStore: TariffStructure = structure;
|
||||
if (structure && isTariffV2(structure)) {
|
||||
const cfg = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
const tz = cfg?.timezone && cfg.timezone.length > 0 ? cfg.timezone : DEFAULT_TZ;
|
||||
toStore = { ...structure, tz };
|
||||
}
|
||||
|
||||
const problems = validateTariffStructure(toStore);
|
||||
if (problems.length) {
|
||||
return reply.code(400).send({ error: "invalid tariff structure", problems });
|
||||
}
|
||||
@@ -94,7 +107,7 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
|
||||
tariffId,
|
||||
effectiveFrom: effective,
|
||||
currency,
|
||||
structure: structure as unknown as Record<string, unknown>,
|
||||
structure: toStore as unknown as Record<string, unknown>,
|
||||
createdBy: req.user?.username ?? null,
|
||||
};
|
||||
db.insert(tariffVersions).values(row).run();
|
||||
|
||||
Reference in New Issue
Block a user