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();
|
||||
|
||||
+324
-94
@@ -3,8 +3,10 @@ import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ApiError,
|
||||
fetchTariff,
|
||||
isTariffV2,
|
||||
publishTariffVersion,
|
||||
type TariffBlock,
|
||||
type TariffCard,
|
||||
type TariffStructure,
|
||||
type TariffState,
|
||||
} from "./api.js";
|
||||
@@ -24,37 +26,63 @@ interface BlockForm {
|
||||
hours: string; // duration of THIS band, in hours (ignored for the last block)
|
||||
price: string; // major units, e.g. "2.00"
|
||||
}
|
||||
// A pricing body the form edits: either a flat rate or a block ladder.
|
||||
interface PricingForm {
|
||||
mode: "ladder" | "flat";
|
||||
flat: string; // major units (used when mode==="flat")
|
||||
blocks: BlockForm[]; // hours-based ladder (used when mode==="ladder")
|
||||
dailyCap: string; // "" = no cap (ladder only)
|
||||
}
|
||||
// An optional time/category TIER (a V2 windowed card). Absent windows = unconstrained.
|
||||
interface TierForm {
|
||||
name: string;
|
||||
priority: string;
|
||||
category: string; // "" = applies to all categories
|
||||
dow: number[]; // selected days 0..6; empty = every day
|
||||
fromHour: string; // "" = all day
|
||||
toHour: string;
|
||||
dateFrom: string; // "" = unbounded
|
||||
dateTo: string;
|
||||
pricing: PricingForm;
|
||||
}
|
||||
interface FormState {
|
||||
currency: string;
|
||||
gracePeriodEntryMin: string;
|
||||
incrementMin: string;
|
||||
dailyCap: string; // "" = no cap
|
||||
lostTicket: string;
|
||||
gracePeriodExitMin: string;
|
||||
blocks: BlockForm[];
|
||||
// The default (always-active) card — its own flat/ladder body + daily cap.
|
||||
base: PricingForm;
|
||||
// Optional time/category tiers. Empty ⇒ a bare V1 structure is published.
|
||||
tiers: TierForm[];
|
||||
}
|
||||
|
||||
const toMinor = (major: string): number => Math.round(parseFloat(major || "0") * 100);
|
||||
const toMajor = (minor: number): string => (minor / 100).toFixed(2);
|
||||
|
||||
function emptyLadder(): PricingForm {
|
||||
return { mode: "ladder", flat: "0.00", dailyCap: "", blocks: [{ hours: "1", price: "2.00" }, { hours: "", price: "1.00" }] };
|
||||
}
|
||||
function emptyTier(): TierForm {
|
||||
return { name: "", priority: "10", category: "", dow: [], fromHour: "", toHour: "", dateFrom: "", dateTo: "", pricing: { ...emptyLadder(), blocks: [{ hours: "", price: "1.00" }] } };
|
||||
}
|
||||
|
||||
function emptyForm(): FormState {
|
||||
return {
|
||||
currency: "EUR",
|
||||
gracePeriodEntryMin: "15",
|
||||
incrementMin: "60",
|
||||
dailyCap: "",
|
||||
lostTicket: "20.00",
|
||||
gracePeriodExitMin: "15",
|
||||
blocks: [{ hours: "1", price: "2.00" }, { hours: "", price: "1.00" }],
|
||||
base: emptyLadder(),
|
||||
tiers: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Convert a published structure's cumulative `uptoMin` (minutes) back into the
|
||||
// per-band hours the form edits. Each band's hours = (its bound − previous bound)
|
||||
// / 60; the open-ended last band has no hours. Legacy versions whose last block is
|
||||
// bounded (pre-2026-06-18, before open-ended was required) still load: the bounded
|
||||
// tail simply shows as its own band and the operator adds/keeps an open-ended one.
|
||||
function blocksToForm(blocks: TariffStructure["blocks"]): BlockForm[] {
|
||||
// Convert a stored block ladder's cumulative `uptoMin` (minutes) into the per-band
|
||||
// hours the form edits. Open-ended last band has no hours. Legacy bounded tails still
|
||||
// load (shown as their own band).
|
||||
function blocksToForm(blocks: TariffBlock[]): BlockForm[] {
|
||||
let prev = 0;
|
||||
return blocks.map((b) => {
|
||||
if (b.uptoMin == null) return { hours: "", price: toMajor(b.priceMinorPerIncrement) };
|
||||
@@ -64,41 +92,112 @@ function blocksToForm(blocks: TariffStructure["blocks"]): BlockForm[] {
|
||||
});
|
||||
}
|
||||
|
||||
// A stored card (V2) or bare-V1 body → the form's PricingForm (flat or ladder).
|
||||
function pricingFromCard(c: { flatMinor?: number; blocks?: TariffBlock[]; dailyCapMinor?: number | null }): PricingForm {
|
||||
if (c.flatMinor != null) {
|
||||
return { mode: "flat", flat: toMajor(c.flatMinor), dailyCap: "", blocks: emptyLadder().blocks };
|
||||
}
|
||||
return {
|
||||
mode: "ladder",
|
||||
flat: "0.00",
|
||||
dailyCap: c.dailyCapMinor == null ? "" : toMajor(c.dailyCapMinor),
|
||||
blocks: blocksToForm(c.blocks ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
function tierFromCard(c: TariffCard): TierForm {
|
||||
const w = c.window ?? {};
|
||||
return {
|
||||
name: c.name,
|
||||
priority: String(c.priority),
|
||||
category: c.category ?? "",
|
||||
dow: w.dow ? [...w.dow] : [],
|
||||
fromHour: w.fromHour ?? "",
|
||||
toHour: w.toHour ?? "",
|
||||
dateFrom: w.dateFrom ?? "",
|
||||
dateTo: w.dateTo ?? "",
|
||||
pricing: pricingFromCard(c),
|
||||
};
|
||||
}
|
||||
|
||||
function formFromActive(s: TariffState): FormState {
|
||||
const v = s.active;
|
||||
if (!v) return emptyForm();
|
||||
const st = v.structure;
|
||||
return {
|
||||
const common = {
|
||||
currency: v.currency,
|
||||
gracePeriodEntryMin: String(st.gracePeriodEntryMin),
|
||||
incrementMin: String(st.incrementMin),
|
||||
dailyCap: st.dailyCapMinor == null ? "" : toMajor(st.dailyCapMinor),
|
||||
lostTicket: toMajor(st.lostTicketMinor),
|
||||
gracePeriodExitMin: String(st.gracePeriodExitMin),
|
||||
blocks: blocksToForm(st.blocks),
|
||||
};
|
||||
if (isTariffV2(st)) {
|
||||
return { ...common, base: pricingFromCard(st.defaultCard), tiers: (st.windowedCards ?? []).map(tierFromCard) };
|
||||
}
|
||||
// V1: the bare ladder becomes the default card body; no tiers.
|
||||
return { ...common, base: pricingFromCard(st), tiers: [] };
|
||||
}
|
||||
|
||||
// Build a tariff card's pricing body (flat XOR ladder) from a PricingForm.
|
||||
function pricingToCardBody(p: PricingForm): Pick<TariffCard, "flatMinor" | "blocks" | "dailyCapMinor"> {
|
||||
if (p.mode === "flat") return { flatMinor: toMinor(p.flat) };
|
||||
// Accumulate each band's hours into cumulative uptoMin (min); last band open-ended.
|
||||
const last = p.blocks.length - 1;
|
||||
let cum = 0;
|
||||
const blocks: TariffBlock[] = p.blocks.map((b, i) => {
|
||||
if (i === last) return { uptoMin: null, priceMinorPerIncrement: toMinor(b.price) };
|
||||
cum += Math.round(Number(b.hours || "0") * 60);
|
||||
return { uptoMin: cum, priceMinorPerIncrement: toMinor(b.price) };
|
||||
});
|
||||
return { blocks, dailyCapMinor: p.dailyCap.trim() === "" ? null : toMinor(p.dailyCap) };
|
||||
}
|
||||
|
||||
function tierToCard(tr: TierForm): TariffCard {
|
||||
const window: TariffCard["window"] = {};
|
||||
if (tr.dow.length > 0) window.dow = [...tr.dow].sort((a, b) => a - b);
|
||||
if (tr.fromHour && tr.toHour) {
|
||||
window.fromHour = tr.fromHour;
|
||||
window.toHour = tr.toHour;
|
||||
}
|
||||
if (tr.dateFrom) window.dateFrom = tr.dateFrom;
|
||||
if (tr.dateTo) window.dateTo = tr.dateTo;
|
||||
const card: TariffCard = {
|
||||
name: tr.name.trim() || "tier",
|
||||
priority: Math.round(Number(tr.priority || "0")),
|
||||
...pricingToCardBody(tr.pricing),
|
||||
};
|
||||
if (tr.category.trim()) card.category = tr.category.trim();
|
||||
if (Object.keys(window).length > 0) card.window = window;
|
||||
return card;
|
||||
}
|
||||
|
||||
function toStructure(f: FormState): TariffStructure {
|
||||
// Accumulate each band's DURATION (hours) into the engine's cumulative `uptoMin`
|
||||
// (minutes). The LAST band is always open-ended (uptoMin null) — its hours are
|
||||
// ignored — so the published structure always satisfies the "last block must be
|
||||
// open-ended" rule (the thereafter-rate is explicit). See wiki/concepts/tariff.md.
|
||||
const last = f.blocks.length - 1;
|
||||
let cumulativeMin = 0;
|
||||
const blocks: TariffBlock[] = f.blocks.map((b, i) => {
|
||||
if (i === last) return { uptoMin: null, priceMinorPerIncrement: toMinor(b.price) };
|
||||
cumulativeMin += Math.round(Number(b.hours || "0") * 60);
|
||||
return { uptoMin: cumulativeMin, priceMinorPerIncrement: toMinor(b.price) };
|
||||
});
|
||||
return {
|
||||
const common = {
|
||||
gracePeriodEntryMin: Math.round(Number(f.gracePeriodEntryMin)),
|
||||
incrementMin: Math.round(Number(f.incrementMin)),
|
||||
blocks,
|
||||
dailyCapMinor: f.dailyCap.trim() === "" ? null : toMinor(f.dailyCap),
|
||||
lostTicketMinor: toMinor(f.lostTicket),
|
||||
gracePeriodExitMin: Math.round(Number(f.gracePeriodExitMin)),
|
||||
overstay: "reprice",
|
||||
overstay: "reprice" as const,
|
||||
};
|
||||
const baseBody = pricingToCardBody(f.base);
|
||||
|
||||
// NO tiers ⇒ publish a BARE V1 structure (back-compat: a site that never wants
|
||||
// tiers gets exactly today's shape; the server leaves it untouched).
|
||||
if (f.tiers.length === 0) {
|
||||
if (f.base.mode === "flat") {
|
||||
// A flat V1: a single open-ended block at the flat rate (V1 has no flat field).
|
||||
return { ...common, blocks: [{ uptoMin: null, priceMinorPerIncrement: toMinor(f.base.flat) }], dailyCapMinor: null };
|
||||
}
|
||||
return { ...common, blocks: baseBody.blocks ?? [], dailyCapMinor: baseBody.dailyCapMinor ?? null };
|
||||
}
|
||||
|
||||
// Tiers present ⇒ V2. tz is stamped server-side from site config (left blank here).
|
||||
return {
|
||||
...common,
|
||||
version: 2,
|
||||
tz: "",
|
||||
defaultCard: { name: "default", priority: 0, ...baseBody },
|
||||
windowedCards: f.tiers.map(tierToCard),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -121,27 +220,49 @@ export function TariffComposer() {
|
||||
function set<K extends keyof FormState>(key: K, value: FormState[K]) {
|
||||
setForm((f) => ({ ...f, [key]: value }));
|
||||
}
|
||||
function setBlock(i: number, patch: Partial<BlockForm>) {
|
||||
setForm((f) => ({ ...f, blocks: f.blocks.map((b, j) => (j === i ? { ...b, ...patch } : b)) }));
|
||||
}
|
||||
// Insert a new bounded band just BEFORE the open-ended "thereafter" tail, so the
|
||||
// last block always stays open-ended.
|
||||
function addBlock() {
|
||||
|
||||
// --- pricing-body editing (used by the default card AND each tier) ---
|
||||
// `update` maps the old PricingForm to a new one; `target` selects which body:
|
||||
// the base card, or tier index N.
|
||||
function updatePricing(target: "base" | number, update: (p: PricingForm) => PricingForm) {
|
||||
setForm((f) => {
|
||||
const tailIdx = f.blocks.length - 1;
|
||||
const next = [...f.blocks];
|
||||
next.splice(tailIdx, 0, { hours: "1", price: "0.00" });
|
||||
return { ...f, blocks: next };
|
||||
if (target === "base") return { ...f, base: update(f.base) };
|
||||
return { ...f, tiers: f.tiers.map((tr, j) => (j === target ? { ...tr, pricing: update(tr.pricing) } : tr)) };
|
||||
});
|
||||
}
|
||||
// Remove a bounded band. The open-ended tail (last row) can't be removed (it's the
|
||||
// required thereafter-rate); the guard also keeps at least the tail present.
|
||||
function removeBlock(i: number) {
|
||||
setForm((f) => {
|
||||
if (i === f.blocks.length - 1 || f.blocks.length <= 1) return f;
|
||||
return { ...f, blocks: f.blocks.filter((_, j) => j !== i) };
|
||||
function setBlock(target: "base" | number, i: number, patch: Partial<BlockForm>) {
|
||||
updatePricing(target, (p) => ({ ...p, blocks: p.blocks.map((b, j) => (j === i ? { ...b, ...patch } : b)) }));
|
||||
}
|
||||
// Insert a bounded band just BEFORE the open-ended tail, so the last block stays open-ended.
|
||||
function addBlock(target: "base" | number) {
|
||||
updatePricing(target, (p) => {
|
||||
const next = [...p.blocks];
|
||||
next.splice(p.blocks.length - 1, 0, { hours: "1", price: "0.00" });
|
||||
return { ...p, blocks: next };
|
||||
});
|
||||
}
|
||||
function removeBlock(target: "base" | number, i: number) {
|
||||
updatePricing(target, (p) => (i === p.blocks.length - 1 || p.blocks.length <= 1 ? p : { ...p, blocks: p.blocks.filter((_, j) => j !== i) }));
|
||||
}
|
||||
|
||||
// --- tier editing ---
|
||||
function setTier(i: number, patch: Partial<TierForm>) {
|
||||
setForm((f) => ({ ...f, tiers: f.tiers.map((tr, j) => (j === i ? { ...tr, ...patch } : tr)) }));
|
||||
}
|
||||
function addTier() {
|
||||
setForm((f) => ({ ...f, tiers: [...f.tiers, emptyTier()] }));
|
||||
}
|
||||
function removeTier(i: number) {
|
||||
setForm((f) => ({ ...f, tiers: f.tiers.filter((_, j) => j !== i) }));
|
||||
}
|
||||
function toggleDow(i: number, d: number) {
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
tiers: f.tiers.map((tr, j) =>
|
||||
j === i ? { ...tr, dow: tr.dow.includes(d) ? tr.dow.filter((x) => x !== d) : [...tr.dow, d] } : tr,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
async function publish() {
|
||||
setSaving(true);
|
||||
@@ -183,62 +304,92 @@ export function TariffComposer() {
|
||||
<input value={form.gracePeriodEntryMin} onChange={(e) => set("gracePeriodEntryMin", e.target.value)} />
|
||||
<label>{t("tariff.billingIncrement")}</label>
|
||||
<input value={form.incrementMin} onChange={(e) => set("incrementMin", e.target.value)} />
|
||||
<label>{t("tariff.dailyCap")}</label>
|
||||
<input value={form.dailyCap} onChange={(e) => set("dailyCap", e.target.value)} placeholder={t("tariff.dailyCapPh")} />
|
||||
<label>{t("tariff.lostTicketFee")}</label>
|
||||
<input value={form.lostTicket} onChange={(e) => set("lostTicket", e.target.value)} />
|
||||
<label>{t("tariff.exitGrace")}</label>
|
||||
<input value={form.gracePeriodExitMin} onChange={(e) => set("gracePeriodExitMin", e.target.value)} />
|
||||
</div>
|
||||
|
||||
<h3 style={{ marginBottom: "0.25rem" }}>{t("tariff.rateBlocks")}</h3>
|
||||
<p style={{ color: "#777", margin: "0 0 0.5rem", fontSize: "0.9em" }}>{t("tariff.rateBlocksHint")}</p>
|
||||
<table style={{ borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: "left", color: "#555" }}>
|
||||
<th style={{ padding: "0 0.5rem" }}>{t("tariff.bandDuration")}</th>
|
||||
<th style={{ padding: "0 0.5rem" }}>{t("tariff.pricePerIncrement")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{form.blocks.map((b, i) => {
|
||||
const isTail = i === form.blocks.length - 1;
|
||||
return (
|
||||
<tr key={i}>
|
||||
<td style={{ padding: "0.15rem 0.5rem" }}>
|
||||
{isTail ? (
|
||||
<span style={{ color: "#777", fontStyle: "italic" }}>{t("tariff.thereafter")}</span>
|
||||
) : (
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.3rem" }}>
|
||||
<input
|
||||
value={b.hours}
|
||||
onChange={(e) => setBlock(i, { hours: e.target.value })}
|
||||
placeholder={t("tariff.egHours")}
|
||||
style={{ width: 70 }}
|
||||
/>
|
||||
<span style={{ color: "#777" }}>{t("tariff.hoursUnit")}</span>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: "0.15rem 0.5rem" }}>
|
||||
<input value={b.price} onChange={(e) => setBlock(i, { price: e.target.value })} style={{ width: 90 }} />
|
||||
</td>
|
||||
<td>
|
||||
{!isTail && (
|
||||
<button type="button" onClick={() => removeBlock(i)}>
|
||||
{t("tariff.remove")}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<button type="button" onClick={addBlock} style={{ marginTop: "0.4rem" }}>
|
||||
{t("tariff.addBlock")}
|
||||
</button>
|
||||
{/* The DEFAULT card — always-active rate. Front-and-centre; a site that never
|
||||
wants tiers just edits this and publishes a bare V1 structure. */}
|
||||
<h3 style={{ marginBottom: "0.25rem" }}>{t("tariff.defaultCard")}</h3>
|
||||
<p style={{ color: "#777", margin: "0 0 0.5rem", fontSize: "0.9em" }}>{t("tariff.defaultCardHint")}</p>
|
||||
<PricingEditor
|
||||
t={t}
|
||||
pricing={form.base}
|
||||
onMode={(mode) => updatePricing("base", (p) => ({ ...p, mode }))}
|
||||
onFlat={(flat) => updatePricing("base", (p) => ({ ...p, flat }))}
|
||||
onCap={(dailyCap) => updatePricing("base", (p) => ({ ...p, dailyCap }))}
|
||||
onBlock={(i, patch) => setBlock("base", i, patch)}
|
||||
onAddBlock={() => addBlock("base")}
|
||||
onRemoveBlock={(i) => removeBlock("base", i)}
|
||||
/>
|
||||
|
||||
{/* Advanced: time & seasonal/category TIERS (opt-in). Empty ⇒ V1 is published. */}
|
||||
<details style={{ marginTop: "1.25rem" }} open={form.tiers.length > 0}>
|
||||
<summary style={{ cursor: "pointer", fontWeight: 600 }}>{t("tariff.tiersAdvanced")}</summary>
|
||||
<p style={{ color: "#777", margin: "0.4rem 0", fontSize: "0.9em" }}>{t("tariff.tiersHint")}</p>
|
||||
{form.tiers.map((tr, i) => (
|
||||
<fieldset key={i} style={{ border: "1px solid #ddd", borderRadius: 6, padding: "0.6rem 0.8rem", marginBottom: "0.75rem" }}>
|
||||
<legend style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
|
||||
<input
|
||||
value={tr.name}
|
||||
onChange={(e) => setTier(i, { name: e.target.value })}
|
||||
placeholder={t("tariff.tierName")}
|
||||
style={{ width: 140 }}
|
||||
/>
|
||||
<button type="button" onClick={() => removeTier(i)}>
|
||||
{t("tariff.remove")}
|
||||
</button>
|
||||
</legend>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.35rem 0.75rem", alignItems: "center", maxWidth: 520 }}>
|
||||
<label>{t("tariff.tierPriority")}</label>
|
||||
<input value={tr.priority} onChange={(e) => setTier(i, { priority: e.target.value })} style={{ width: 70 }} />
|
||||
<label>{t("tariff.tierCategory")}</label>
|
||||
<input value={tr.category} onChange={(e) => setTier(i, { category: e.target.value })} placeholder={t("tariff.tierCategoryPh")} style={{ width: 140 }} />
|
||||
<label>{t("tariff.tierDays")}</label>
|
||||
<span style={{ display: "flex", gap: "0.3rem", flexWrap: "wrap" }}>
|
||||
{[1, 2, 3, 4, 5, 6, 0].map((d) => (
|
||||
<label key={d} style={{ display: "inline-flex", alignItems: "center", gap: "0.15rem", fontSize: "0.85em" }}>
|
||||
<input type="checkbox" checked={tr.dow.includes(d)} onChange={() => toggleDow(i, d)} />
|
||||
{t(`tariff.dow${d}`)}
|
||||
</label>
|
||||
))}
|
||||
</span>
|
||||
<label>{t("tariff.tierHours")}</label>
|
||||
<span style={{ display: "inline-flex", gap: "0.3rem", alignItems: "center" }}>
|
||||
<input value={tr.fromHour} onChange={(e) => setTier(i, { fromHour: e.target.value })} placeholder="22:00" style={{ width: 70 }} />
|
||||
<span>–</span>
|
||||
<input value={tr.toHour} onChange={(e) => setTier(i, { toHour: e.target.value })} placeholder="06:00" style={{ width: 70 }} />
|
||||
{tr.fromHour && tr.toHour && tr.toHour <= tr.fromHour && (
|
||||
<span style={{ color: "#777", fontSize: "0.8em" }}>{t("tariff.tierOvernight")}</span>
|
||||
)}
|
||||
</span>
|
||||
<label>{t("tariff.tierDates")}</label>
|
||||
<span style={{ display: "inline-flex", gap: "0.3rem", alignItems: "center" }}>
|
||||
<input type="date" value={tr.dateFrom} onChange={(e) => setTier(i, { dateFrom: e.target.value })} />
|
||||
<span>–</span>
|
||||
<input type="date" value={tr.dateTo} onChange={(e) => setTier(i, { dateTo: e.target.value })} />
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ marginTop: "0.5rem" }}>
|
||||
<PricingEditor
|
||||
t={t}
|
||||
pricing={tr.pricing}
|
||||
onMode={(mode) => updatePricing(i, (p) => ({ ...p, mode }))}
|
||||
onFlat={(flat) => updatePricing(i, (p) => ({ ...p, flat }))}
|
||||
onCap={(dailyCap) => updatePricing(i, (p) => ({ ...p, dailyCap }))}
|
||||
onBlock={(bi, patch) => setBlock(i, bi, patch)}
|
||||
onAddBlock={() => addBlock(i)}
|
||||
onRemoveBlock={(bi) => removeBlock(i, bi)}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
))}
|
||||
<button type="button" onClick={addTier}>
|
||||
{t("tariff.addTier")}
|
||||
</button>
|
||||
</details>
|
||||
|
||||
<div style={{ marginTop: "1rem" }}>
|
||||
<button type="button" onClick={publish} disabled={saving}>
|
||||
@@ -251,3 +402,82 @@ export function TariffComposer() {
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// A reusable flat/ladder pricing-body editor — used by the default card and each tier.
|
||||
function PricingEditor(props: {
|
||||
t: (k: string) => string;
|
||||
pricing: PricingForm;
|
||||
onMode: (m: "ladder" | "flat") => void;
|
||||
onFlat: (v: string) => void;
|
||||
onCap: (v: string) => void;
|
||||
onBlock: (i: number, patch: Partial<BlockForm>) => void;
|
||||
onAddBlock: () => void;
|
||||
onRemoveBlock: (i: number) => void;
|
||||
}) {
|
||||
const { t, pricing: p } = props;
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: "flex", gap: "1rem", marginBottom: "0.4rem", fontSize: "0.9em" }}>
|
||||
<label style={{ display: "inline-flex", gap: "0.25rem", alignItems: "center" }}>
|
||||
<input type="radio" checked={p.mode === "ladder"} onChange={() => props.onMode("ladder")} />
|
||||
{t("tariff.modeLadder")}
|
||||
</label>
|
||||
<label style={{ display: "inline-flex", gap: "0.25rem", alignItems: "center" }}>
|
||||
<input type="radio" checked={p.mode === "flat"} onChange={() => props.onMode("flat")} />
|
||||
{t("tariff.modeFlat")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{p.mode === "flat" ? (
|
||||
<div style={{ display: "inline-flex", gap: "0.4rem", alignItems: "center" }}>
|
||||
<span style={{ color: "#777" }}>{t("tariff.pricePerIncrement")}</span>
|
||||
<input value={p.flat} onChange={(e) => props.onFlat(e.target.value)} style={{ width: 90 }} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table style={{ borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: "left", color: "#555" }}>
|
||||
<th style={{ padding: "0 0.5rem" }}>{t("tariff.bandDuration")}</th>
|
||||
<th style={{ padding: "0 0.5rem" }}>{t("tariff.pricePerIncrement")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{p.blocks.map((b, i) => {
|
||||
const isTail = i === p.blocks.length - 1;
|
||||
return (
|
||||
<tr key={i}>
|
||||
<td style={{ padding: "0.15rem 0.5rem" }}>
|
||||
{isTail ? (
|
||||
<span style={{ color: "#777", fontStyle: "italic" }}>{t("tariff.thereafter")}</span>
|
||||
) : (
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.3rem" }}>
|
||||
<input value={b.hours} onChange={(e) => props.onBlock(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} style={{ width: 70 }} />
|
||||
<span style={{ color: "#777" }}>{t("tariff.hoursUnit")}</span>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: "0.15rem 0.5rem" }}>
|
||||
<input value={b.price} onChange={(e) => props.onBlock(i, { price: e.target.value })} style={{ width: 90 }} />
|
||||
</td>
|
||||
<td>{!isTail && <button type="button" onClick={() => props.onRemoveBlock(i)}>{t("tariff.remove")}</button>}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div style={{ marginTop: "0.4rem", display: "flex", gap: "1rem", alignItems: "center" }}>
|
||||
<button type="button" onClick={props.onAddBlock}>
|
||||
{t("tariff.addBlock")}
|
||||
</button>
|
||||
<span style={{ display: "inline-flex", gap: "0.3rem", alignItems: "center", fontSize: "0.9em" }}>
|
||||
<span style={{ color: "#777" }}>{t("tariff.dailyCap")}</span>
|
||||
<input value={p.dailyCap} onChange={(e) => props.onCap(e.target.value)} placeholder={t("tariff.dailyCapPh")} style={{ width: 90 }} />
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+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> {
|
||||
|
||||
@@ -120,6 +120,28 @@ export const en: Catalog = {
|
||||
publishNewVersion: "Publish new version",
|
||||
publishing: "Publishing…",
|
||||
publishedOk: "New tariff version published — it's now the active rate card.",
|
||||
defaultCard: "Default card (always active)",
|
||||
defaultCardHint: "The base rate applied when no time/seasonal tier matches. This alone is enough for most car parks.",
|
||||
modeLadder: "Hourly ladder",
|
||||
modeFlat: "Flat price",
|
||||
tiersAdvanced: "Advanced: time & seasonal tiers",
|
||||
tiersHint: "Optional. Add cards that apply only at certain hours/days/dates or for a category (e.g. happy hour, night rate, weekend, bus). With no tiers, the simple card is published.",
|
||||
tierName: "Name",
|
||||
tierPriority: "Priority",
|
||||
tierCategory: "Category",
|
||||
tierCategoryPh: "e.g. bus",
|
||||
tierDays: "Days",
|
||||
tierHours: "Hours",
|
||||
tierDates: "Dates",
|
||||
tierOvernight: "(crosses midnight)",
|
||||
addTier: "+ Add tier",
|
||||
dow1: "Mon",
|
||||
dow2: "Tue",
|
||||
dow3: "Wed",
|
||||
dow4: "Thu",
|
||||
dow5: "Fri",
|
||||
dow6: "Sat",
|
||||
dow0: "Sun",
|
||||
},
|
||||
subs: {
|
||||
title: "Subscriptions",
|
||||
|
||||
@@ -122,6 +122,28 @@ export const sq = {
|
||||
publishNewVersion: "Publiko version të ri",
|
||||
publishing: "Duke publikuar…",
|
||||
publishedOk: "U publikua versioni i ri i tarifës — tani është karta tarifore aktive.",
|
||||
defaultCard: "Karta e parazgjedhur (gjithmonë aktive)",
|
||||
defaultCardHint: "Çmimi bazë i zbatuar kur asnjë nivel kohor/sezonal nuk vlen. Kjo e vetme është mjaftueshëm për shumicën e parkimeve.",
|
||||
modeLadder: "Shkallë orësh",
|
||||
modeFlat: "Çmim fiks",
|
||||
tiersAdvanced: "Të avancuara: nivele kohore & sezonale",
|
||||
tiersHint: "Opsionale. Shto karta që vlejnë vetëm në orë/ditë/data ose kategori të caktuara (p.sh. orë e lirë, tarifë nate, fundjavë, autobus). Pa nivele, publikohet karta e thjeshtë.",
|
||||
tierName: "Emri",
|
||||
tierPriority: "Përparësia",
|
||||
tierCategory: "Kategoria",
|
||||
tierCategoryPh: "p.sh. autobus",
|
||||
tierDays: "Ditët",
|
||||
tierHours: "Orët",
|
||||
tierDates: "Datat",
|
||||
tierOvernight: "(kalon mesnatën)",
|
||||
addTier: "+ Shto nivel",
|
||||
dow1: "Hën",
|
||||
dow2: "Mar",
|
||||
dow3: "Mër",
|
||||
dow4: "Enj",
|
||||
dow5: "Pre",
|
||||
dow6: "Sht",
|
||||
dow0: "Die",
|
||||
},
|
||||
subs: {
|
||||
title: "Abonimet",
|
||||
|
||||
Reference in New Issue
Block a user