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:
+42
-1
@@ -240,7 +240,10 @@ export interface TariffBlock {
|
||||
uptoMin: number | null;
|
||||
priceMinorPerIncrement: number;
|
||||
}
|
||||
export interface TariffStructure {
|
||||
// Mirrors @parking/shared. Two shapes: V1 (bare ladder) and V2 (default + windowed
|
||||
// cards by time-of-day / dow / date / category, flat or laddered). The discriminant
|
||||
// is the presence of `defaultCard`. See wiki/concepts/tariff-time-tiers.md.
|
||||
export interface TariffStructureV1 {
|
||||
gracePeriodEntryMin: number;
|
||||
incrementMin: number;
|
||||
blocks: TariffBlock[];
|
||||
@@ -249,6 +252,39 @@ export interface TariffStructure {
|
||||
gracePeriodExitMin: number;
|
||||
overstay: "reprice";
|
||||
}
|
||||
export interface TariffWindow {
|
||||
dow?: number[];
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
fromHour?: string;
|
||||
toHour?: string;
|
||||
}
|
||||
export interface TariffCard {
|
||||
name: string;
|
||||
priority: number;
|
||||
category?: string;
|
||||
window?: TariffWindow;
|
||||
flatMinor?: number;
|
||||
blocks?: TariffBlock[];
|
||||
dailyCapMinor?: number | null;
|
||||
}
|
||||
export interface TariffStructureV2 {
|
||||
version: 2;
|
||||
tz: string;
|
||||
gracePeriodEntryMin: number;
|
||||
incrementMin: number;
|
||||
lostTicketMinor: number;
|
||||
gracePeriodExitMin: number;
|
||||
overstay: "reprice";
|
||||
defaultCard: TariffCard;
|
||||
windowedCards?: TariffCard[];
|
||||
}
|
||||
export type TariffStructure = TariffStructureV1 | TariffStructureV2;
|
||||
|
||||
/** True when a structure is the windowed V2 shape (mirrors @parking/shared isTariffV2). */
|
||||
export function isTariffV2(t: TariffStructure): t is TariffStructureV2 {
|
||||
return (t as TariffStructureV2).defaultCard != null;
|
||||
}
|
||||
export interface TariffVersion {
|
||||
id: string;
|
||||
tariffId: string;
|
||||
@@ -446,6 +482,11 @@ export interface SiteConfig {
|
||||
address: string | null;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
/** IANA timezone for tariff wall-clock windows (e.g. "Europe/Tirane"). Copied into
|
||||
* each published tariff version so its windows are frozen. */
|
||||
timezone: string | null;
|
||||
/** Default vehicle/customer category frozen onto each transient entry (V2 pricing). */
|
||||
defaultVehicleCategory: string | null;
|
||||
}
|
||||
|
||||
export function fetchOccupancy(): Promise<Occupancy> {
|
||||
|
||||
Reference in New Issue
Block a user