Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 486f8deae6 | |||
| 3e6773a6d5 | |||
| cf1ff5676d | |||
| 91cc79b14e |
@@ -24,3 +24,4 @@ dist/
|
||||
|
||||
# Graphify knowledge-graph output (dev tool; generated, not committed)
|
||||
graphify-out/
|
||||
parking.sqlite*.bak-*
|
||||
@@ -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();
|
||||
|
||||
@@ -335,7 +335,7 @@ export class ShiftService {
|
||||
`Expected drawer: ${money(r.expectedDrawerMinor)} ${cur}`,
|
||||
];
|
||||
try {
|
||||
await printer.printReport({ title: "SHIFT Z-REPORT", lines });
|
||||
await printer.printReport({ title: "RAPORT TURNI", lines });
|
||||
return true;
|
||||
} catch (err) {
|
||||
this.#logger.warn(`Z-report print failed for ${r.operator}: ${(err as Error).message} (event recorded)`);
|
||||
|
||||
+319
-89
@@ -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 }}
|
||||
{/* 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)}
|
||||
/>
|
||||
<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)}>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<button type="button" onClick={addBlock} style={{ marginTop: "0.4rem" }}>
|
||||
{t("tariff.addBlock")}
|
||||
</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> {
|
||||
|
||||
+154
-16
@@ -3,28 +3,159 @@
|
||||
/* Bloomberg-terminal aesthetic: dense, dark, monospace, keyboard-first.
|
||||
Tailwind v4 — design tokens live here in @theme (no tailwind.config.js).
|
||||
The booth runs on a fixed appliance display; we optimise for a dark room,
|
||||
glanceable status colour, and high information density over whitespace. */
|
||||
glanceable status colour, and high information density over whitespace.
|
||||
|
||||
Token vocabulary adopted from the "TRM" design system (Tracking & Race
|
||||
Management) — TOKENS ONLY: colours, type scale, spacing, radii, shadows.
|
||||
None of TRM's race-timing components are used. The existing `term-*` accents
|
||||
are aligned onto TRM's exact values so the whole booth UI inherits the TRM
|
||||
palette without renaming a single class. TRM's full vocabulary is also
|
||||
exposed as utilities (night-*, ink-*, paper-*, flag/amber/green/blue, the
|
||||
spacing/type/shadow scales) for new work.
|
||||
|
||||
Offline appliance: NO webfont @import (no network at runtime). Goldplay (the
|
||||
TRM display face) is not self-hosted yet — display/heading text falls back to
|
||||
a clean sans stack; wire local Goldplay @font-face here if it's wanted. */
|
||||
@theme {
|
||||
/* Surfaces — near-black, layered greys for panels/borders. */
|
||||
--color-term-bg: #0a0e12;
|
||||
--color-term-panel: #11161c;
|
||||
--color-term-panel-2: #161d25;
|
||||
--color-term-border: #232c37;
|
||||
--color-term-muted: #6b7785;
|
||||
--color-term-text: #c9d3de;
|
||||
/* ============================================================
|
||||
TERMINAL ACCENTS — aligned onto TRM's exact values.
|
||||
These keep their `term-*` names (used across every screen),
|
||||
but now resolve to TRM colours so the palette is unified.
|
||||
============================================================ */
|
||||
/* Surfaces — TRM "night" (trackside dark) scale. */
|
||||
--color-term-bg: #0b0d10; /* TRM --night */
|
||||
--color-term-panel: #14171c; /* TRM --night-2 */
|
||||
--color-term-panel-2: #1e222a; /* TRM --night-3 */
|
||||
--color-term-border: #2a2f38; /* TRM --night-line */
|
||||
--color-term-muted: #8a8a82; /* TRM --night-fg-3 / --ink-4 */
|
||||
--color-term-text: #f2f2ee; /* TRM --night-fg */
|
||||
|
||||
/* Status accents — the terminal's signal colours. */
|
||||
--color-term-amber: #f5a623; /* primary accent / headings / focus */
|
||||
--color-term-green: #2ecc71; /* entry / ok / free */
|
||||
--color-term-red: #ff4d4f; /* exit / fault / full */
|
||||
--color-term-cyan: #38bdf8; /* payment / info */
|
||||
/* Status accents — TRM semantic colours. */
|
||||
--color-term-amber: #f2a516; /* TRM --amber (caution / accent / focus) */
|
||||
--color-term-green: #2e8c4a; /* TRM --green (entry / ok / free) */
|
||||
--color-term-red: #e8412b; /* TRM --flag (exit / fault / full) */
|
||||
--color-term-cyan: #2563c8; /* TRM --blue (payment / info / live) */
|
||||
|
||||
/* Monospace stack — IBM Plex Mono / JetBrains first, system mono fallback. */
|
||||
/* ============================================================
|
||||
TRM FULL VOCABULARY — exposed as Tailwind utilities for new work.
|
||||
============================================================ */
|
||||
/* Ink & paper (light surfaces — for any light-on-dark inversions). */
|
||||
--color-paper: #fafaf7;
|
||||
--color-paper-2: #f2f2ee;
|
||||
--color-paper-3: #e8e8e2;
|
||||
--color-ink: #0e0e0c;
|
||||
--color-ink-2: #2a2a26;
|
||||
--color-ink-3: #5a5a53;
|
||||
--color-ink-4: #8a8a82;
|
||||
--color-ink-5: #b8b8b0;
|
||||
--color-ink-6: #dcdcd4;
|
||||
|
||||
/* Night scale (the booth's working surfaces). */
|
||||
--color-night: #0b0d10;
|
||||
--color-night-2: #14171c;
|
||||
--color-night-3: #1e222a;
|
||||
--color-night-line: #2a2f38;
|
||||
--color-night-fg: #f2f2ee;
|
||||
--color-night-fg-2: #b8b8b0;
|
||||
--color-night-fg-3: #8a8a82;
|
||||
|
||||
/* Race accents + semantic. */
|
||||
--color-flag: #e8412b;
|
||||
--color-flag-2: #c8331f;
|
||||
--color-flag-tint: #fbe3de;
|
||||
--color-amber: #f2a516;
|
||||
--color-amber-2: #c88500;
|
||||
--color-amber-tint: #fbefd0;
|
||||
--color-green: #2e8c4a;
|
||||
--color-green-2: #1f6a36;
|
||||
--color-green-tint: #ddefe2;
|
||||
--color-blue: #2563c8;
|
||||
--color-blue-2: #1a4fa8;
|
||||
--color-blue-tint: #dce6f8;
|
||||
--color-violet: #6b46c1;
|
||||
--color-magenta: #c9296f;
|
||||
--color-teal: #188c8a;
|
||||
|
||||
--color-ok: #2e8c4a;
|
||||
--color-warn: #f2a516;
|
||||
--color-danger: #e8412b;
|
||||
--color-info: #2563c8;
|
||||
|
||||
/* Data-viz categorical (8). */
|
||||
--color-viz-1: #e8412b;
|
||||
--color-viz-2: #2563c8;
|
||||
--color-viz-3: #2e8c4a;
|
||||
--color-viz-4: #f2a516;
|
||||
--color-viz-5: #6b46c1;
|
||||
--color-viz-6: #188c8a;
|
||||
--color-viz-7: #c9296f;
|
||||
--color-viz-8: #5a5a53;
|
||||
|
||||
/* ---------- TYPE — families ---------- */
|
||||
/* Mono is the booth's primary face (data-dense, tabular). Display/UI fall
|
||||
back to a clean sans (Goldplay not self-hosted — see header note). */
|
||||
--font-mono: "JetBrains Mono", "IBM Plex Mono", ui-monospace, "SFMono-Regular",
|
||||
"Menlo", "Consolas", monospace;
|
||||
--font-display: "Goldplay", "Helvetica Neue", Arial, sans-serif;
|
||||
--font-ui: "Goldplay", "Helvetica Neue", Arial, sans-serif;
|
||||
--font-body: "Inter", "Helvetica Neue", Arial, sans-serif;
|
||||
|
||||
/* Tight radius — terminals are square. */
|
||||
--radius-term: 2px;
|
||||
/* ---------- TYPE — scale (TRM, optimised for data density) ---------- */
|
||||
--text-overline: 11px;
|
||||
--text-micro: 12px;
|
||||
--text-small: 13px;
|
||||
--text-body: 15px;
|
||||
--text-lead: 17px;
|
||||
--text-h6: 14px;
|
||||
--text-h5: 16px;
|
||||
--text-h4: 20px;
|
||||
--text-h3: 26px;
|
||||
--text-h2: 34px;
|
||||
--text-h1: 48px;
|
||||
--text-display: 72px;
|
||||
--text-jumbo: 120px;
|
||||
|
||||
/* ---------- SPACING (TRM 4px base) ---------- */
|
||||
--spacing-s0: 0;
|
||||
--spacing-s1: 2px;
|
||||
--spacing-s2: 4px;
|
||||
--spacing-s3: 8px;
|
||||
--spacing-s4: 12px;
|
||||
--spacing-s5: 16px;
|
||||
--spacing-s6: 20px;
|
||||
--spacing-s7: 24px;
|
||||
--spacing-s8: 32px;
|
||||
--spacing-s9: 40px;
|
||||
--spacing-s10: 48px;
|
||||
--spacing-s11: 64px;
|
||||
--spacing-s12: 80px;
|
||||
--spacing-s13: 96px;
|
||||
|
||||
/* ---------- RADIUS — TRM is square-edged ---------- */
|
||||
--radius-term: 2px; /* existing alias, kept */
|
||||
--radius-r0: 0;
|
||||
--radius-r1: 2px;
|
||||
--radius-r2: 4px;
|
||||
--radius-r3: 6px;
|
||||
--radius-r4: 10px;
|
||||
|
||||
/* ---------- ELEVATION — TRM sharp "printed" offset shadows ---------- */
|
||||
--shadow-term-1: 0 1px 0 0 #0e0e0c;
|
||||
--shadow-term-2: 2px 2px 0 0 #0e0e0c;
|
||||
--shadow-term-3: 4px 4px 0 0 #0e0e0c;
|
||||
--shadow-soft: 0 1px 2px rgba(14, 14, 12, 0.06), 0 4px 12px rgba(14, 14, 12, 0.04);
|
||||
--shadow-pop: 0 8px 24px rgba(14, 14, 12, 0.12);
|
||||
|
||||
/* ---------- MOTION (TRM) ---------- */
|
||||
--ease-snap: cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
/* ---------- COMPONENT TOKENS (TRM control heights, table rows) ---------- */
|
||||
--control-h-sm: 28px;
|
||||
--control-h-md: 36px;
|
||||
--control-h-lg: 44px;
|
||||
--table-row-h: 36px;
|
||||
--table-row-h-dense: 28px;
|
||||
}
|
||||
|
||||
html,
|
||||
@@ -45,6 +176,13 @@ body {
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
/* Tabular numerics everywhere — counts, money, clocks must not jitter. */
|
||||
.num,
|
||||
.tabular {
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* Terminal scrollbars — thin, dark, unobtrusive. */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
|
||||
@@ -44,9 +44,9 @@ export const en: Catalog = {
|
||||
entry: "entry",
|
||||
exit: "exit",
|
||||
both: "entry/exit",
|
||||
mixed: "mixed",
|
||||
lane: "lane",
|
||||
booth: "booth",
|
||||
mixed: "entry/exit",
|
||||
lane: "at lane",
|
||||
booth: "at booth",
|
||||
},
|
||||
state: {
|
||||
ready: "ready",
|
||||
@@ -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",
|
||||
|
||||
@@ -44,9 +44,9 @@ export const sq = {
|
||||
entry: "hyrje",
|
||||
exit: "dalje",
|
||||
both: "hyrje/dalje",
|
||||
mixed: "i përzier",
|
||||
lane: "korsia",
|
||||
booth: "kabina",
|
||||
mixed: "hyrje/dalje",
|
||||
lane: "në korsi",
|
||||
booth: "në kabinë",
|
||||
},
|
||||
state: {
|
||||
ready: "gati",
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `site_config` ADD `timezone` text;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `site_config` ADD `default_vehicle_category` text;
|
||||
@@ -36,6 +36,20 @@
|
||||
"when": 1781800000000,
|
||||
"tag": "0004_subscriptions_rename",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "6",
|
||||
"when": 1781884800000,
|
||||
"tag": "0005_site_timezone",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "6",
|
||||
"when": 1781884900000,
|
||||
"tag": "0006_site_default_category",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -171,6 +171,19 @@ export const siteConfig = sqliteTable("site_config", {
|
||||
* own price and may differ. null = no site default set. See
|
||||
* wiki/entities/subscription.md. */
|
||||
subscriptionMonthlyPriceMinor: integer("subscription_monthly_price_minor"),
|
||||
/** IANA timezone the site operates in (e.g. "Europe/Tirane"). Used to evaluate a
|
||||
* tariff's wall-clock pricing windows (happy hour / night / seasonal). COPIED into
|
||||
* each published tariff version's structure.tz so the windows are frozen/immutable
|
||||
* per version — historical sessions reprice deterministically regardless of any
|
||||
* later config change. null/absent ⇒ default "Europe/Tirane" at publish time.
|
||||
* See wiki/concepts/tariff-time-tiers.md. */
|
||||
timezone: text("timezone"),
|
||||
/** Default vehicle/customer category assigned to a transient entry when none is
|
||||
* captured at the lane (every transient today). Operator policy — a plain car park
|
||||
* leaves it "default"; a mixed lot might set "car". Frozen into each vehicle_entry
|
||||
* payload so V2 category pricing reprices identically at exit. null ⇒ the shared
|
||||
* DEFAULT_VEHICLE_CATEGORY fallback. See wiki/concepts/tariff-time-tiers.md. */
|
||||
defaultVehicleCategory: text("default_vehicle_category"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { registry } from "../registry.js";
|
||||
import { dingtianDriver } from "./access-dingtian.js";
|
||||
import { stubAccessDriver } from "./access-stub.js";
|
||||
import { dahuaDriver, hikvisionDriver } from "./camera.js";
|
||||
import { cashinoDriver } from "./printer-cashino.js";
|
||||
import { rongtaDriver } from "./printer-rongta.js";
|
||||
import { geeQrReaderDriver, tcpipReaderDriver, wiegandReaderDriver } from "./reader.js";
|
||||
|
||||
@@ -22,6 +23,7 @@ export function registerBuiltinDrivers(): void {
|
||||
registry.register(hikvisionDriver);
|
||||
registry.register(dahuaDriver);
|
||||
registry.register(rongtaDriver);
|
||||
registry.register(cashinoDriver);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -33,4 +35,5 @@ export {
|
||||
hikvisionDriver,
|
||||
dahuaDriver,
|
||||
rongtaDriver,
|
||||
cashinoDriver,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import type {
|
||||
DeviceHealth,
|
||||
PrinterDevice,
|
||||
PrintReport,
|
||||
SubscriptionCardData,
|
||||
TicketData,
|
||||
} from "../interfaces.js";
|
||||
import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
|
||||
import { hostField, portField, stubLog } from "./common.js";
|
||||
import {
|
||||
probe,
|
||||
renderReport,
|
||||
renderSubscriptionCard,
|
||||
renderTicket,
|
||||
sendRaw,
|
||||
} from "./printer-escpos.js";
|
||||
|
||||
// Cashino 80mm network thermal printer driver. The Cashino is an ESC/POS clone:
|
||||
// it PRINTS identically to the Rongta (same byte stream — see ./printer-escpos.ts),
|
||||
// so tickets, reports and subscription cards render the same. What it does NOT
|
||||
// have is the Rongta board's decoded status web page (/prn_stat.htm). It cannot
|
||||
// report paper-out / cover-open / cutter faults in a form we trust.
|
||||
//
|
||||
// Therefore this driver deliberately does NOT implement MonitorableDevice
|
||||
// (no readStatus). The device monitor then falls back to the generic
|
||||
// `healthCheck()` — a plain TCP reachability PING of the print socket. So the
|
||||
// booth footer shows this printer as "ready" when it's reachable and "offline"
|
||||
// when it isn't, and never a wrong paper/cover verdict it cannot actually sense.
|
||||
// (Reusing the Rongta driver made it scrape a status page the Cashino doesn't
|
||||
// serve, producing the bogus "degraded" feedback this driver fixes.)
|
||||
//
|
||||
// No auth on the print socket — like the other field devices it lives on the
|
||||
// isolated device VLAN. Roles + failover work exactly as for the Rongta
|
||||
// (entry-dispenser / booth-receipt + failoverRank); the server owns selection.
|
||||
// See wiki/concepts/printer-status-monitoring.md and printer-roles-failover.md.
|
||||
|
||||
class CashinoPrinter implements PrinterDevice {
|
||||
readonly driverId = "cashino";
|
||||
readonly #host: string;
|
||||
readonly #port: number;
|
||||
readonly #timeout: number;
|
||||
|
||||
constructor(config: DeviceConfig) {
|
||||
this.#host = String(config.host);
|
||||
this.#port = config.port ? Number(config.port) : 9100;
|
||||
this.#timeout = config.timeoutMs ? Number(config.timeoutMs) : 3000;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
await this.healthCheck();
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
stubLog(this.driverId, "disconnect");
|
||||
}
|
||||
|
||||
/**
|
||||
* Reachability only — a TCP connect probe of the raw print socket. The Cashino
|
||||
* has no trustworthy status protocol, so this is the floor and the ceiling of
|
||||
* what we report: reachable → ready, unreachable → offline. Deliberately NO
|
||||
* readStatus(): the monitor uses this for the traffic-light, never a guessed
|
||||
* paper/cover state.
|
||||
*/
|
||||
async healthCheck(): Promise<DeviceHealth> {
|
||||
try {
|
||||
await probe(this.#host, this.#port, this.#timeout);
|
||||
return { status: "ready" };
|
||||
} catch (err) {
|
||||
return { status: "offline", detail: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
async printTicket(data: TicketData): Promise<void> {
|
||||
await sendRaw(this.#host, this.#port, renderTicket(data), this.#timeout);
|
||||
stubLog(this.driverId, `printed ticket ${data.ticketId}`);
|
||||
}
|
||||
|
||||
async printReport(report: PrintReport): Promise<void> {
|
||||
await sendRaw(this.#host, this.#port, renderReport(report), this.#timeout);
|
||||
stubLog(
|
||||
this.driverId,
|
||||
`printed report "${report.title}" (${report.lines.length} lines)`,
|
||||
);
|
||||
}
|
||||
|
||||
async printSubscriptionCard(data: SubscriptionCardData): Promise<void> {
|
||||
await sendRaw(
|
||||
this.#host,
|
||||
this.#port,
|
||||
renderSubscriptionCard(data),
|
||||
this.#timeout,
|
||||
);
|
||||
stubLog(this.driverId, `printed subscription card ${data.code}`);
|
||||
}
|
||||
}
|
||||
|
||||
const roleField: ConfigField = {
|
||||
key: "role",
|
||||
label: "Role",
|
||||
type: "select",
|
||||
required: true,
|
||||
default: "entry-dispenser",
|
||||
options: [
|
||||
{
|
||||
value: "entry-dispenser",
|
||||
label: "Entry dispenser (outside / at the lane)",
|
||||
},
|
||||
{ value: "booth-receipt", label: "Booth printer (receipts + backup)" },
|
||||
],
|
||||
help: "Entry tickets print on the entry dispenser, falling back to the booth printer if it is offline.",
|
||||
};
|
||||
|
||||
const rankField: ConfigField = {
|
||||
key: "failoverRank",
|
||||
label: "Failover rank",
|
||||
type: "number",
|
||||
required: false,
|
||||
default: 0,
|
||||
help: "Higher = tried first within the same role. The booth printer also backs up the entry dispenser.",
|
||||
};
|
||||
|
||||
export const cashinoDriver: PrinterDriver = {
|
||||
id: "cashino",
|
||||
category: "printer",
|
||||
label: "Cashino 80mm thermal printer",
|
||||
description:
|
||||
"Cashino 80mm thermal printer (ESC/POS over raw TCP, port 9100). Prints like the Rongta but has no status page — monitored by reachability ping only (no paper/cover/cutter reporting). No auth on the print socket — isolate the VLAN.",
|
||||
transports: ["tcp-ip"],
|
||||
configFields: [
|
||||
hostField,
|
||||
{
|
||||
...portField(9100),
|
||||
required: false,
|
||||
help: "Raw print socket (ESC/POS over JetDirect/RAW, default 9100).",
|
||||
},
|
||||
roleField,
|
||||
rankField,
|
||||
{
|
||||
key: "timeoutMs",
|
||||
label: "Timeout (ms)",
|
||||
type: "number",
|
||||
required: false,
|
||||
default: 3000,
|
||||
},
|
||||
],
|
||||
create: (c) => new CashinoPrinter(c),
|
||||
};
|
||||
@@ -0,0 +1,309 @@
|
||||
import { Socket } from "node:net";
|
||||
import type {
|
||||
PrintReport,
|
||||
SubscriptionCardData,
|
||||
TicketData,
|
||||
} from "../interfaces.js";
|
||||
|
||||
// Shared ESC/POS rendering + raw-TCP transport for 80mm thermal printers.
|
||||
// Rongta RP-series, Cashino, and the many OEM clones all speak ESC/POS over a
|
||||
// raw TCP socket on port 9100 (the JetDirect/RAW convention) with no auth on the
|
||||
// print socket — they live on the isolated device VLAN. The BYTE STREAM is
|
||||
// identical across these clones; what differs is live status reporting (the
|
||||
// Rongta board serves a decoded status page; the Cashino does not), so status
|
||||
// stays in each driver while the rendering/transport live here.
|
||||
// See wiki/entities/rongta-printer.md and wiki/concepts/network-isolation.md.
|
||||
|
||||
// --- ESC/POS command bytes ----------------------------------------------------
|
||||
const ESC = 0x1b;
|
||||
const GS = 0x1d;
|
||||
const LF = 0x0a;
|
||||
|
||||
const INIT = Buffer.from([ESC, 0x40]); // ESC @ — reset to power-on defaults
|
||||
const ALIGN_CENTER = Buffer.from([ESC, 0x61, 0x01]); // ESC a 1
|
||||
const ALIGN_LEFT = Buffer.from([ESC, 0x61, 0x00]); // ESC a 0
|
||||
const BOLD_ON = Buffer.from([ESC, 0x45, 0x01]); // ESC E 1
|
||||
const BOLD_OFF = Buffer.from([ESC, 0x45, 0x00]); // ESC E 0
|
||||
const DOUBLE_ON = Buffer.from([GS, 0x21, 0x11]); // GS ! — double width+height
|
||||
const DOUBLE_OFF = Buffer.from([GS, 0x21, 0x00]);
|
||||
const FEED_AND_CUT = Buffer.from([ESC, 0x64, 0x04, GS, 0x56, 0x42, 0x00]); // feed 4, GS V B 0 partial cut
|
||||
|
||||
// Select code page 852 (Latin-2) for the character set: ESC t n, n=18 (0x12).
|
||||
// CP852 carries the Albanian letters we print (ë, ç, …); without it the printer
|
||||
// would interpret our high bytes as CP437 glyphs. Sent in every print's INIT
|
||||
// preamble. See wiki/concepts/site-metadata.md (i18n / codepage).
|
||||
const SELECT_CP852 = Buffer.from([ESC, 0x74, 0x12]);
|
||||
|
||||
// Minimal Unicode → CP852 byte map for the characters Albanian text actually uses
|
||||
// beyond ASCII. Anything not listed is transliterated to an ASCII fallback (below)
|
||||
// so we never emit a byte that renders as the wrong glyph. Extend as needed.
|
||||
const CP852: Record<string, number> = {
|
||||
ë: 0x89,
|
||||
Ë: 0xeb,
|
||||
ç: 0x87,
|
||||
Ç: 0x80,
|
||||
// common Latin-2 extras that may appear in a park name/address:
|
||||
ä: 0x84,
|
||||
ö: 0x94,
|
||||
ü: 0x81,
|
||||
é: 0x82,
|
||||
á: 0xa0,
|
||||
í: 0xa1,
|
||||
ó: 0xa2,
|
||||
ú: 0xa3,
|
||||
};
|
||||
// ASCII transliteration for any char with no CP852 mapping (last-resort, so an
|
||||
// odd glyph degrades to a readable letter rather than garbage).
|
||||
const ASCII_FALLBACK: Record<string, string> = {
|
||||
ë: "e",
|
||||
Ë: "E",
|
||||
ç: "c",
|
||||
Ç: "C",
|
||||
ä: "a",
|
||||
ö: "o",
|
||||
ü: "u",
|
||||
é: "e",
|
||||
á: "a",
|
||||
í: "i",
|
||||
ó: "o",
|
||||
ú: "u",
|
||||
};
|
||||
|
||||
/** Encode one line of text to CP852 bytes + a line feed. ASCII (<0x80) passes
|
||||
* through; mapped chars use their CP852 byte; unmapped non-ASCII falls back to an
|
||||
* ASCII letter. Pair with SELECT_CP852 in the print preamble. */
|
||||
function line(text = ""): Buffer {
|
||||
const out: number[] = [];
|
||||
for (const ch of text) {
|
||||
const code = ch.codePointAt(0) ?? 0;
|
||||
const mapped = CP852[ch];
|
||||
const fallback = ASCII_FALLBACK[ch];
|
||||
if (code < 0x80) {
|
||||
out.push(code);
|
||||
} else if (mapped !== undefined) {
|
||||
out.push(mapped);
|
||||
} else if (fallback !== undefined) {
|
||||
out.push(...Buffer.from(fallback, "ascii"));
|
||||
} else {
|
||||
out.push(0x3f); // "?" — unknown char, never a wrong glyph
|
||||
}
|
||||
}
|
||||
out.push(LF);
|
||||
return Buffer.from(out);
|
||||
}
|
||||
|
||||
// --- Scannable symbol (printer-generated, no image rendering) -----------------
|
||||
// The ticket id is the session key (wiki/concepts/ticket-encoding.md). We print it
|
||||
// as a 1D Code128 barcode so ANY legacy laser barcode scanner the booth might have
|
||||
// can read it. The barcode is rendered by the printer board from these ESC/POS
|
||||
// commands — we send the data, the firmware draws the bars (no bitmap, no
|
||||
// dependency). The same code is printed as large human-readable digits below, so
|
||||
// the operator can hand-key it if every reader fails.
|
||||
|
||||
/** GS k — Code128 1D barcode. Height/width set first, then HRI off, then data. */
|
||||
function code128(data: string): Buffer {
|
||||
// Code128 code set B (printable ASCII) — prefix the data with the {B selector.
|
||||
const payload = Buffer.from(`{B${data}`, "ascii");
|
||||
return Buffer.concat([
|
||||
Buffer.from([GS, 0x68, 0x64]), // GS h 100 — barcode height = 100 dots (taller = tolerant of scan angle)
|
||||
Buffer.from([GS, 0x77, 0x03]), // GS w 3 — module width = 3 (wider bars for the short-range "Simple" QR/barcode engine; 13-digit Code128 ≈ 495/576 dots, fits 80mm with quiet zones)
|
||||
Buffer.from([GS, 0x48, 0x00]), // GS H 0 — HRI text off (we print the id ourselves)
|
||||
// GS k 73 n <data> — function B form: 73 = Code128, n = data byte length.
|
||||
Buffer.from([GS, 0x6b, 0x49, payload.length]),
|
||||
payload,
|
||||
]);
|
||||
}
|
||||
|
||||
// --- 2D QR symbol (printer-generated via ESC/POS GS ( k) -----------------------
|
||||
// A true QR for the SUBSCRIPTION card — the subscriber scans it at the reader (which
|
||||
// reads QR + 1D barcode) every entry/exit for the coverage period. The board renders
|
||||
// the QR from these GS ( k commands (no bitmap, no dependency), same approach as
|
||||
// code128. We also print the code as text below as the hand-key fallback.
|
||||
|
||||
/** A QR code via ESC/POS `GS ( k`. `size` = module dot size (1–16; 6 ≈ readable on
|
||||
* 80mm at short range). Error-correction level M (15%) — robust to a smudged print. */
|
||||
function qrCode(data: string, size = 6): Buffer {
|
||||
const bytes = Buffer.from(data, "ascii");
|
||||
// pL/pH encode the data length + 3 (the cn,fn,m header bytes) for function 180.
|
||||
const store = bytes.length + 3;
|
||||
const pL = store & 0xff;
|
||||
const pH = (store >> 8) & 0xff;
|
||||
return Buffer.concat([
|
||||
// fn 165: select QR model — 1d 28 6b 04 00 31 41 <model=50(2)> 00
|
||||
Buffer.from([GS, 0x28, 0x6b, 0x04, 0x00, 0x31, 0x41, 0x32, 0x00]),
|
||||
// fn 167: module size — 1d 28 6b 03 00 31 43 <size>
|
||||
Buffer.from([GS, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x43, size]),
|
||||
// fn 169: error correction level — 1d 28 6b 03 00 31 45 <49=M>
|
||||
Buffer.from([GS, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x45, 0x31]),
|
||||
// fn 180: store the symbol data — 1d 28 6b pL pH 31 50 30 <data>
|
||||
Buffer.from([GS, 0x28, 0x6b, pL, pH, 0x31, 0x50, 0x30]),
|
||||
bytes,
|
||||
// fn 181: print the stored symbol — 1d 28 6b 03 00 31 51 30
|
||||
Buffer.from([GS, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x51, 0x30]),
|
||||
]);
|
||||
}
|
||||
|
||||
// Ticket/receipt strings — Albanian (the site prints in Albanian for now). Kept in
|
||||
// one place so a real i18n layer (per-locale tables + a t() helper) can replace this
|
||||
// later without touching the render functions. See wiki/concepts/site-metadata.md.
|
||||
const STR = {
|
||||
/** NIUS label prefix; printed only when the park has a NIUS. */
|
||||
nius: (v: string) => `NIUS: ${v}`,
|
||||
/** "Printed at:" — precedes the issue timestamp. */
|
||||
issuedAt: (v: string) => `Printuar më: ${v}`,
|
||||
/** Subscription-card title. */
|
||||
subscription: "ABONIM",
|
||||
/** "Holder: <name>" line on the card. */
|
||||
holder: (name: string) => `Mbajtësi: ${name}`,
|
||||
/** "Valid: <from> – <to>" line on the card. */
|
||||
validity: (from: string, to: string) => `Vlen: ${from} – ${to}`,
|
||||
phone: (v: string) => `TEL: ${v}`,
|
||||
} as const;
|
||||
|
||||
/** Build the ESC/POS byte stream for a free-form text report (e.g. shift Z-report). */
|
||||
export function renderReport(report: PrintReport): Buffer {
|
||||
return Buffer.concat([
|
||||
INIT,
|
||||
SELECT_CP852,
|
||||
ALIGN_CENTER,
|
||||
BOLD_ON,
|
||||
line(report.title),
|
||||
BOLD_OFF,
|
||||
ALIGN_LEFT,
|
||||
line(),
|
||||
...report.lines.map((l) => line(l)),
|
||||
FEED_AND_CUT,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Render the park-identity header from site metadata. Prints the park name large
|
||||
* (or "PARKING" if unset), then operator / NIUS / address lines that are present.
|
||||
* NIUS and the rest only print when set. Non-ASCII renders via CP852 (see line()). */
|
||||
function renderHeader(h: TicketData["header"]): Buffer {
|
||||
const parts: Buffer[] = [
|
||||
ALIGN_CENTER,
|
||||
BOLD_ON,
|
||||
DOUBLE_ON,
|
||||
line(h?.parkName || "PARKING"),
|
||||
DOUBLE_OFF,
|
||||
BOLD_OFF,
|
||||
];
|
||||
if (h?.operatorName) parts.push(line(h.operatorName));
|
||||
if (h?.nius) parts.push(line(STR.nius(h.nius)));
|
||||
if (h?.address) {
|
||||
// Address may be multi-line; print each line centered.
|
||||
for (const ln of h.address.split(/\r?\n/))
|
||||
if (ln.trim()) parts.push(line(ln.trim()));
|
||||
}
|
||||
if (h?.phone) parts.push(line(STR.phone(h.phone)));
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
|
||||
/** Build the full ESC/POS byte stream for an entry ticket.
|
||||
* Header (park identity) → 1D Code128 barcode of the ticket id → the id in large
|
||||
* digits → issue time. Code128 is read by ANY legacy 1D barcode scanner the booth
|
||||
* might have; the printed digits are the fallback if every reader fails (operator
|
||||
* hand-keys the all-numeric code). Text is Albanian.
|
||||
* See wiki/concepts/ticket-encoding.md and site-metadata.md. */
|
||||
export function renderTicket(data: TicketData): Buffer {
|
||||
return Buffer.concat([
|
||||
INIT,
|
||||
SELECT_CP852,
|
||||
renderHeader(data.header),
|
||||
line(),
|
||||
// The scannable barcode + the same code in large human-readable digits.
|
||||
code128(data.ticketId),
|
||||
line(),
|
||||
BOLD_ON,
|
||||
DOUBLE_ON,
|
||||
line(data.ticketId),
|
||||
DOUBLE_OFF,
|
||||
BOLD_OFF,
|
||||
line(),
|
||||
line(STR.issuedAt(data.issuedAt)),
|
||||
FEED_AND_CUT,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Build the ESC/POS byte stream for a SUBSCRIPTION CARD: park header → a scannable
|
||||
* QR of the code → the code in text (hand-key fallback) → holder + validity window.
|
||||
* The subscriber keeps this and scans the QR at the reader every entry/exit. */
|
||||
export function renderSubscriptionCard(data: SubscriptionCardData): Buffer {
|
||||
const parts: Buffer[] = [
|
||||
INIT,
|
||||
SELECT_CP852,
|
||||
renderHeader(data.header),
|
||||
line(),
|
||||
BOLD_ON,
|
||||
line(STR.subscription),
|
||||
BOLD_OFF,
|
||||
line(),
|
||||
ALIGN_CENTER,
|
||||
qrCode(data.code),
|
||||
line(),
|
||||
// The code in text, as the fallback if the QR won't scan.
|
||||
line(data.code),
|
||||
ALIGN_LEFT,
|
||||
line(),
|
||||
];
|
||||
if (data.holderName) parts.push(line(STR.holder(data.holderName)));
|
||||
if (data.validFrom || data.validTo) {
|
||||
parts.push(line(STR.validity(data.validFrom ?? "—", data.validTo ?? "—")));
|
||||
}
|
||||
parts.push(FEED_AND_CUT);
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
|
||||
/** Open a TCP socket, write the bytes, wait for flush, then close. */
|
||||
export function sendRaw(
|
||||
host: string,
|
||||
port: number,
|
||||
payload: Buffer,
|
||||
timeoutMs: number,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const sock = new Socket();
|
||||
let settled = false;
|
||||
const done = (err?: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
sock.destroy();
|
||||
err ? reject(err) : resolve();
|
||||
};
|
||||
sock.setTimeout(timeoutMs);
|
||||
sock.on("timeout", () => done(new Error("timeout")));
|
||||
sock.on("error", done);
|
||||
sock.connect(port, host, () => {
|
||||
sock.write(payload, (err) => (err ? done(err) : done()));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** TCP connect probe — reachability of the raw print socket. The print socket has
|
||||
* no status protocol we rely on, so this is the floor for any ESC/POS printer:
|
||||
* it answers "is the printer reachable", not "is it out of paper". */
|
||||
export function probe(
|
||||
host: string,
|
||||
port: number,
|
||||
timeoutMs: number,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const sock = new Socket();
|
||||
let settled = false;
|
||||
const done = (err?: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
sock.destroy();
|
||||
err ? reject(err) : resolve();
|
||||
};
|
||||
sock.setTimeout(timeoutMs);
|
||||
sock.on("timeout", () => done(new Error("timeout")));
|
||||
sock.on("error", done);
|
||||
sock.connect(port, host, () => done());
|
||||
});
|
||||
}
|
||||
|
||||
// --- shared driver config fields ----------------------------------------------
|
||||
// Role + failover are identical across ESC/POS printers; defined here so each
|
||||
// driver shares them. See wiki/concepts/printer-roles-failover.md.
|
||||
export type PrinterRole = "entry-dispenser" | "booth-receipt";
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Socket } from "node:net";
|
||||
import { request as httpRequest } from "node:http";
|
||||
import type {
|
||||
Device,
|
||||
@@ -12,11 +11,21 @@ import type {
|
||||
} from "../interfaces.js";
|
||||
import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
|
||||
import { hostField, portField, stubLog } from "./common.js";
|
||||
import {
|
||||
probe,
|
||||
renderReport,
|
||||
renderSubscriptionCard,
|
||||
renderTicket,
|
||||
sendRaw,
|
||||
} from "./printer-escpos.js";
|
||||
|
||||
// Rongta 80mm network thermal printer driver. Rongta RP-series printers (and the
|
||||
// many OEM clones that share their firmware) speak ESC/POS over a raw TCP socket
|
||||
// on port 9100 — the JetDirect/RAW convention. There is no auth on the print
|
||||
// socket; like the other field devices it lives on the isolated device VLAN.
|
||||
// on port 9100 — the JetDirect/RAW convention. The ESC/POS rendering + transport
|
||||
// are shared with the other ESC/POS clones in ./printer-escpos.ts; what is unique
|
||||
// to Rongta — and lives here — is LIVE STATUS via the board's own status web page.
|
||||
// There is no auth on the print socket; like the other field devices it lives on
|
||||
// the isolated device VLAN.
|
||||
// See wiki/entities/rongta-printer.md and wiki/concepts/network-isolation.md.
|
||||
//
|
||||
// ROLES + FAILOVER: a lane has more than one printer. Each instance declares a
|
||||
@@ -27,253 +36,22 @@ import { hostField, portField, stubLog } from "./common.js";
|
||||
// itself is role-agnostic; the role/rank live in config and the caller (server)
|
||||
// owns the failover selection. See wiki/concepts/printer-roles-failover.md.
|
||||
|
||||
// --- ESC/POS command bytes ----------------------------------------------------
|
||||
const ESC = 0x1b;
|
||||
const GS = 0x1d;
|
||||
const LF = 0x0a;
|
||||
|
||||
const INIT = Buffer.from([ESC, 0x40]); // ESC @ — reset to power-on defaults
|
||||
const ALIGN_CENTER = Buffer.from([ESC, 0x61, 0x01]); // ESC a 1
|
||||
const ALIGN_LEFT = Buffer.from([ESC, 0x61, 0x00]); // ESC a 0
|
||||
const BOLD_ON = Buffer.from([ESC, 0x45, 0x01]); // ESC E 1
|
||||
const BOLD_OFF = Buffer.from([ESC, 0x45, 0x00]); // ESC E 0
|
||||
const DOUBLE_ON = Buffer.from([GS, 0x21, 0x11]); // GS ! — double width+height
|
||||
const DOUBLE_OFF = Buffer.from([GS, 0x21, 0x00]);
|
||||
const FEED_AND_CUT = Buffer.from([ESC, 0x64, 0x04, GS, 0x56, 0x42, 0x00]); // feed 4, GS V B 0 partial cut
|
||||
|
||||
// Select code page 852 (Latin-2) for the character set: ESC t n, n=18 (0x12).
|
||||
// CP852 carries the Albanian letters we print (ë, ç, …); without it the printer
|
||||
// would interpret our high bytes as CP437 glyphs. Sent in every print's INIT
|
||||
// preamble. See wiki/concepts/site-metadata.md (i18n / codepage).
|
||||
const SELECT_CP852 = Buffer.from([ESC, 0x74, 0x12]);
|
||||
|
||||
// Minimal Unicode → CP852 byte map for the characters Albanian text actually uses
|
||||
// beyond ASCII. Anything not listed is transliterated to an ASCII fallback (below)
|
||||
// so we never emit a byte that renders as the wrong glyph. Extend as needed.
|
||||
const CP852: Record<string, number> = {
|
||||
ë: 0x89, Ë: 0xeb,
|
||||
ç: 0x87, Ç: 0x80,
|
||||
// common Latin-2 extras that may appear in a park name/address:
|
||||
ä: 0x84, ö: 0x94, ü: 0x81, é: 0x82, á: 0xa0, í: 0xa1, ó: 0xa2, ú: 0xa3,
|
||||
};
|
||||
// ASCII transliteration for any char with no CP852 mapping (last-resort, so an
|
||||
// odd glyph degrades to a readable letter rather than garbage).
|
||||
const ASCII_FALLBACK: Record<string, string> = {
|
||||
ë: "e", Ë: "E", ç: "c", Ç: "C", ä: "a", ö: "o", ü: "u",
|
||||
é: "e", á: "a", í: "i", ó: "o", ú: "u",
|
||||
};
|
||||
|
||||
/** Encode one line of text to CP852 bytes + a line feed. ASCII (<0x80) passes
|
||||
* through; mapped chars use their CP852 byte; unmapped non-ASCII falls back to an
|
||||
* ASCII letter. Pair with SELECT_CP852 in the print preamble. */
|
||||
function line(text = ""): Buffer {
|
||||
const out: number[] = [];
|
||||
for (const ch of text) {
|
||||
const code = ch.codePointAt(0) ?? 0;
|
||||
const mapped = CP852[ch];
|
||||
const fallback = ASCII_FALLBACK[ch];
|
||||
if (code < 0x80) {
|
||||
out.push(code);
|
||||
} else if (mapped !== undefined) {
|
||||
out.push(mapped);
|
||||
} else if (fallback !== undefined) {
|
||||
out.push(...Buffer.from(fallback, "ascii"));
|
||||
} else {
|
||||
out.push(0x3f); // "?" — unknown char, never a wrong glyph
|
||||
}
|
||||
}
|
||||
out.push(LF);
|
||||
return Buffer.from(out);
|
||||
}
|
||||
|
||||
// --- Scannable symbol (printer-generated, no image rendering) -----------------
|
||||
// The ticket id is the session key (wiki/concepts/ticket-encoding.md). We print it
|
||||
// as a 1D Code128 barcode so ANY legacy laser barcode scanner the booth might have
|
||||
// can read it. The barcode is rendered by the Rongta board from these ESC/POS
|
||||
// commands — we send the data, the firmware draws the bars (no bitmap, no
|
||||
// dependency). The same code is printed as large human-readable digits below, so
|
||||
// the operator can hand-key it if every reader fails. (A QR for phone scanning may
|
||||
// be added later behind an admin toggle.)
|
||||
|
||||
/** GS k — Code128 1D barcode. Height/width set first, then HRI off, then data. */
|
||||
function code128(data: string): Buffer {
|
||||
// Code128 code set B (printable ASCII) — prefix the data with the {B selector.
|
||||
const payload = Buffer.from(`{B${data}`, "ascii");
|
||||
return Buffer.concat([
|
||||
Buffer.from([GS, 0x68, 0x64]), // GS h 100 — barcode height = 100 dots (taller = tolerant of scan angle)
|
||||
Buffer.from([GS, 0x77, 0x03]), // GS w 3 — module width = 3 (wider bars for the short-range "Simple" QR/barcode engine; 13-digit Code128 ≈ 495/576 dots, fits 80mm with quiet zones)
|
||||
Buffer.from([GS, 0x48, 0x00]), // GS H 0 — HRI text off (we print the id ourselves)
|
||||
// GS k 73 n <data> — function B form: 73 = Code128, n = data byte length.
|
||||
Buffer.from([GS, 0x6b, 0x49, payload.length]),
|
||||
payload,
|
||||
]);
|
||||
}
|
||||
|
||||
// --- 2D QR symbol (printer-generated via ESC/POS GS ( k) -----------------------
|
||||
// A true QR for the SUBSCRIPTION card — the subscriber scans it at the reader (which
|
||||
// reads QR + 1D barcode) every entry/exit for the coverage period. The board renders
|
||||
// the QR from these GS ( k commands (no bitmap, no dependency), same approach as
|
||||
// code128. We also print the code as text below as the hand-key fallback. The QR
|
||||
// "model 2" sequence: set model → set module size → set error-correction → store the
|
||||
// data in symbol storage → print it. See ESC/POS GS ( k (function 165/167/169/180/181).
|
||||
|
||||
/** A QR code via ESC/POS `GS ( k`. `size` = module dot size (1–16; 6 ≈ readable on
|
||||
* 80mm at short range). Error-correction level M (15%) — robust to a smudged print. */
|
||||
function qrCode(data: string, size = 6): Buffer {
|
||||
const bytes = Buffer.from(data, "ascii");
|
||||
// pL/pH encode the data length + 3 (the cn,fn,m header bytes) for function 180.
|
||||
const store = bytes.length + 3;
|
||||
const pL = store & 0xff;
|
||||
const pH = (store >> 8) & 0xff;
|
||||
return Buffer.concat([
|
||||
// fn 165: select QR model — 1d 28 6b 04 00 31 41 <model=50(2)> 00
|
||||
Buffer.from([GS, 0x28, 0x6b, 0x04, 0x00, 0x31, 0x41, 0x32, 0x00]),
|
||||
// fn 167: module size — 1d 28 6b 03 00 31 43 <size>
|
||||
Buffer.from([GS, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x43, size]),
|
||||
// fn 169: error correction level — 1d 28 6b 03 00 31 45 <49=M>
|
||||
Buffer.from([GS, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x45, 0x31]),
|
||||
// fn 180: store the symbol data — 1d 28 6b pL pH 31 50 30 <data>
|
||||
Buffer.from([GS, 0x28, 0x6b, pL, pH, 0x31, 0x50, 0x30]),
|
||||
bytes,
|
||||
// fn 181: print the stored symbol — 1d 28 6b 03 00 31 51 30
|
||||
Buffer.from([GS, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x51, 0x30]),
|
||||
]);
|
||||
}
|
||||
|
||||
// Ticket/receipt strings — Albanian (the site prints in Albanian for now). Kept in
|
||||
// one place so a real i18n layer (per-locale tables + a t() helper) can replace this
|
||||
// later without touching the render functions. See wiki/concepts/site-metadata.md.
|
||||
const STR = {
|
||||
/** NIUS label prefix; printed only when the park has a NIUS. */
|
||||
nius: (v: string) => `NIUS: ${v}`,
|
||||
/** "Printed at:" — precedes the issue timestamp. */
|
||||
issuedAt: (v: string) => `Printuar më: ${v}`,
|
||||
/** "Lost your ticket? <phone>" footer; printed only when a phone is set. */
|
||||
lostTicket: (phone: string) => `Keni humbur biletën? ${phone}`,
|
||||
/** Subscription-card title. */
|
||||
subscription: "ABONIM",
|
||||
/** "Holder: <name>" line on the card. */
|
||||
holder: (name: string) => `Mbajtësi: ${name}`,
|
||||
/** "Valid: <from> – <to>" line on the card. */
|
||||
validity: (from: string, to: string) => `Vlen: ${from} – ${to}`,
|
||||
} as const;
|
||||
|
||||
/** Build the ESC/POS byte stream for a free-form text report (e.g. shift Z-report). */
|
||||
function renderReport(report: PrintReport): Buffer {
|
||||
return Buffer.concat([
|
||||
INIT,
|
||||
SELECT_CP852,
|
||||
ALIGN_CENTER,
|
||||
BOLD_ON,
|
||||
line(report.title),
|
||||
BOLD_OFF,
|
||||
ALIGN_LEFT,
|
||||
line(),
|
||||
...report.lines.map((l) => line(l)),
|
||||
FEED_AND_CUT,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Render the park-identity header from site metadata. Prints the park name large
|
||||
* (or "PARKING" if unset), then operator / NIUS / address lines that are present.
|
||||
* NIUS and the rest only print when set. Non-ASCII renders via CP852 (see line()). */
|
||||
function renderHeader(h: TicketData["header"]): Buffer {
|
||||
const parts: Buffer[] = [ALIGN_CENTER, BOLD_ON, DOUBLE_ON, line(h?.parkName || "PARKING"), DOUBLE_OFF, BOLD_OFF];
|
||||
if (h?.operatorName) parts.push(line(h.operatorName));
|
||||
if (h?.nius) parts.push(line(STR.nius(h.nius)));
|
||||
if (h?.address) {
|
||||
// Address may be multi-line; print each line centered.
|
||||
for (const ln of h.address.split(/\r?\n/)) if (ln.trim()) parts.push(line(ln.trim()));
|
||||
}
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
|
||||
/** Build the full ESC/POS byte stream for an entry ticket.
|
||||
* Header (park identity) → 1D Code128 barcode of the ticket id → the id in large
|
||||
* digits → issue time → optional lost-ticket footer. Code128 is read by ANY legacy
|
||||
* 1D barcode scanner the booth might have; the printed digits are the fallback if
|
||||
* every reader fails (operator hand-keys the all-numeric code). Text is Albanian.
|
||||
* See wiki/concepts/ticket-encoding.md and site-metadata.md. */
|
||||
function renderTicket(data: TicketData): Buffer {
|
||||
return Buffer.concat([
|
||||
INIT,
|
||||
SELECT_CP852,
|
||||
renderHeader(data.header),
|
||||
line(),
|
||||
// The scannable barcode + the same code in large human-readable digits.
|
||||
code128(data.ticketId),
|
||||
line(),
|
||||
BOLD_ON,
|
||||
DOUBLE_ON,
|
||||
line(data.ticketId),
|
||||
DOUBLE_OFF,
|
||||
BOLD_OFF,
|
||||
line(),
|
||||
line(STR.issuedAt(data.issuedAt)),
|
||||
// Contact footer (lost-ticket help) if a phone is set.
|
||||
...(data.header?.phone ? [line(STR.lostTicket(data.header.phone))] : []),
|
||||
FEED_AND_CUT,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Build the ESC/POS byte stream for a SUBSCRIPTION CARD: park header → a scannable
|
||||
* QR of the code → the code in text (hand-key fallback) → holder + validity window.
|
||||
* The subscriber keeps this and scans the QR at the reader every entry/exit. */
|
||||
function renderSubscriptionCard(data: SubscriptionCardData): Buffer {
|
||||
const parts: Buffer[] = [
|
||||
INIT,
|
||||
SELECT_CP852,
|
||||
renderHeader(data.header),
|
||||
line(),
|
||||
BOLD_ON,
|
||||
line(STR.subscription),
|
||||
BOLD_OFF,
|
||||
line(),
|
||||
ALIGN_CENTER,
|
||||
qrCode(data.code),
|
||||
line(),
|
||||
// The code in text, as the fallback if the QR won't scan.
|
||||
line(data.code),
|
||||
ALIGN_LEFT,
|
||||
line(),
|
||||
];
|
||||
if (data.holderName) parts.push(line(STR.holder(data.holderName)));
|
||||
if (data.validFrom || data.validTo) {
|
||||
parts.push(line(STR.validity(data.validFrom ?? "—", data.validTo ?? "—")));
|
||||
}
|
||||
parts.push(FEED_AND_CUT);
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
|
||||
/** Open a TCP socket, write the bytes, wait for flush, then close. */
|
||||
function sendRaw(host: string, port: number, payload: Buffer, timeoutMs: number): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const sock = new Socket();
|
||||
let settled = false;
|
||||
const done = (err?: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
sock.destroy();
|
||||
err ? reject(err) : resolve();
|
||||
};
|
||||
sock.setTimeout(timeoutMs);
|
||||
sock.on("timeout", () => done(new Error("timeout")));
|
||||
sock.on("error", done);
|
||||
sock.connect(port, host, () => {
|
||||
sock.write(payload, (err) => (err ? done(err) : done()));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- live status via the device's own status web page -------------------------
|
||||
// The Rongta board serves /prn_stat.htm, a small HTML table where the DEVICE has
|
||||
// already decoded the ESC/POS status bits into labelled Yes/No rows. We scrape
|
||||
// that rather than send raw `DLE EOT` ourselves: on this clone the DLE EOT reply
|
||||
// bytes don't follow the canonical bit layout (verified on hardware), so trusting
|
||||
// the device's own decode is the safe choice. See printer-status-monitoring.md.
|
||||
// A clone that does NOT serve this page (e.g. the Cashino) uses its own driver
|
||||
// with a plain reachability probe — it must not pretend to report paper/cover.
|
||||
|
||||
/** The fault flags the status page reports (a subset of PrinterStatus). */
|
||||
type StatusFlag = "coverOpen" | "cutterError" | "paperEnd" | "paperNearEnd" | "offline";
|
||||
type StatusFlag =
|
||||
| "coverOpen"
|
||||
| "cutterError"
|
||||
| "paperEnd"
|
||||
| "paperNearEnd"
|
||||
| "offline";
|
||||
type StatusFlags = Partial<Record<StatusFlag, boolean>>;
|
||||
|
||||
/** Label text on the status page (NBSP/space-normalised, lowercased) → our key. */
|
||||
@@ -286,10 +64,20 @@ const STATUS_FIELDS: Record<string, StatusFlag> = {
|
||||
};
|
||||
|
||||
/** GET the status page over HTTP and return the raw HTML. */
|
||||
function fetchStatusPage(host: string, httpPort: number, timeoutMs: number): Promise<string> {
|
||||
function fetchStatusPage(
|
||||
host: string,
|
||||
httpPort: number,
|
||||
timeoutMs: number,
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = httpRequest(
|
||||
{ host, port: httpPort, path: "/prn_stat.htm", method: "GET", timeout: timeoutMs },
|
||||
{
|
||||
host,
|
||||
port: httpPort,
|
||||
path: "/prn_stat.htm",
|
||||
method: "GET",
|
||||
timeout: timeoutMs,
|
||||
},
|
||||
(res) => {
|
||||
let data = "";
|
||||
res.on("data", (c) => (data += c));
|
||||
@@ -318,8 +106,15 @@ function parseStatusPage(html: string): StatusFlags {
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = rowRe.exec(html))) {
|
||||
if (m[1] === undefined || m[2] === undefined) continue;
|
||||
const label = m[1].replace(/ /gi, " ").replace(/\s+/g, " ").trim().toLowerCase();
|
||||
const value = m[2].replace(/ /gi, " ").trim().toLowerCase();
|
||||
const label = m[1]
|
||||
.replace(/ /gi, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const value = m[2]
|
||||
.replace(/ /gi, " ")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const key = STATUS_FIELDS[label];
|
||||
if (key && (value === "yes" || value === "no")) {
|
||||
out[key] = value === "yes";
|
||||
@@ -328,24 +123,6 @@ function parseStatusPage(html: string): StatusFlags {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** TCP connect probe — the print socket has no status protocol we rely on. */
|
||||
function probe(host: string, port: number, timeoutMs: number): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const sock = new Socket();
|
||||
let settled = false;
|
||||
const done = (err?: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
sock.destroy();
|
||||
err ? reject(err) : resolve();
|
||||
};
|
||||
sock.setTimeout(timeoutMs);
|
||||
sock.on("timeout", () => done(new Error("timeout")));
|
||||
sock.on("error", done);
|
||||
sock.connect(port, host, () => done());
|
||||
});
|
||||
}
|
||||
|
||||
class RongtaPrinter implements PrinterDevice, MonitorableDevice {
|
||||
readonly driverId = "rongta";
|
||||
readonly #host: string;
|
||||
@@ -384,11 +161,19 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
|
||||
|
||||
async printReport(report: PrintReport): Promise<void> {
|
||||
await sendRaw(this.#host, this.#port, renderReport(report), this.#timeout);
|
||||
stubLog(this.driverId, `printed report "${report.title}" (${report.lines.length} lines)`);
|
||||
stubLog(
|
||||
this.driverId,
|
||||
`printed report "${report.title}" (${report.lines.length} lines)`,
|
||||
);
|
||||
}
|
||||
|
||||
async printSubscriptionCard(data: SubscriptionCardData): Promise<void> {
|
||||
await sendRaw(this.#host, this.#port, renderSubscriptionCard(data), this.#timeout);
|
||||
await sendRaw(
|
||||
this.#host,
|
||||
this.#port,
|
||||
renderSubscriptionCard(data),
|
||||
this.#timeout,
|
||||
);
|
||||
stubLog(this.driverId, `printed subscription card ${data.code}`);
|
||||
}
|
||||
|
||||
@@ -413,7 +198,13 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
|
||||
}
|
||||
|
||||
const flags = parseStatusPage(html);
|
||||
const expected: StatusFlag[] = ["coverOpen", "cutterError", "paperEnd", "paperNearEnd", "offline"];
|
||||
const expected: StatusFlag[] = [
|
||||
"coverOpen",
|
||||
"cutterError",
|
||||
"paperEnd",
|
||||
"paperNearEnd",
|
||||
"offline",
|
||||
];
|
||||
const missing = expected.filter((k) => flags[k] === undefined);
|
||||
if (missing.length > 0) {
|
||||
return {
|
||||
@@ -434,7 +225,8 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
|
||||
return {
|
||||
status: faults.length > 0 ? "degraded" : "ready",
|
||||
...flags,
|
||||
detail: faults.length > 0 ? faults.map((f) => labels[f]).join(", ") : undefined,
|
||||
detail:
|
||||
faults.length > 0 ? faults.map((f) => labels[f]).join(", ") : undefined,
|
||||
checkedAt,
|
||||
};
|
||||
}
|
||||
@@ -450,7 +242,10 @@ const roleField: ConfigField = {
|
||||
required: true,
|
||||
default: "entry-dispenser",
|
||||
options: [
|
||||
{ value: "entry-dispenser", label: "Entry dispenser (outside / at the lane)" },
|
||||
{
|
||||
value: "entry-dispenser",
|
||||
label: "Entry dispenser (outside / at the lane)",
|
||||
},
|
||||
{ value: "booth-receipt", label: "Booth printer (receipts + backup)" },
|
||||
],
|
||||
help: "Entry tickets print on the entry dispenser, falling back to the booth printer if it is offline.",
|
||||
@@ -470,15 +265,32 @@ export const rongtaDriver: PrinterDriver = {
|
||||
category: "printer",
|
||||
label: "Rongta 80mm thermal printer",
|
||||
description:
|
||||
"Rongta RP-series 80mm thermal printer (and ESC/POS-compatible clones) over raw TCP (port 9100). No auth on the print socket — isolate the VLAN.",
|
||||
"Rongta RP-series 80mm thermal printer (and ESC/POS-compatible clones that serve the /prn_stat.htm status page) over raw TCP (port 9100). No auth on the print socket — isolate the VLAN.",
|
||||
transports: ["tcp-ip"],
|
||||
configFields: [
|
||||
hostField,
|
||||
{ ...portField(9100), required: false, help: "Raw print socket (ESC/POS over JetDirect/RAW, default 9100)." },
|
||||
{ key: "httpPort", label: "Status web port", type: "port", required: false, default: 80, help: "Device status page (/prn_stat.htm) port for live monitoring (default 80)." },
|
||||
{
|
||||
...portField(9100),
|
||||
required: false,
|
||||
help: "Raw print socket (ESC/POS over JetDirect/RAW, default 9100).",
|
||||
},
|
||||
{
|
||||
key: "httpPort",
|
||||
label: "Status web port",
|
||||
type: "port",
|
||||
required: false,
|
||||
default: 80,
|
||||
help: "Device status page (/prn_stat.htm) port for live monitoring (default 80).",
|
||||
},
|
||||
roleField,
|
||||
rankField,
|
||||
{ key: "timeoutMs", label: "Timeout (ms)", type: "number", required: false, default: 3000 },
|
||||
{
|
||||
key: "timeoutMs",
|
||||
label: "Timeout (ms)",
|
||||
type: "number",
|
||||
required: false,
|
||||
default: 3000,
|
||||
},
|
||||
],
|
||||
create: (c) => new RongtaPrinter(c),
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ export {
|
||||
hikvisionDriver,
|
||||
dahuaDriver,
|
||||
rongtaDriver,
|
||||
cashinoDriver,
|
||||
} from "./drivers/index.js";
|
||||
export { isPrinter, type PrinterRole } from "./drivers/printer-rongta.js";
|
||||
export {
|
||||
|
||||
@@ -15,9 +15,11 @@
|
||||
"build": "tsc -b",
|
||||
"dev": "tsc -b --watch",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "tsc --noEmit"
|
||||
"lint": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "6.0.3"
|
||||
"typescript": "6.0.3",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
+444
-34
@@ -85,6 +85,9 @@ export interface LedgerPayload {
|
||||
/** plate/vehicle from the vision service (advisory). */
|
||||
readonly plate?: string;
|
||||
readonly plateConfidence?: number;
|
||||
/** vehicle_entry: the vehicle/customer category, frozen at entry so V2 category
|
||||
* pricing reprices identically at exit. Absent on legacy entries (= default). */
|
||||
readonly category?: string;
|
||||
/** Free-form for forward-compat without a schema change. */
|
||||
readonly [k: string]: unknown;
|
||||
}
|
||||
@@ -93,11 +96,22 @@ export interface LedgerPayload {
|
||||
export type DeviceEventKind = "input" | "relay" | "status" | "read" | "snapshot";
|
||||
|
||||
/**
|
||||
* The composable rate card stored in a tariff_version.structure. Pure data the
|
||||
* fee function interprets — no rates in code. Stepped duration blocks + caps/grace;
|
||||
* a flat rate is just one block. See wiki/concepts/tariff.md.
|
||||
* The composable rate card stored in a tariff_version.structure.
|
||||
*
|
||||
* Two shapes, a discriminated union (see TariffStructure):
|
||||
* - V1 (TariffStructureV1): a single block ladder + cap/grace at the top level —
|
||||
* the original shape. Bare structures with no `defaultCard` are V1 and price
|
||||
* via the verbatim V1 algorithm, UNCHANGED. The one live production version is
|
||||
* V1 and must keep pricing identically.
|
||||
* - V2 (TariffStructureV2): a default card + optional WINDOWED cards selected by
|
||||
* wall-clock time-of-day / day-of-week / date and/or vehicle category, each card
|
||||
* a flat rate OR a block ladder. Adds the legacy ParkSQL2017 pricing breadth on
|
||||
* top of integer-minor-unit money + immutable versions. See wiki/concepts/tariff.md
|
||||
* and wiki/concepts/tariff-time-tiers.md.
|
||||
*
|
||||
* Pure data the fee function interprets — no rates in code, integer minor units.
|
||||
*/
|
||||
export interface TariffStructure {
|
||||
export interface TariffStructureV1 {
|
||||
/** Free if exited within this (drop-off/turnaround). */
|
||||
readonly gracePeriodEntryMin: number;
|
||||
/** Billing granularity; partial increments round UP. */
|
||||
@@ -120,6 +134,74 @@ export interface TariffBlock {
|
||||
readonly priceMinorPerIncrement: number;
|
||||
}
|
||||
|
||||
/** A wall-clock activation window for a V2 card. All parts are AND-ed; an absent
|
||||
* part is unconstrained. Evaluated in the version's frozen tz. */
|
||||
export interface TariffWindow {
|
||||
/** Days-of-week this card is active (0=Sun..6=Sat), local to tz. Absent/empty = every day. */
|
||||
readonly dow?: readonly number[];
|
||||
/** Inclusive local date window "YYYY-MM-DD" (seasonal/holiday). Absent = unbounded that side. */
|
||||
readonly dateFrom?: string;
|
||||
readonly dateTo?: string;
|
||||
/** Local hour-of-day window "HH:MM". `toHour <= fromHour` means it WRAPS past
|
||||
* midnight (e.g. 22:00→06:00 night rate). Absent pair = all day. */
|
||||
readonly fromHour?: string;
|
||||
readonly toHour?: string;
|
||||
}
|
||||
|
||||
/** A V2 pricing card: a flat rate OR a stepped block ladder (with its own cap).
|
||||
* `flatMinor` and `blocks` are mutually exclusive. The defaultCard has no window. */
|
||||
export interface TariffCard {
|
||||
/** Human label (also the final, deterministic precedence tiebreak). */
|
||||
readonly name: string;
|
||||
/** Integer precedence tiebreak among equally-specific cards; higher wins. */
|
||||
readonly priority: number;
|
||||
/** Vehicle/customer category this card prices. Absent = applies to all categories. */
|
||||
readonly category?: string;
|
||||
/** Wall-clock activation window. Absent only on the defaultCard (always active). */
|
||||
readonly window?: TariffWindow;
|
||||
/** Flat price per billing increment (mutually exclusive with `blocks`). */
|
||||
readonly flatMinor?: number;
|
||||
/** Stepped ladder (mutually exclusive with `flatMinor`); last block open-ended. */
|
||||
readonly blocks?: readonly TariffBlock[];
|
||||
/** Cap per rolling 24h for THIS card's ladder. Only the defaultCard's cap governs
|
||||
* a mixed day (see computeFeeV2). null = no cap. */
|
||||
readonly dailyCapMinor?: number | null;
|
||||
}
|
||||
|
||||
export interface TariffStructureV2 {
|
||||
/** Schema marker; presence of `defaultCard` is the real discriminant. */
|
||||
readonly version: 2;
|
||||
/** IANA zone the wall-clock windows are evaluated in, FROZEN in the version for
|
||||
* reproducibility — never read from the host clock. Copied from site config on
|
||||
* publish (default "Europe/Tirane"). */
|
||||
readonly tz: string;
|
||||
// --- shared billing knobs (same meaning as V1) ---
|
||||
readonly gracePeriodEntryMin: number;
|
||||
readonly incrementMin: number;
|
||||
readonly lostTicketMinor: number;
|
||||
readonly gracePeriodExitMin: number;
|
||||
readonly overstay: "reprice";
|
||||
/** The always-applicable fallback (no window). Its dailyCapMinor governs the day. */
|
||||
readonly defaultCard: TariffCard;
|
||||
/** Ordered, optional windowed/category cards. Absent/empty ⇒ behaves like V1. */
|
||||
readonly windowedCards?: readonly TariffCard[];
|
||||
}
|
||||
|
||||
/** The stored/wire type: legacy-bare V1 or windowed V2. computeFee + validate accept
|
||||
* both; the discriminant is the presence of `defaultCard`. */
|
||||
export type TariffStructure = TariffStructureV1 | TariffStructureV2;
|
||||
|
||||
/** True when a structure is the windowed V2 shape (has a defaultCard). */
|
||||
export function isTariffV2(t: TariffStructure): t is TariffStructureV2 {
|
||||
return (t as TariffStructureV2).defaultCard != null;
|
||||
}
|
||||
|
||||
/** The vehicle/customer category assigned to a transient entry when none is captured
|
||||
* (every transient today). A V2 card with no `category` applies to all; a card WITH a
|
||||
* category only applies to a matching session — so the default routes to the
|
||||
* category-agnostic + default cards. See wiki/concepts/tariff-time-tiers.md. */
|
||||
export const DEFAULT_VEHICLE_CATEGORY = "default";
|
||||
|
||||
/**
|
||||
* Compute the parking fee (integer minor units) for a stay, from a TariffStructure.
|
||||
* PURE + deterministic + offline — the pay station calls it with asOf = now; the
|
||||
@@ -135,7 +217,18 @@ export function computeFee(
|
||||
enteredAt: string,
|
||||
asOf: string,
|
||||
tariff: TariffStructure,
|
||||
category?: string,
|
||||
): number {
|
||||
return isTariffV2(tariff)
|
||||
? computeFeeV2(enteredAt, asOf, tariff, category)
|
||||
: computeFeeV1(enteredAt, asOf, tariff);
|
||||
}
|
||||
|
||||
/** The original (V1) fee algorithm — a single block ladder, no wall-clock. Kept
|
||||
* VERBATIM so bare/legacy structures (incl. the live production version) price
|
||||
* identically. Do not "unify" this into the V2 path: a rounding divergence would
|
||||
* corrupt repricing of already-signed sessions. */
|
||||
function computeFeeV1(enteredAt: string, asOf: string, tariff: TariffStructureV1): number {
|
||||
const ms = Date.parse(asOf) - Date.parse(enteredAt);
|
||||
if (!Number.isFinite(ms) || ms <= 0) return 0;
|
||||
const rawMinutes = ms / 60_000;
|
||||
@@ -161,59 +254,251 @@ export function computeFee(
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* The V2 fee algorithm — adds wall-clock time-of-day / day-of-week / date windows
|
||||
* and vehicle-category cards on top of the V1 ladder. PURE + integer + deterministic
|
||||
* (the signed ledger reprices against this; reproducibility is mandatory).
|
||||
*
|
||||
* Two decoupled clocks: ELAPSED minutes advance the block-ladder position (continuous
|
||||
* across card switches — a happy-hour boundary mid-stay does NOT reset the ladder);
|
||||
* WALL-CLOCK time (in the version's frozen tz) selects which card's rate applies to
|
||||
* each increment. Stepping one increment at a time and re-selecting the card makes the
|
||||
* boundary slicing implicit. The DEFAULT card's dailyCap governs each rolling-24h day
|
||||
* (a windowed card lowers the rate but never the day ceiling). See tariff-time-tiers.md.
|
||||
*/
|
||||
function computeFeeV2(
|
||||
enteredAt: string,
|
||||
asOf: string,
|
||||
tariff: TariffStructureV2,
|
||||
category?: string,
|
||||
): number {
|
||||
const enteredMs = Date.parse(enteredAt);
|
||||
const ms = Date.parse(asOf) - enteredMs;
|
||||
if (!Number.isFinite(ms) || ms <= 0) return 0;
|
||||
const rawMinutes = ms / 60_000;
|
||||
if (rawMinutes <= tariff.gracePeriodEntryMin) return 0; // grace on RAW duration (V1 rule)
|
||||
const inc = Math.max(1, tariff.incrementMin);
|
||||
const minutes = Math.ceil(rawMinutes / inc) * inc; // round UP (V1 rule)
|
||||
|
||||
// Cards in contention: the default plus any windowed card matching the category.
|
||||
// (A card with no `category` applies to all; one with a category applies only to
|
||||
// a matching session.) The defaultCard always matches and is the fallback.
|
||||
const cards = [
|
||||
tariff.defaultCard,
|
||||
...(tariff.windowedCards ?? []).filter((c) => c.category == null || c.category === category),
|
||||
];
|
||||
const dayCap = tariff.defaultCard.dailyCapMinor ?? null;
|
||||
|
||||
const DAY = 24 * 60;
|
||||
let total = 0;
|
||||
for (let segStart = 0; segStart < minutes; segStart += DAY) {
|
||||
const segEnd = Math.min(segStart + DAY, minutes);
|
||||
let segFee = 0;
|
||||
for (let within = segStart; within < segEnd; within += inc) {
|
||||
const wall = localBreakdown(enteredMs + within * 60_000, tariff.tz);
|
||||
const card = selectCard(cards, wall);
|
||||
if (card.flatMinor != null) {
|
||||
segFee += card.flatMinor;
|
||||
} else {
|
||||
// Ladder position = minutes into THIS rolling-24h day (resets each day, V1 rule).
|
||||
segFee += rateAt(card.blocks ?? [], within - segStart);
|
||||
}
|
||||
}
|
||||
if (dayCap != null) segFee = Math.min(segFee, dayCap);
|
||||
total += segFee;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an admin-authored tariff structure. Returns [] if valid, else a list
|
||||
* of human-readable problems. Pure — used by the composer route (and any caller)
|
||||
* so a malformed rate card can never be published. See wiki/concepts/tariff.md.
|
||||
*/
|
||||
export function validateTariffStructure(s: unknown): string[] {
|
||||
const errs: string[] = [];
|
||||
if (!s || typeof s !== "object") return ["structure must be an object"];
|
||||
const t = s as Partial<TariffStructure>;
|
||||
// Discriminate: a `defaultCard` ⇒ the windowed V2 shape; otherwise legacy bare V1.
|
||||
// The V1 branch is kept byte-identical (same messages) so the live version still
|
||||
// validates the same on any future republish.
|
||||
return (s as Partial<TariffStructureV2>).defaultCard != null
|
||||
? validateTariffV2(s as Partial<TariffStructureV2>)
|
||||
: validateTariffV1(s as Partial<TariffStructureV1>);
|
||||
}
|
||||
|
||||
const nonNegInt = (v: unknown, label: string) => {
|
||||
function nonNegInt(v: unknown, label: string, errs: string[]): void {
|
||||
if (typeof v !== "number" || !Number.isInteger(v) || v < 0) errs.push(`${label} must be a non-negative integer`);
|
||||
};
|
||||
nonNegInt(t.gracePeriodEntryMin, "gracePeriodEntryMin");
|
||||
nonNegInt(t.gracePeriodExitMin, "gracePeriodExitMin");
|
||||
nonNegInt(t.lostTicketMinor, "lostTicketMinor");
|
||||
if (typeof t.incrementMin !== "number" || !Number.isInteger(t.incrementMin) || t.incrementMin < 1) {
|
||||
errs.push("incrementMin must be a positive integer");
|
||||
}
|
||||
if (t.dailyCapMinor != null) nonNegInt(t.dailyCapMinor, "dailyCapMinor");
|
||||
if (t.overstay !== "reprice") errs.push('overstay must be "reprice"');
|
||||
}
|
||||
|
||||
if (!Array.isArray(t.blocks) || t.blocks.length === 0) {
|
||||
errs.push("blocks must be a non-empty array");
|
||||
} else {
|
||||
/** Validate the block ladder (ascending bounds, open-ended last). `prefix` labels
|
||||
* errors (e.g. "blocks" or "defaultCard.blocks"). Shared by V1 + V2. */
|
||||
function validateBlocks(blocks: unknown, prefix: string, errs: string[]): void {
|
||||
if (!Array.isArray(blocks) || blocks.length === 0) {
|
||||
errs.push(`${prefix} must be a non-empty array`);
|
||||
return;
|
||||
}
|
||||
let prevBound = 0;
|
||||
t.blocks.forEach((b, i) => {
|
||||
const last = i === t.blocks!.length - 1;
|
||||
nonNegInt(b?.priceMinorPerIncrement, `blocks[${i}].priceMinorPerIncrement`);
|
||||
blocks.forEach((b: Partial<TariffBlock>, i: number) => {
|
||||
const last = i === blocks.length - 1;
|
||||
nonNegInt(b?.priceMinorPerIncrement, `${prefix}[${i}].priceMinorPerIncrement`, errs);
|
||||
if (b?.uptoMin == null) {
|
||||
if (!last) errs.push(`blocks[${i}] is open-ended (uptoMin null) but not last`);
|
||||
} else {
|
||||
if (typeof b.uptoMin !== "number" || !Number.isInteger(b.uptoMin) || b.uptoMin <= prevBound) {
|
||||
errs.push(`blocks[${i}].uptoMin must be an integer greater than the previous block's bound (${prevBound})`);
|
||||
if (!last) errs.push(`${prefix}[${i}] is open-ended (uptoMin null) but not last`);
|
||||
} else if (typeof b.uptoMin !== "number" || !Number.isInteger(b.uptoMin) || b.uptoMin <= prevBound) {
|
||||
errs.push(`${prefix}[${i}].uptoMin must be an integer greater than the previous block's bound (${prevBound})`);
|
||||
} else {
|
||||
prevBound = b.uptoMin;
|
||||
}
|
||||
}
|
||||
});
|
||||
// The LAST block must be open-ended (uptoMin null) so the "thereafter" rate is
|
||||
// always explicit. A bounded final block silently inherits its own rate past
|
||||
// its bound (a hidden, never-stated price) — forbidden on publish so the admin
|
||||
// must state what time beyond the ladder costs. See wiki/concepts/tariff.md.
|
||||
// (Read/pricing of already-published versions is unaffected — validation runs
|
||||
// only on publish; rateAt() still gracefully handles legacy bounded tails.)
|
||||
const lastBlock = t.blocks[t.blocks.length - 1];
|
||||
// always explicit — a bounded final block silently inherits its own rate past its
|
||||
// bound (a hidden, never-stated price). See wiki/concepts/tariff.md.
|
||||
const lastBlock = (blocks as Partial<TariffBlock>[])[blocks.length - 1];
|
||||
if (lastBlock && lastBlock.uptoMin != null) {
|
||||
errs.push("the last block must be open-ended (uptoMin: null) — the thereafter-rate must be stated explicitly");
|
||||
errs.push(
|
||||
prefix === "blocks"
|
||||
? "the last block must be open-ended (uptoMin: null) — the thereafter-rate must be stated explicitly"
|
||||
: `${prefix}: the last block must be open-ended (uptoMin: null) — the thereafter-rate must be stated explicitly`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateTariffV1(t: Partial<TariffStructureV1>): string[] {
|
||||
const errs: string[] = [];
|
||||
nonNegInt(t.gracePeriodEntryMin, "gracePeriodEntryMin", errs);
|
||||
nonNegInt(t.gracePeriodExitMin, "gracePeriodExitMin", errs);
|
||||
nonNegInt(t.lostTicketMinor, "lostTicketMinor", errs);
|
||||
if (typeof t.incrementMin !== "number" || !Number.isInteger(t.incrementMin) || t.incrementMin < 1) {
|
||||
errs.push("incrementMin must be a positive integer");
|
||||
}
|
||||
if (t.dailyCapMinor != null) nonNegInt(t.dailyCapMinor, "dailyCapMinor", errs);
|
||||
if (t.overstay !== "reprice") errs.push('overstay must be "reprice"');
|
||||
validateBlocks(t.blocks, "blocks", errs);
|
||||
return errs;
|
||||
}
|
||||
|
||||
const HHMM = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||
const YMD = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/** Validate one V2 card's pricing body (flat XOR ladder) + window. */
|
||||
function validateCard(c: Partial<TariffCard> | undefined, label: string, isDefault: boolean, errs: string[]): void {
|
||||
if (!c || typeof c !== "object") {
|
||||
errs.push(`${label} must be an object`);
|
||||
return;
|
||||
}
|
||||
if (typeof c.name !== "string" || c.name.length === 0) errs.push(`${label}.name is required`);
|
||||
if (typeof c.priority !== "number" || !Number.isInteger(c.priority)) errs.push(`${label}.priority must be an integer`);
|
||||
|
||||
const hasFlat = c.flatMinor != null;
|
||||
const hasBlocks = c.blocks != null;
|
||||
if (hasFlat === hasBlocks) {
|
||||
errs.push(`${label} must set exactly one of flatMinor or blocks`);
|
||||
} else if (hasFlat) {
|
||||
nonNegInt(c.flatMinor, `${label}.flatMinor`, errs);
|
||||
if (c.dailyCapMinor != null) errs.push(`${label}: dailyCapMinor applies to a block ladder, not a flat rate`);
|
||||
} else {
|
||||
validateBlocks(c.blocks, `${label}.blocks`, errs);
|
||||
if (c.dailyCapMinor != null) nonNegInt(c.dailyCapMinor, `${label}.dailyCapMinor`, errs);
|
||||
}
|
||||
|
||||
if (isDefault) {
|
||||
if (c.window != null) errs.push("defaultCard must not have a window (it is the always-active fallback)");
|
||||
if (c.category != null) errs.push("defaultCard must not have a category (it is the catch-all)");
|
||||
} else {
|
||||
validateWindow(c.window, `${label}.window`, errs);
|
||||
if (c.category != null && (typeof c.category !== "string" || c.category.length === 0)) {
|
||||
errs.push(`${label}.category must be a non-empty string when present`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateWindow(w: Partial<TariffWindow> | undefined, label: string, errs: string[]): void {
|
||||
if (w == null) return; // a windowed card with no window = always-on tier (allowed)
|
||||
if (w.dow != null) {
|
||||
if (!Array.isArray(w.dow) || w.dow.some((d) => !Number.isInteger(d) || d < 0 || d > 6)) {
|
||||
errs.push(`${label}.dow must be integers 0-6 (0=Sun)`);
|
||||
}
|
||||
}
|
||||
const hasFrom = w.fromHour != null;
|
||||
const hasTo = w.toHour != null;
|
||||
if (hasFrom !== hasTo) errs.push(`${label}: fromHour and toHour must be set together`);
|
||||
if (hasFrom && hasTo) {
|
||||
if (!HHMM.test(w.fromHour!)) errs.push(`${label}.fromHour must be "HH:MM"`);
|
||||
if (!HHMM.test(w.toHour!)) errs.push(`${label}.toHour must be "HH:MM"`);
|
||||
// toHour <= fromHour is allowed (overnight wrap) — not an error.
|
||||
}
|
||||
if (w.dateFrom != null && !YMD.test(w.dateFrom)) errs.push(`${label}.dateFrom must be "YYYY-MM-DD"`);
|
||||
if (w.dateTo != null && !YMD.test(w.dateTo)) errs.push(`${label}.dateTo must be "YYYY-MM-DD"`);
|
||||
if (w.dateFrom != null && w.dateTo != null && YMD.test(w.dateFrom) && YMD.test(w.dateTo) && w.dateFrom > w.dateTo) {
|
||||
errs.push(`${label}.dateFrom must be ≤ dateTo`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateTariffV2(t: Partial<TariffStructureV2>): string[] {
|
||||
const errs: string[] = [];
|
||||
nonNegInt(t.gracePeriodEntryMin, "gracePeriodEntryMin", errs);
|
||||
nonNegInt(t.gracePeriodExitMin, "gracePeriodExitMin", errs);
|
||||
nonNegInt(t.lostTicketMinor, "lostTicketMinor", errs);
|
||||
if (typeof t.incrementMin !== "number" || !Number.isInteger(t.incrementMin) || t.incrementMin < 1) {
|
||||
errs.push("incrementMin must be a positive integer");
|
||||
}
|
||||
if (t.overstay !== "reprice") errs.push('overstay must be "reprice"');
|
||||
|
||||
const cards = t.windowedCards ?? [];
|
||||
// tz is required once there are windowed cards (wall-clock is meaningless without it).
|
||||
if (cards.length > 0 && (typeof t.tz !== "string" || t.tz.length === 0)) {
|
||||
errs.push("tz (IANA timezone) is required when windowedCards are present");
|
||||
}
|
||||
|
||||
validateCard(t.defaultCard, "defaultCard", true, errs);
|
||||
if (!Array.isArray(t.windowedCards) && t.windowedCards != null) {
|
||||
errs.push("windowedCards must be an array");
|
||||
} else {
|
||||
cards.forEach((c, i) => validateCard(c, `windowedCards[${i}]`, false, errs));
|
||||
}
|
||||
|
||||
// Precedence determinism: reject two cards (same category bucket) that tie on
|
||||
// (specificity, priority) with overlapping windows — the operator must break the
|
||||
// tie with priority rather than relying silently on the name tiebreak.
|
||||
detectAmbiguousPrecedence(cards, errs);
|
||||
return errs;
|
||||
}
|
||||
|
||||
/** Flag pairs of windowed cards that could BOTH be the precedence winner for some
|
||||
* instant (same category bucket, equal specificity + priority, overlapping windows).
|
||||
* Conservative overlap test; false positives are safer than a silent tie. */
|
||||
function detectAmbiguousPrecedence(cards: readonly Partial<TariffCard>[], errs: string[]): void {
|
||||
for (let i = 0; i < cards.length; i++) {
|
||||
for (let j = i + 1; j < cards.length; j++) {
|
||||
const a = cards[i]!;
|
||||
const b = cards[j]!;
|
||||
if ((a.category ?? null) !== (b.category ?? null)) continue;
|
||||
if (a.priority !== b.priority) continue;
|
||||
const sa = specificity(a as TariffCard);
|
||||
const sb = specificity(b as TariffCard);
|
||||
if (sa[0] !== sb[0] || sa[1] !== sb[1] || sa[2] !== sb[2]) continue;
|
||||
if (windowsOverlap(a.window, b.window)) {
|
||||
errs.push(
|
||||
`windowedCards "${a.name ?? i}" and "${b.name ?? j}" are equally specific with the same priority and overlapping windows — give one a higher priority to break the tie`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Conservative window-overlap: true unless a dimension provably disjoints them. */
|
||||
function windowsOverlap(a: TariffWindow | undefined, b: TariffWindow | undefined): boolean {
|
||||
if (!a || !b) return true; // an unconstrained window overlaps anything
|
||||
// dow: disjoint only if both constrain dow and share no day.
|
||||
if (a.dow && a.dow.length && b.dow && b.dow.length && !a.dow.some((d) => b.dow!.includes(d))) return false;
|
||||
// date: disjoint only if both fully bounded and ranges don't intersect.
|
||||
if (a.dateFrom && a.dateTo && b.dateFrom && b.dateTo && (a.dateTo < b.dateFrom || b.dateTo < a.dateFrom)) return false;
|
||||
// hour: disjoint only if both have non-wrapping ranges that don't intersect.
|
||||
if (a.fromHour && a.toHour && b.fromHour && b.toHour) {
|
||||
const af = hourToMin(a.fromHour), at = hourToMin(a.toHour), bf = hourToMin(b.fromHour), bt = hourToMin(b.toHour);
|
||||
if (at > af && bt > bf && (at <= bf || bt <= af)) return false; // both non-wrapping & disjoint
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Price of the increment that starts at `cumulativeMin` — the block whose range
|
||||
* [prevUpto, uptoMin) contains it; the open-ended (uptoMin=null) block catches the rest. */
|
||||
function rateAt(blocks: readonly TariffBlock[], cumulativeMin: number): number {
|
||||
@@ -227,6 +512,131 @@ function rateAt(blocks: readonly TariffBlock[], cumulativeMin: number): number {
|
||||
return blocks.length ? blocks[blocks.length - 1]!.priceMinorPerIncrement : 0;
|
||||
}
|
||||
|
||||
// --- V2 wall-clock helpers (pure, deterministic given the frozen tz) ----------
|
||||
|
||||
/** Wall-clock breakdown of an instant in a fixed IANA tz. Pure: the same (instant,
|
||||
* tz) always yields the same result (tz is frozen in the tariff version, never the
|
||||
* host). Uses Intl.DateTimeFormat — handles DST for the named zone. */
|
||||
export interface WallClock {
|
||||
readonly y: number;
|
||||
readonly mo: number; // 1-12
|
||||
readonly d: number; // 1-31
|
||||
readonly hour: number; // 0-23
|
||||
readonly minute: number; // 0-59
|
||||
readonly dow: number; // 0=Sun..6=Sat
|
||||
}
|
||||
|
||||
const DOW_INDEX: Record<string, number> = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
|
||||
|
||||
export function localBreakdown(instantMs: number, tz: string): WallClock {
|
||||
const fmt = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: tz,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
weekday: "short",
|
||||
});
|
||||
const parts = fmt.formatToParts(new Date(instantMs));
|
||||
const get = (t: string) => parts.find((p) => p.type === t)?.value ?? "";
|
||||
return {
|
||||
y: Number(get("year")),
|
||||
mo: Number(get("month")),
|
||||
d: Number(get("day")),
|
||||
hour: Number(get("hour")),
|
||||
minute: Number(get("minute")),
|
||||
dow: DOW_INDEX[get("weekday")] ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** "HH:MM" → minutes-of-day (0-1439). Invalid → NaN (validation rejects those). */
|
||||
function hourToMin(hhmm: string): number {
|
||||
const m = /^(\d{2}):(\d{2})$/.exec(hhmm);
|
||||
if (!m) return NaN;
|
||||
return Number(m[1]) * 60 + Number(m[2]);
|
||||
}
|
||||
|
||||
/** "YYYY-MM-DD" → comparable integer YYYYMMDD. */
|
||||
function dateKey(w: WallClock): number {
|
||||
return w.y * 10000 + w.mo * 100 + w.d;
|
||||
}
|
||||
function isoDateKey(iso: string): number {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
|
||||
return m ? Number(m[1]) * 10000 + Number(m[2]) * 100 + Number(m[3]) : NaN;
|
||||
}
|
||||
|
||||
/** Does a card's window cover this wall-clock instant? Absent parts are unconstrained;
|
||||
* an absent window (defaultCard) always matches. An hour range with `toHour <= fromHour`
|
||||
* is an overnight wrap (active when hour ≥ fromHour OR hour < toHour). */
|
||||
function matchesWindow(w: TariffWindow | undefined, wall: WallClock): boolean {
|
||||
if (!w) return true;
|
||||
if (w.dow && w.dow.length > 0 && !w.dow.includes(wall.dow)) return false;
|
||||
if (w.dateFrom != null && dateKey(wall) < isoDateKey(w.dateFrom)) return false;
|
||||
if (w.dateTo != null && dateKey(wall) > isoDateKey(w.dateTo)) return false;
|
||||
if (w.fromHour != null && w.toHour != null) {
|
||||
const from = hourToMin(w.fromHour);
|
||||
const to = hourToMin(w.toHour);
|
||||
const now = wall.hour * 60 + wall.minute;
|
||||
if (to <= from) {
|
||||
// overnight wrap, e.g. 22:00→06:00
|
||||
if (!(now >= from || now < to)) return false;
|
||||
} else {
|
||||
if (!(now >= from && now < to)) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Specificity tuple (date, dow, hour) — more constrained windows win. Higher is
|
||||
* more specific; compared lexicographically. */
|
||||
function specificity(c: TariffCard): [number, number, number] {
|
||||
const w = c.window;
|
||||
const hasDate = w != null && (w.dateFrom != null || w.dateTo != null) ? 1 : 0;
|
||||
const hasDow = w != null && w.dow != null && w.dow.length > 0 ? 1 : 0;
|
||||
const hasHour = w != null && w.fromHour != null && w.toHour != null ? 1 : 0;
|
||||
return [hasDate, hasDow, hasHour];
|
||||
}
|
||||
|
||||
/** Pick the single active card for a wall-clock instant from the candidate cards
|
||||
* (default + category-matched). TOTAL + order-independent: most-specific wins, then
|
||||
* higher `priority`, then `name` lexicographically as the final deterministic tiebreak
|
||||
* (never array index). The defaultCard has specificity (0,0,0) so it only wins when
|
||||
* nothing more specific matches. */
|
||||
function selectCard(cards: readonly TariffCard[], wall: WallClock): TariffCard {
|
||||
let best: TariffCard | undefined;
|
||||
let bestSpec: [number, number, number] = [-1, -1, -1];
|
||||
for (const c of cards) {
|
||||
if (!matchesWindow(c.window, wall)) continue;
|
||||
const spec = specificity(c);
|
||||
if (best === undefined || compareCard(spec, c, bestSpec, best) > 0) {
|
||||
best = c;
|
||||
bestSpec = spec;
|
||||
}
|
||||
}
|
||||
// The defaultCard always matches, so `best` is never undefined in practice; the
|
||||
// fallback keeps the function total even for a pathological empty card list.
|
||||
return best ?? cards[0]!;
|
||||
}
|
||||
|
||||
/** Order: specificity desc, then priority desc, then name asc. Returns >0 if (specA,a)
|
||||
* should beat (specB,b). */
|
||||
function compareCard(
|
||||
specA: [number, number, number],
|
||||
a: TariffCard,
|
||||
specB: [number, number, number],
|
||||
b: TariffCard,
|
||||
): number {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (specA[i]! !== specB[i]!) return specA[i]! - specB[i]!;
|
||||
}
|
||||
if (a.priority !== b.priority) return a.priority - b.priority;
|
||||
// Name as the final, total tiebreak. Lower name wins → invert so >0 means a beats b.
|
||||
if (a.name !== b.name) return a.name < b.name ? 1 : -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export const ROLES: readonly Role[] = [
|
||||
"admin",
|
||||
"operator",
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
computeFee,
|
||||
validateTariffStructure,
|
||||
type TariffStructureV1,
|
||||
type TariffStructureV2,
|
||||
type TariffCard,
|
||||
} from "./index.js";
|
||||
|
||||
const entered = "2026-06-18T00:00:00.000Z";
|
||||
const at = (min: number) => new Date(Date.parse(entered) + min * 60_000).toISOString();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (a) GOLDEN V1 regression — the live production structure must reprice to these
|
||||
// exact integers. Captured from the pre-V2 engine. This is the most important
|
||||
// test: it proves a signed historical session reprices identically.
|
||||
// ---------------------------------------------------------------------------
|
||||
const liveV1: TariffStructureV1 = {
|
||||
gracePeriodEntryMin: 5,
|
||||
incrementMin: 60,
|
||||
blocks: [
|
||||
{ uptoMin: 60, priceMinorPerIncrement: 20000 },
|
||||
{ uptoMin: 180, priceMinorPerIncrement: 10000 },
|
||||
],
|
||||
dailyCapMinor: 100000,
|
||||
lostTicketMinor: 100000,
|
||||
gracePeriodExitMin: 5,
|
||||
overstay: "reprice",
|
||||
};
|
||||
|
||||
describe("V1 golden regression", () => {
|
||||
const golden: Record<number, number> = {
|
||||
3: 0, 30: 20000, 60: 20000, 61: 30000, 120: 30000, 180: 40000,
|
||||
181: 50000, 240: 50000, 1440: 100000, 1500: 120000, 2880: 200000,
|
||||
};
|
||||
for (const [min, want] of Object.entries(golden)) {
|
||||
it(`${min} min → ${want}`, () => {
|
||||
expect(computeFee(entered, at(Number(min)), liveV1)).toBe(want);
|
||||
});
|
||||
}
|
||||
it("a V1 structure ignores the category argument", () => {
|
||||
expect(computeFee(entered, at(120), liveV1, "bus")).toBe(30000);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// V2 building blocks
|
||||
// ---------------------------------------------------------------------------
|
||||
const ladder = (open: number, first?: { uptoMin: number; rate: number }) =>
|
||||
first
|
||||
? [{ uptoMin: first.uptoMin, priceMinorPerIncrement: first.rate }, { uptoMin: null, priceMinorPerIncrement: open }]
|
||||
: [{ uptoMin: null, priceMinorPerIncrement: open }];
|
||||
|
||||
const defaultCard: TariffCard = {
|
||||
name: "default",
|
||||
priority: 0,
|
||||
blocks: ladder(20000), // flat 200/h ladder (open-ended)
|
||||
dailyCapMinor: null,
|
||||
};
|
||||
|
||||
function v2(windowedCards: TariffCard[], tz = "Europe/Tirane", over: Partial<TariffStructureV2> = {}): TariffStructureV2 {
|
||||
return {
|
||||
version: 2,
|
||||
tz,
|
||||
gracePeriodEntryMin: 5,
|
||||
incrementMin: 60,
|
||||
lostTicketMinor: 100000,
|
||||
gracePeriodExitMin: 5,
|
||||
overstay: "reprice",
|
||||
defaultCard,
|
||||
windowedCards,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("V2 back-compat: a V2 with no windowed cards prices like its default ladder", () => {
|
||||
it("default-only V2 == equivalent V1", () => {
|
||||
const s = v2([]);
|
||||
// 200/h flat ladder, 3h
|
||||
expect(computeFee(entered, at(180), s)).toBe(60000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("V2 time-of-day window (happy hour)", () => {
|
||||
// Tirane is UTC+2 in June (DST). entered 00:00Z = 02:00 local.
|
||||
// Happy hour 04:00–06:00 local = 02:00–04:00Z. Default 200/h, happy 50/h.
|
||||
const happy: TariffCard = {
|
||||
name: "happy",
|
||||
priority: 10,
|
||||
window: { fromHour: "04:00", toHour: "06:00" },
|
||||
blocks: ladder(5000),
|
||||
};
|
||||
const s = v2([happy]);
|
||||
it("a stay crossing into happy hour bills each increment by its wall-clock card", () => {
|
||||
// 0-120min elapsed = local 02:00-04:00 (default 200/h ×2 = 400),
|
||||
// 120-240min = local 04:00-06:00 (happy 50/h ×2 = 100). Total 500 = 50000.
|
||||
expect(computeFee(entered, at(240), s)).toBe(50000);
|
||||
});
|
||||
it("a stay entirely before happy hour is all default", () => {
|
||||
expect(computeFee(entered, at(120), s)).toBe(40000); // 2h × 200
|
||||
});
|
||||
});
|
||||
|
||||
describe("V2 overnight wrap window", () => {
|
||||
// night 22:00→06:00 local (wraps midnight), cheap 50/h.
|
||||
const night: TariffCard = {
|
||||
name: "night",
|
||||
priority: 10,
|
||||
window: { fromHour: "22:00", toHour: "06:00" },
|
||||
blocks: ladder(5000),
|
||||
};
|
||||
const s = v2([night]);
|
||||
it("an early-morning stay (local 02:00-04:00) is inside the wrap → night rate", () => {
|
||||
expect(computeFee(entered, at(120), s)).toBe(10000); // 2h × 50
|
||||
});
|
||||
});
|
||||
|
||||
describe("V2 day-of-week tested at the increment's wall-clock day", () => {
|
||||
// 2026-06-18 is a Thursday (dow 4). A Friday-only card must NOT apply.
|
||||
const friOnly: TariffCard = { name: "fri", priority: 10, window: { dow: [5] }, blocks: ladder(5000) };
|
||||
it("Thursday stay does not get the Friday card", () => {
|
||||
expect(computeFee(entered, at(120), v2([friOnly]))).toBe(40000); // default 200×2
|
||||
});
|
||||
const thuOnly: TariffCard = { name: "thu", priority: 10, window: { dow: [4] }, blocks: ladder(5000) };
|
||||
it("Thursday stay gets the Thursday card", () => {
|
||||
expect(computeFee(entered, at(120), v2([thuOnly]))).toBe(10000); // 50×2
|
||||
});
|
||||
});
|
||||
|
||||
describe("V2 flat card", () => {
|
||||
const flatNight: TariffCard = {
|
||||
name: "flat",
|
||||
priority: 10,
|
||||
window: { fromHour: "00:00", toHour: "23:59" }, // effectively all day here
|
||||
flatMinor: 3000,
|
||||
};
|
||||
it("flat card charges flatMinor per increment", () => {
|
||||
expect(computeFee(entered, at(180), v2([flatNight]))).toBe(9000); // 3h × 30
|
||||
});
|
||||
});
|
||||
|
||||
describe("V2 category filter", () => {
|
||||
const busCard: TariffCard = { name: "bus", priority: 10, category: "bus", blocks: ladder(40000) };
|
||||
const s = v2([busCard]);
|
||||
it("a bus session uses the bus card (400/h)", () => {
|
||||
expect(computeFee(entered, at(120), s, "bus")).toBe(80000);
|
||||
});
|
||||
it("a car session ignores the bus card → default (200/h)", () => {
|
||||
expect(computeFee(entered, at(120), s, "car")).toBe(40000);
|
||||
});
|
||||
it("no category given ignores the bus card → default", () => {
|
||||
expect(computeFee(entered, at(120), s)).toBe(40000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("V2 daily cap uses the DEFAULT card's cap on a mixed day", () => {
|
||||
// default cap 1000/day; a cheap night card present. 24h elapsed.
|
||||
const night: TariffCard = { name: "night", priority: 10, window: { fromHour: "22:00", toHour: "06:00" }, blocks: ladder(5000) };
|
||||
const s = v2([night], "Europe/Tirane", { defaultCard: { ...defaultCard, dailyCapMinor: 100000 } });
|
||||
it("a 24h stay is capped at the default card's 1000/day", () => {
|
||||
expect(computeFee(entered, at(1440), s)).toBe(100000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("V2 precedence is total + order-independent", () => {
|
||||
// Specificity order is date > dow > hour-only (see plan / tariff-time-tiers.md).
|
||||
// So a dow-constrained card beats an hour-only card at an overlapping instant.
|
||||
const dowCard: TariffCard = { name: "a-dow", priority: 5, window: { dow: [4] }, blocks: ladder(10000) }; // Thu, 100/h
|
||||
const hourCard: TariffCard = { name: "b-hour", priority: 5, window: { fromHour: "02:00", toHour: "04:00" }, blocks: ladder(5000) }; // local 02-04, 50/h
|
||||
it("dow (more specific than hour-only) wins at an overlapping instant", () => {
|
||||
// local 02:00-04:00 = elapsed 0-120; both match, dow ranks above hour → 100/h
|
||||
expect(computeFee(entered, at(120), v2([dowCard, hourCard]))).toBe(20000);
|
||||
});
|
||||
it("a date window beats a dow window (date is most specific)", () => {
|
||||
const dateCard: TariffCard = { name: "c-date", priority: 1, window: { dateFrom: "2026-06-18", dateTo: "2026-06-18" }, blocks: ladder(5000) }; // 50/h
|
||||
// date beats dow even with LOWER priority (specificity dominates priority)
|
||||
expect(computeFee(entered, at(120), v2([dowCard, dateCard]))).toBe(10000);
|
||||
});
|
||||
it("fee is identical when windowedCards order is shuffled", () => {
|
||||
const a = computeFee(entered, at(120), v2([dowCard, hourCard]));
|
||||
const b = computeFee(entered, at(120), v2([hourCard, dowCard]));
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe("V2 DST determinism (Europe/Tirane)", () => {
|
||||
// Spring forward 2026-03-29 03:00 local (clocks 02:00→03:00). Fall back 2026-10-25.
|
||||
const cheap: TariffCard = { name: "c", priority: 10, window: { fromHour: "00:00", toHour: "23:59" }, flatMinor: 1000 };
|
||||
it("a stay across the spring-forward boundary prices deterministically", () => {
|
||||
const e = "2026-03-29T00:00:00.000Z"; // 01:00 local pre-jump
|
||||
const a1 = computeFee(e, new Date(Date.parse(e) + 240 * 60_000).toISOString(), v2([cheap]));
|
||||
const a2 = computeFee(e, new Date(Date.parse(e) + 240 * 60_000).toISOString(), v2([cheap]));
|
||||
expect(a1).toBe(a2); // determinism
|
||||
expect(a1).toBe(4000); // 4h × flat 10
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (e) validation accept/reject matrix
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("validate V1 (unchanged messages)", () => {
|
||||
it("accepts the live structure", () => {
|
||||
expect(validateTariffStructure({ ...liveV1, blocks: [...liveV1.blocks, { uptoMin: null, priceMinorPerIncrement: 5000 }] })).toEqual([]);
|
||||
});
|
||||
it("rejects a bounded last block", () => {
|
||||
expect(validateTariffStructure(liveV1)).toContain(
|
||||
"the last block must be open-ended (uptoMin: null) — the thereafter-rate must be stated explicitly",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validate V2", () => {
|
||||
const okDefault: TariffCard = { name: "d", priority: 0, blocks: ladder(20000) };
|
||||
const base = { version: 2 as const, tz: "Europe/Tirane", gracePeriodEntryMin: 5, incrementMin: 60, lostTicketMinor: 0, gracePeriodExitMin: 5, overstay: "reprice" as const };
|
||||
|
||||
it("accepts a minimal default-only V2", () => {
|
||||
expect(validateTariffStructure({ ...base, defaultCard: okDefault })).toEqual([]);
|
||||
});
|
||||
it("requires tz when windowedCards present", () => {
|
||||
const errs = validateTariffStructure({ ...base, tz: "", defaultCard: okDefault, windowedCards: [{ name: "w", priority: 1, window: { dow: [1] }, blocks: ladder(5000) }] });
|
||||
expect(errs).toContain("tz (IANA timezone) is required when windowedCards are present");
|
||||
});
|
||||
it("rejects a card with both flat and blocks", () => {
|
||||
const errs = validateTariffStructure({ ...base, defaultCard: { name: "d", priority: 0, flatMinor: 100, blocks: ladder(100) } });
|
||||
expect(errs).toContain("defaultCard must set exactly one of flatMinor or blocks");
|
||||
});
|
||||
it("rejects defaultCard with a window", () => {
|
||||
const errs = validateTariffStructure({ ...base, defaultCard: { ...okDefault, window: { dow: [1] } } });
|
||||
expect(errs).toContain("defaultCard must not have a window (it is the always-active fallback)");
|
||||
});
|
||||
it("rejects a bad hour format", () => {
|
||||
const errs = validateTariffStructure({ ...base, defaultCard: okDefault, windowedCards: [{ name: "w", priority: 1, window: { fromHour: "25:00", toHour: "26:00" }, blocks: ladder(5000) }] });
|
||||
expect(errs.some((e) => e.includes("fromHour"))).toBe(true);
|
||||
});
|
||||
it("rejects ambiguous precedence (equal specificity+priority, overlapping)", () => {
|
||||
const errs = validateTariffStructure({
|
||||
...base,
|
||||
defaultCard: okDefault,
|
||||
windowedCards: [
|
||||
{ name: "x", priority: 5, window: { dow: [1, 2] }, blocks: ladder(5000) },
|
||||
{ name: "y", priority: 5, window: { dow: [2, 3] }, blocks: ladder(6000) },
|
||||
],
|
||||
});
|
||||
expect(errs.some((e) => e.includes("higher priority to break the tie"))).toBe(true);
|
||||
});
|
||||
it("allows the tie to be broken by priority", () => {
|
||||
const errs = validateTariffStructure({
|
||||
...base,
|
||||
defaultCard: okDefault,
|
||||
windowedCards: [
|
||||
{ name: "x", priority: 5, window: { dow: [1, 2] }, blocks: ladder(5000) },
|
||||
{ name: "y", priority: 6, window: { dow: [2, 3] }, blocks: ladder(6000) },
|
||||
],
|
||||
});
|
||||
expect(errs).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
// Only run tests from src (TypeScript source). Without this, the compiled copies
|
||||
// in dist/ get picked up as duplicate (stale) test files.
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
Generated
+252
@@ -168,6 +168,9 @@ importers:
|
||||
typescript:
|
||||
specifier: 6.0.3
|
||||
version: 6.0.3
|
||||
vitest:
|
||||
specifier: ^4.1.9
|
||||
version: 4.1.9(@types/node@25.9.3)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4))
|
||||
|
||||
packages:
|
||||
|
||||
@@ -1109,6 +1112,9 @@ packages:
|
||||
'@rolldown/pluginutils@1.0.1':
|
||||
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
|
||||
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
'@tailwindcss/node@4.3.1':
|
||||
resolution: {integrity: sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==}
|
||||
|
||||
@@ -1292,6 +1298,15 @@ packages:
|
||||
'@types/better-sqlite3@7.6.13':
|
||||
resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==}
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||
|
||||
'@types/deep-eql@4.0.2':
|
||||
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||
|
||||
'@types/estree@1.0.9':
|
||||
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||
|
||||
'@types/node@25.9.3':
|
||||
resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==}
|
||||
|
||||
@@ -1316,6 +1331,35 @@ packages:
|
||||
babel-plugin-react-compiler:
|
||||
optional: true
|
||||
|
||||
'@vitest/expect@4.1.9':
|
||||
resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==}
|
||||
|
||||
'@vitest/mocker@4.1.9':
|
||||
resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==}
|
||||
peerDependencies:
|
||||
msw: ^2.4.9
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
msw:
|
||||
optional: true
|
||||
vite:
|
||||
optional: true
|
||||
|
||||
'@vitest/pretty-format@4.1.9':
|
||||
resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==}
|
||||
|
||||
'@vitest/runner@4.1.9':
|
||||
resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==}
|
||||
|
||||
'@vitest/snapshot@4.1.9':
|
||||
resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==}
|
||||
|
||||
'@vitest/spy@4.1.9':
|
||||
resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==}
|
||||
|
||||
'@vitest/utils@4.1.9':
|
||||
resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==}
|
||||
|
||||
abstract-logging@2.0.1:
|
||||
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
|
||||
|
||||
@@ -1337,6 +1381,10 @@ packages:
|
||||
asn1.js@5.4.1:
|
||||
resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==}
|
||||
|
||||
assertion-error@2.0.1:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
atomic-sleep@1.0.0:
|
||||
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
@@ -1378,6 +1426,10 @@ packages:
|
||||
buffer@5.7.1:
|
||||
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
|
||||
|
||||
chai@6.2.2:
|
||||
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
chownr@1.1.4:
|
||||
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
|
||||
|
||||
@@ -1389,6 +1441,9 @@ packages:
|
||||
resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
convert-source-map@2.0.0:
|
||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||
|
||||
cookie-es@3.1.1:
|
||||
resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==}
|
||||
|
||||
@@ -1531,6 +1586,9 @@ packages:
|
||||
resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
es-module-lexer@2.1.0:
|
||||
resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==}
|
||||
|
||||
esbuild@0.18.20:
|
||||
resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -1549,10 +1607,17 @@ packages:
|
||||
escape-html@1.0.3:
|
||||
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||
|
||||
expand-template@2.0.3:
|
||||
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
expect-type@1.3.0:
|
||||
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
fast-decode-uri-component@1.0.1:
|
||||
resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==}
|
||||
|
||||
@@ -1814,6 +1879,10 @@ packages:
|
||||
obliterator@2.0.5:
|
||||
resolution: {integrity: sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==}
|
||||
|
||||
obug@2.1.3:
|
||||
resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
|
||||
on-exit-leak-free@2.1.2:
|
||||
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
@@ -1825,6 +1894,9 @@ packages:
|
||||
resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
pathe@2.0.3:
|
||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
@@ -1998,6 +2070,9 @@ packages:
|
||||
setprototypeof@1.2.0:
|
||||
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
||||
|
||||
siginfo@2.0.0:
|
||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||
|
||||
simple-concat@1.0.1:
|
||||
resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
|
||||
|
||||
@@ -2022,10 +2097,16 @@ packages:
|
||||
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||
engines: {node: '>= 10.x'}
|
||||
|
||||
stackback@0.0.2:
|
||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||
|
||||
statuses@2.0.2:
|
||||
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
std-env@4.1.0:
|
||||
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
|
||||
|
||||
steed@1.1.3:
|
||||
resolution: {integrity: sha512-EUkci0FAUiE4IvGTSKcDJIQ/eRUP2JJb56+fvZ4sdnguLTqIdKjSxUe138poW8mkvKWXW2sFPrgTsxqoISnmoA==}
|
||||
|
||||
@@ -2057,10 +2138,21 @@ packages:
|
||||
resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
tinybench@2.9.0:
|
||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||
|
||||
tinyexec@1.2.4:
|
||||
resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
tinyglobby@0.2.17:
|
||||
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
tinyrainbow@3.1.0:
|
||||
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
toad-cache@3.7.1:
|
||||
resolution: {integrity: sha512-5DXWzE4Vz7xNHsv+xQ+MGfJYyC78Aok3tEr0MNwHoRf7vZnga1mQXZ4/Nsodld4VR6Wd+VhfmqnNrsRJyYPfrQ==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -2163,10 +2255,56 @@ packages:
|
||||
yaml:
|
||||
optional: true
|
||||
|
||||
vitest@4.1.9:
|
||||
resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==}
|
||||
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@edge-runtime/vm': '*'
|
||||
'@opentelemetry/api': ^1.9.0
|
||||
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
|
||||
'@vitest/browser-playwright': 4.1.9
|
||||
'@vitest/browser-preview': 4.1.9
|
||||
'@vitest/browser-webdriverio': 4.1.9
|
||||
'@vitest/coverage-istanbul': 4.1.9
|
||||
'@vitest/coverage-v8': 4.1.9
|
||||
'@vitest/ui': 4.1.9
|
||||
happy-dom: '*'
|
||||
jsdom: '*'
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
'@edge-runtime/vm':
|
||||
optional: true
|
||||
'@opentelemetry/api':
|
||||
optional: true
|
||||
'@types/node':
|
||||
optional: true
|
||||
'@vitest/browser-playwright':
|
||||
optional: true
|
||||
'@vitest/browser-preview':
|
||||
optional: true
|
||||
'@vitest/browser-webdriverio':
|
||||
optional: true
|
||||
'@vitest/coverage-istanbul':
|
||||
optional: true
|
||||
'@vitest/coverage-v8':
|
||||
optional: true
|
||||
'@vitest/ui':
|
||||
optional: true
|
||||
happy-dom:
|
||||
optional: true
|
||||
jsdom:
|
||||
optional: true
|
||||
|
||||
void-elements@3.1.0:
|
||||
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||
engines: {node: '>=8'}
|
||||
hasBin: true
|
||||
|
||||
wrappy@1.0.2:
|
||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||
|
||||
@@ -2904,6 +3042,8 @@ snapshots:
|
||||
|
||||
'@rolldown/pluginutils@1.0.1': {}
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@tailwindcss/node@4.3.1':
|
||||
dependencies:
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
@@ -3056,6 +3196,15 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 25.9.3
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
dependencies:
|
||||
'@types/deep-eql': 4.0.2
|
||||
assertion-error: 2.0.1
|
||||
|
||||
'@types/deep-eql@4.0.2': {}
|
||||
|
||||
'@types/estree@1.0.9': {}
|
||||
|
||||
'@types/node@25.9.3':
|
||||
dependencies:
|
||||
undici-types: 7.24.6
|
||||
@@ -3073,6 +3222,47 @@ snapshots:
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
vite: 8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)
|
||||
|
||||
'@vitest/expect@4.1.9':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/spy': 4.1.9
|
||||
'@vitest/utils': 4.1.9
|
||||
chai: 6.2.2
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/mocker@4.1.9(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.1.9
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)
|
||||
|
||||
'@vitest/pretty-format@4.1.9':
|
||||
dependencies:
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/runner@4.1.9':
|
||||
dependencies:
|
||||
'@vitest/utils': 4.1.9
|
||||
pathe: 2.0.3
|
||||
|
||||
'@vitest/snapshot@4.1.9':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 4.1.9
|
||||
'@vitest/utils': 4.1.9
|
||||
magic-string: 0.30.21
|
||||
pathe: 2.0.3
|
||||
|
||||
'@vitest/spy@4.1.9': {}
|
||||
|
||||
'@vitest/utils@4.1.9':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 4.1.9
|
||||
convert-source-map: 2.0.0
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
abstract-logging@2.0.1: {}
|
||||
|
||||
ajv-formats@3.0.1(ajv@8.20.0):
|
||||
@@ -3097,6 +3287,8 @@ snapshots:
|
||||
minimalistic-assert: 1.0.1
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
atomic-sleep@1.0.0: {}
|
||||
|
||||
avvio@9.2.0:
|
||||
@@ -3141,12 +3333,16 @@ snapshots:
|
||||
base64-js: 1.5.1
|
||||
ieee754: 1.2.1
|
||||
|
||||
chai@6.2.2: {}
|
||||
|
||||
chownr@1.1.4: {}
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
content-disposition@1.1.0: {}
|
||||
|
||||
convert-source-map@2.0.0: {}
|
||||
|
||||
cookie-es@3.1.1: {}
|
||||
|
||||
cookie@1.1.1: {}
|
||||
@@ -3199,6 +3395,8 @@ snapshots:
|
||||
graceful-fs: 4.2.11
|
||||
tapable: 2.3.3
|
||||
|
||||
es-module-lexer@2.1.0: {}
|
||||
|
||||
esbuild@0.18.20:
|
||||
optionalDependencies:
|
||||
'@esbuild/android-arm': 0.18.20
|
||||
@@ -3284,8 +3482,14 @@ snapshots:
|
||||
|
||||
escape-html@1.0.3: {}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.9
|
||||
|
||||
expand-template@2.0.3: {}
|
||||
|
||||
expect-type@1.3.0: {}
|
||||
|
||||
fast-decode-uri-component@1.0.1: {}
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
@@ -3519,6 +3723,8 @@ snapshots:
|
||||
|
||||
obliterator@2.0.5: {}
|
||||
|
||||
obug@2.1.3: {}
|
||||
|
||||
on-exit-leak-free@2.1.2: {}
|
||||
|
||||
once@1.4.0:
|
||||
@@ -3530,6 +3736,8 @@ snapshots:
|
||||
lru-cache: 11.5.1
|
||||
minipass: 7.1.3
|
||||
|
||||
pathe@2.0.3: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@4.0.4: {}
|
||||
@@ -3705,6 +3913,8 @@ snapshots:
|
||||
|
||||
setprototypeof@1.2.0: {}
|
||||
|
||||
siginfo@2.0.0: {}
|
||||
|
||||
simple-concat@1.0.1: {}
|
||||
|
||||
simple-get@4.0.1:
|
||||
@@ -3728,8 +3938,12 @@ snapshots:
|
||||
|
||||
split2@4.2.0: {}
|
||||
|
||||
stackback@0.0.2: {}
|
||||
|
||||
statuses@2.0.2: {}
|
||||
|
||||
std-env@4.1.0: {}
|
||||
|
||||
steed@1.1.3:
|
||||
dependencies:
|
||||
fastfall: 1.5.1
|
||||
@@ -3769,11 +3983,17 @@ snapshots:
|
||||
dependencies:
|
||||
real-require: 1.0.0
|
||||
|
||||
tinybench@2.9.0: {}
|
||||
|
||||
tinyexec@1.2.4: {}
|
||||
|
||||
tinyglobby@0.2.17:
|
||||
dependencies:
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
picomatch: 4.0.4
|
||||
|
||||
tinyrainbow@3.1.0: {}
|
||||
|
||||
toad-cache@3.7.1: {}
|
||||
|
||||
toidentifier@1.0.1: {}
|
||||
@@ -3838,8 +4058,40 @@ snapshots:
|
||||
jiti: 2.7.0
|
||||
tsx: 4.22.4
|
||||
|
||||
vitest@4.1.9(@types/node@25.9.3)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.9
|
||||
'@vitest/mocker': 4.1.9(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4))
|
||||
'@vitest/pretty-format': 4.1.9
|
||||
'@vitest/runner': 4.1.9
|
||||
'@vitest/snapshot': 4.1.9
|
||||
'@vitest/spy': 4.1.9
|
||||
'@vitest/utils': 4.1.9
|
||||
es-module-lexer: 2.1.0
|
||||
expect-type: 1.3.0
|
||||
magic-string: 0.30.21
|
||||
obug: 2.1.3
|
||||
pathe: 2.0.3
|
||||
picomatch: 4.0.4
|
||||
std-env: 4.1.0
|
||||
tinybench: 2.9.0
|
||||
tinyexec: 1.2.4
|
||||
tinyglobby: 0.2.17
|
||||
tinyrainbow: 3.1.0
|
||||
vite: 8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 25.9.3
|
||||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
||||
void-elements@3.1.0: {}
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
dependencies:
|
||||
siginfo: 2.0.0
|
||||
stackback: 0.0.2
|
||||
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
ws@8.21.0: {}
|
||||
|
||||
@@ -24,7 +24,12 @@ The operator UI outgrew "plain React + useState" once it needed live updates and
|
||||
the latest pushed occupancy. Anything durable is re-fetched via Query.
|
||||
- **Tailwind v4** with a **"Bloomberg-terminal" theme** (`apps/web/src/index.css`, `@theme`):
|
||||
near-black surfaces, amber/green/red/cyan status accents, monospace, dense/keyboard-first. **Radix**
|
||||
primitives (Dialog, etc.) for accessible unstyled components.
|
||||
primitives (Dialog, etc.) for accessible unstyled components. The token VALUES are adopted from the
|
||||
**"TRM" design system** (Claude Design project; race-timing kit) — **tokens only**, no TRM
|
||||
components: the `term-*` accents are aligned onto TRM's `night`/semantic colours and TRM's full
|
||||
vocabulary (night/ink/paper scales, flag/amber/green/blue, the spacing/type/shadow scales) is
|
||||
exposed as utilities for new work. Offline appliance ⇒ no webfont `@import`; Goldplay (TRM's display
|
||||
face) not self-hosted yet — display text falls back to a sans stack.
|
||||
- **react-i18next** for [[i18n]] (Albanian default).
|
||||
|
||||
> This SUPERSEDES the original "plain React, no framework" note on [[react-vite-spa]] — that held
|
||||
|
||||
@@ -35,6 +35,19 @@ This is captured as a device capability: `MonitorableDevice.readStatus(): Printe
|
||||
`isMonitorable()`. A future printer with a different status mechanism just implements the same
|
||||
interface.
|
||||
|
||||
**A clone WITHOUT a trustworthy status mechanism must NOT implement `readStatus`.** The **Cashino**
|
||||
80mm printer prints via the identical ESC/POS stream (shared in `drivers/printer-escpos.ts`) but
|
||||
serves **no** `/prn_stat.htm` page. Running it on the Rongta driver made the monitor scrape a page
|
||||
that isn't there → a bogus `degraded`/page-error verdict even while the printer was fine (the
|
||||
incident that prompted this). Fix: a dedicated `cashino` driver that is **not** `MonitorableDevice`
|
||||
(no `readStatus`), so `isMonitorable()` is false and the monitor falls back to the generic
|
||||
`healthCheck()` — a plain **TCP reachability ping** of the print socket: reachable → `ready`,
|
||||
unreachable → `offline`, and **never** a guessed paper/cover state it cannot sense. This is the
|
||||
correct floor for any ESC/POS printer that can't report consumables: report only what you can
|
||||
actually observe. The shared ESC/POS rendering/transport (`renderTicket`/`renderReport`/
|
||||
`renderSubscriptionCard`/`sendRaw`/`probe`) was extracted to `printer-escpos.ts` so both drivers
|
||||
share the print path and only status differs.
|
||||
|
||||
## Status mapping (fail safe)
|
||||
|
||||
`readStatus()` maps to `ready | degraded | offline`:
|
||||
|
||||
@@ -2,19 +2,21 @@
|
||||
type: concept
|
||||
tags: [parking, domain, business, pricing, design]
|
||||
sources: [parksql2017-legacy-schema]
|
||||
updated: 2026-06-17
|
||||
status: open
|
||||
updated: 2026-06-18
|
||||
status: settled
|
||||
---
|
||||
|
||||
# Tariff Time Tiers — happy hour, off-peak, weekend, seasonal
|
||||
|
||||
Design for **time-of-day / day-of-week / seasonal pricing** on top of the existing [[tariff]] engine.
|
||||
**Time-of-day / day-of-week / seasonal / category pricing** on top of the existing [[tariff]] engine.
|
||||
Resolves the `tariff.md` open question *"Time-of-day / weekday tiers — not in the block model yet."*
|
||||
Driven by two concrete operator asks: a **happy-hour** rate, and (from [[parksql2017-legacy-schema|the
|
||||
legacy schema]]) **vehicle/customer categories**.
|
||||
Driven by the ask to match the legacy [[parksql2017-legacy-schema|ParkSQL2017]] pricing breadth
|
||||
(happy hour, weekend/seasonal windows, vehicle/customer category, flat rate) — but on our
|
||||
integer-minor-unit money + immutable signed-version engine, NOT legacy's float money / mutable rows.
|
||||
|
||||
> Status: **design, not built.** No schema/code committed yet — this records the chosen shape and
|
||||
> the rejected alternatives so implementation is a transcription.
|
||||
> Status: **BUILT 2026-06-18 (V2 tariff).** This page records the as-built shape + the decisions.
|
||||
> The engine is the "V2" arm of `TariffStructure` in `@parking/shared`; a bare V1 structure (no
|
||||
> `defaultCard`) still prices via the unchanged V1 algorithm. See the as-built section at the end.
|
||||
|
||||
## The two real-world models we looked at
|
||||
|
||||
@@ -106,10 +108,56 @@ A bare `defaultCard` (no `windowedCards`) is exactly today's tariff — so this
|
||||
site that never wants tiers never sees them. Keeps the **intuitive-for-operators** goal: the common
|
||||
case stays one rate card; tiers are opt-in.
|
||||
|
||||
## As-built (2026-06-18) — resolved decisions
|
||||
|
||||
- **Shape**: `TariffStructure` is a discriminated union. **V1** = the original bare ladder (unchanged,
|
||||
verbatim algorithm). **V2** = `{ version:2, tz, <shared knobs>, defaultCard, windowedCards[] }`.
|
||||
Discriminant = presence of `defaultCard`. Grace/increment/lostTicket/exit-grace are **top-level
|
||||
(shared)**; the flat-XOR-ladder body + per-card `dailyCapMinor` live on each card.
|
||||
- **Ladder accrual = elapsed-continuous** (decided). Elapsed minutes advance the block-ladder
|
||||
position; wall-clock selects the card per increment. A happy-hour boundary mid-stay does NOT reset
|
||||
the ladder or the daily cap. Implemented by stepping one `incrementMin` at a time and re-selecting
|
||||
the card (boundary slicing is implicit).
|
||||
- **Timezone is FROZEN in the version** (`structure.tz`), sourced from **site config**
|
||||
(`site_config.timezone`, default `Europe/Tirane`) and stamped server-side on publish — NEVER read
|
||||
from the host clock, or historical repricing would drift and break the signed ledger. Tested for
|
||||
DST determinism (`Europe/Tirane` spring-forward/fall-back).
|
||||
- **Daily cap on a mixed day = the DEFAULT card's `dailyCapMinor`** governs the whole rolling-24h
|
||||
segment (decided). Windowed cards lower the rate, never the day ceiling. Predictable + easy to
|
||||
explain.
|
||||
- **Precedence** = specificity tuple **(date > dow > hour-only)**, then integer `priority` (higher
|
||||
wins), then `name` lexicographically as the **final, total, order-independent** tiebreak. Validation
|
||||
*rejects* two cards tied on (category, specificity, priority) with overlapping windows, forcing the
|
||||
operator to disambiguate with `priority`. (Property-tested: shuffling `windowedCards` yields an
|
||||
identical fee.)
|
||||
- **Category = a FIELD on each card** (`card.category`), NOT a tariff scope (reversed the earlier
|
||||
lean). Justification: both pricing call-sites hardcode the single `scope:"site"` tariff; a card-field
|
||||
keeps the whole category→price mapping inside the one immutable `structure` the `payment` event
|
||||
already pins via `tariffVersionId` — fewer frozen moving parts, no `tariffs`-table rework. A card
|
||||
with no `category` applies to all; the `defaultCard` is category-agnostic. The session's category is
|
||||
**frozen in the signed `vehicle_entry` payload** (`payload.category`), so exit reprices identically.
|
||||
Sourced today from `site_config.default_vehicle_category` (operator policy; default
|
||||
`DEFAULT_VEHICLE_CATEGORY` in `@parking/shared`). Per-relay capture (a "bus lane") is the future
|
||||
seam, mirroring per-relay direction.
|
||||
- **Flat rate** is a first-class card body (`flatMinor`, mutually exclusive with `blocks`). A flat V1
|
||||
is published as a single open-ended block (V1 has no flat field).
|
||||
- **UI** (`TariffComposer.tsx`): default card **front-and-centre** (flat/ladder toggle + cap); tiers
|
||||
under a collapsed **"Advanced: time & seasonal tiers"** disclosure (window builder — dow checkboxes,
|
||||
optional date range, optional hour range with an overnight hint; category; priority; flat/ladder
|
||||
body reusing the default editor). `toStructure` emits a **bare V1 when there are no tiers**
|
||||
(back-compat: untouched sites publish exactly today's shape).
|
||||
|
||||
**As-built code**: `computeFee`/`computeFeeV2`/`validateTariffStructure`/`selectCard`/`localBreakdown`
|
||||
in `packages/shared/src/index.ts` (+ `tariff.test.ts`, 36 cases incl. the golden V1 regression);
|
||||
`routes/tariffs.ts` (tz stamping), `routes/site.ts` (tz + default-category fields), `entry-flow.ts`
|
||||
(category frozen at entry), `pay-station.ts` + `exit-flow.ts` (read category, pass to `computeFee`);
|
||||
`schema.ts` + migrations `0005`/`0006` (`site_config.timezone`, `default_vehicle_category`);
|
||||
`TariffComposer.tsx` + `api.ts` + i18n.
|
||||
|
||||
## Open
|
||||
- Elapsed-continuous vs. per-window ladder reset (lean: elapsed-continuous).
|
||||
- Holiday/special-event calendar: a date list per version, or a separate editable calendar table?
|
||||
- Precedence model — confirm most-specific + explicit `priority` tiebreak.
|
||||
- Category axis — confirm "category = tariff scope" vs. window dimension (deferred).
|
||||
- UI: how to author windows without confusing operators (the notoriously-hard part — keep default
|
||||
card front-and-center, tiers as an "advanced" add).
|
||||
- Holiday/special-event calendar: today a date range per card (`dateFrom`/`dateTo`); a reusable named
|
||||
holiday calendar (one date list, referenced by cards) is a future nicety, not built.
|
||||
- Per-relay/lane **category capture** at a transient gate (the "bus lane") — seam noted in
|
||||
`entry-flow.ts`; today every transient takes the site default category.
|
||||
- A composer **price preview** ("at 14:30 Tue a 2h stay costs …") — high-value for operator trust,
|
||||
deferred.
|
||||
|
||||
@@ -217,15 +217,17 @@ Unlike the event log, tariff data is **mutable master data** in the sense that n
|
||||
on the network — [[offline-first]]), a base currency, and a rounding policy. Deferred to
|
||||
[[open-questions]].
|
||||
|
||||
## Extensions under design
|
||||
## Extensions
|
||||
|
||||
Two operator asks extend this engine; both have design pages (not yet built), grounded in
|
||||
[[parksql2017-legacy-schema|the legacy schema]] + external research:
|
||||
Grounded in [[parksql2017-legacy-schema|the legacy schema]] + external research:
|
||||
|
||||
- **Time-of-day / weekday / seasonal tiers** (happy hour, off-peak, weekend, vehicle category) —
|
||||
see [[tariff-time-tiers]]. Chosen shape: **time-windowed rate cards** selected by wall-clock window,
|
||||
layered additively on this structure (a bare default card = today's behaviour). The hard part is
|
||||
slicing a stay at window boundaries while keeping the block ladder + daily cap continuous.
|
||||
- **Time-of-day / weekday / seasonal tiers + vehicle category + flat rate** — **BUILT 2026-06-18**
|
||||
as the **V2 tariff** (the "V2" arm of `TariffStructure`). A `defaultCard` plus optional windowed
|
||||
cards selected by wall-clock window / day-of-week / date / category, each flat or laddered; a stay
|
||||
is sliced at window boundaries while the block ladder + daily cap stay continuous (elapsed-
|
||||
continuous). A bare V1 structure (no `defaultCard`) is unchanged. The wall-clock tz is **frozen in
|
||||
the version** (from site config) for reproducibility. Full as-built decisions in
|
||||
[[tariff-time-tiers]].
|
||||
- **Validation & sponsorship** (merchant comps, coupons, **postpaid B2B** "enter/exit free, bill the
|
||||
business monthly") — see [[validation-sponsorship]]. A validation is a **typed modifier applied as a
|
||||
signed event** on a transient session, distinct from a [[subscription]]; postpaid sponsors accrue a
|
||||
|
||||
+1
-1
@@ -80,7 +80,7 @@ Counts: 4 sources · 19 entities · 42 concepts · 5 decision records.
|
||||
## Concepts — business domain
|
||||
- [[parking-session]] — the core domain entity; a projection over the signed log, never a mutable table.
|
||||
- [[tariff]] — fee model; pure, data-driven, offline; pay-on-foot adds a walk-back grace window.
|
||||
- [[tariff-time-tiers]] — design: happy-hour/off-peak/weekend/seasonal + vehicle categories via time-windowed rate cards.
|
||||
- [[tariff-time-tiers]] — BUILT (V2 tariff): happy-hour/off-peak/weekend/seasonal + vehicle category + flat rate via wall-clock windowed cards; tz frozen per version.
|
||||
- [[booth-exit-flow]] — manned booth: pay → voucher (self-exit later) or immediate exit; active sessions; audited barrier re-open.
|
||||
- [[shift]] — manned-only accountability period; explicit Start/End; End → signed + printed Z-report; drawer float carries across shifts.
|
||||
- [[capacity-occupancy]] — live count = open sessions; refuse entry + FULL sign when full (soft policy); exit never blocked.
|
||||
|
||||
+14
@@ -854,3 +854,17 @@ The version selector picks "latest tariff_version with effectiveFrom ≤ session
|
||||
The owner asked for "first N hours × X, next N hours × Y, …, 24h cap" — which the stepped-block engine ALREADY does (ordered blocks, per-block rate, rolling-24h cap; computeFee tested). So no new axis: the work was making the model complete + footgun-free. Two changes. (1) **Forbid a bounded last block** — `validateTariffStructure` (shared) now rejects a final block with a non-null `uptoMin`, so the "thereafter" rate is always explicit; previously a bounded tail silently inherited its own rate past its bound (a hidden, never-stated price — e.g. the live ALL tariff's 180-min last block billed hour 4+ at the 3rd-hour rate). `rateAt()` still prices legacy bounded-tail versions gracefully and validation is publish-only, so immutable published versions are unaffected (no migration). (2) **Composer edits bands as a DURATION in hours**, not cumulative minutes — `BlockForm` carries `hours`; `toStructure` accumulates into cumulative `uptoMin` minutes; the last row is a pinned, non-removable, hours-less "thereafter (open-ended)" band; `blocksToForm` round-trips stored minutes back to band hours (legacy bounded tails still load). i18n: replaced `upToMin`/`egExample` with `bandDuration`/`hoursUnit`/`egHours` in sq+en (catalog parity green). Verified: validator rejects bounded-last / accepts open-ended; computeFee correct at 1/2/3/5/6/24h for a 0-2h@200,2-5h@100,5h+@50 + 1000 cap card. Full build green (shared/server/web). Updated [[tariff]]. No migration.
|
||||
|
||||
NB considered-and-rejected: time-of-day / weekday wall-clock tiers ("timeframe") were offered but the owner explicitly chose the elapsed-duration ladder only — see [[tariff-time-tiers]] for the deferred wall-clock axis.
|
||||
|
||||
## [2026-06-18] feat | Tariff V2 — legacy-parity pricing (time-of-day, category, seasonal, flat)
|
||||
|
||||
Brought the legacy ParkSQL2017 pricing BREADTH onto our engine (keeping integer-minor-unit money + immutable signed versions; rejecting legacy float money / mutable rows). `TariffStructure` is now a discriminated union: V1 = the original bare ladder (UNCHANGED, verbatim algorithm — golden-regression-tested against the live production 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 (with user): 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 (not tariff scope), 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 (window builder), emits BARE V1 when no tiers (back-compat). DB: migrations 0005 (timezone) + 0006 (default_vehicle_category) — applied to the live DB (backed up). 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. Full monorepo build green; V2 publish verified end-to-end via inject (tz stamped from config not client; malformed V2 rejected). Updated [[tariff-time-tiers]] (status open→settled, as-built), [[tariff]], index. No event-chain change.
|
||||
|
||||
NB incident: the running tsx-watch dev server (server + vite) crashed mid-edit on a half-saved file + a schema column the live DB lacked; recovered by finishing the edits, applying the migration to the live DB, and restarting both watchers. Live DB backup left at apps/server/parking.sqlite.bak-*.
|
||||
|
||||
## [2026-06-18] feat | Booth UI — adopted "TRM" design-system tokens (tokens only)
|
||||
|
||||
The owner linked a Claude Design project (`019ddfee-…`, "TRM — Tracking & Race Management") and asked to implement its designs in `apps/web/`. TRM is a RACE-TIMING design system (dashboard/leaderboard/marketing/mobile kits: RaceControl, HeroClock, LiveTable, BibCard, Ticker) — NOT a parking design; literally porting it would have reskinned the booth UI with race components. Flagged the mismatch; owner chose **tokens only**. So: brought TRM's token VOCABULARY into the Tailwind v4 `@theme` (`apps/web/src/index.css`) and ALIGNED the existing `term-*` accents onto TRM's exact values — surfaces → TRM `night` scale (#0b0d10/#14171c/#1e222a/#2a2f38), amber #f5a623→#f2a516, green #2ecc71→#2e8c4a, red #ff4d4f→#e8412b (flag), cyan #38bdf8→#2563c8 (blue). No component touched (170+ `term-*` references resolve unchanged). Also exposed TRM's full vocabulary as utilities for new work: night/ink/paper scales, flag/amber/green/blue + tints, viz-1..8, the 4px spacing scale (s0..s13), type scale (overline..jumbo), square radii, sharp "printed" offset shadows, control/table-row heights. Offline-appliance constraint ⇒ deliberately did NOT keep TRM's Google-Fonts `@import` (no runtime network); Goldplay (TRM display face) left un-self-hosted — display/heading falls back to a sans stack (mono is the booth's primary face anyway), noted for later wiring. Verified: web build green; login renders on the new palette (amber focus ring = #f2a516). Updated [[booth-console]]. No logic/schema/event-chain change.
|
||||
|
||||
## [2026-06-18] fix | Cashino printer — ping-only driver (no false status) + Albanian device-role wording
|
||||
|
||||
Two device-feedback issues. (1) **Cashino 80mm printer reported wrong status.** It was configured on the `rongta` driver, whose `readStatus()` scrapes the Rongta board's `/prn_stat.htm` status page — which the Cashino does NOT serve. Result: a bogus `degraded`/page-error verdict while the printer was actually online (it printed fine; `healthCheck` TCP-ping passed). Root cause: the Cashino is an ESC/POS PRINT clone but has no trustworthy STATUS mechanism. Fix: extracted the shared ESC/POS rendering + transport (renderTicket/renderReport/renderSubscriptionCard/sendRaw/probe + CP852 map + code128/qrCode) from `printer-rongta.ts` into a new `drivers/printer-escpos.ts`; added a dedicated `cashino` driver that reuses that print path but is deliberately **NOT** `MonitorableDevice` (no readStatus). So `isMonitorable()` is false and the device monitor falls back to the generic `healthCheck()` — a plain TCP reachability ping of the print socket: reachable→ready, unreachable→offline, never a guessed paper/cover state. Rongta driver unchanged (still scrapes its page, still monitorable). Registered `cashinoDriver`; re-exported from the package. Switched the live entry-dispenser printer at **10.0.10.9** from `rongta`→`cashino` in the DB (backed up incl. WAL: apps/server/parking.sqlite*.bak-cashino-*); 10.0.10.10 (booth Rongta) left as-is. Verified at runtime: cashino registered, isMonitorable=false, no readStatus, healthCheck→offline on unreachable; live /api/devices/status → both printers `ready` (lane via ping, booth via page). (2) **Albanian device-role chip wording was wrong.** The footer label is `"{category} {role}"`; the role suffixes read badly: access `mixed`="i përzier" gave `Barriera i përzier` ("Barrier mixed" — wrong word + wrong gender; `mixed` actually means a barrier spanning >1 direction) → now `hyrje/dalje` (entry/exit). printer `lane`="korsia" gave `Printer korsia` ("Printer the-lane") → now `në korsi` (at the lane); `booth`="kabina" (`Printer kabina`) → `në kabinë` (at the booth). English tidied to match: mixed→"entry/exit", lane→"at lane", booth→"at booth". i18n catalog parity green. Updated [[printer-status-monitoring]]. No schema/event-chain change.
|
||||
|
||||
Reference in New Issue
Block a user