27 Commits

Author SHA1 Message Date
julian d71ba82999 feat(booth): payment receipt / exit voucher — transparency slip + CP852 fixes
After a completed payment the customer always gets a transparency record:
entry time, payment time, duration parked, amount + tender. One shared
ESC/POS renderer (renderReceipt + ReceiptData in @parking/devices), two
modes: VOUCHER = those figures PLUS the scannable Code128 barcode and an
emphasised walk-back-grace line, so the one slip both proves payment and
self-exits at a distant exit reader (replaced the old barcode-only voucher);
STANDALONE = detail-only, auto-printed at payment when no voucher is issued.
Figures fold from the SIGNED ledger (latest payment event); printed on the
booth printer (failover to dispenser). Best-effort: a printer fault never
blocks the exit that already happened — the modal shows a note and offers
"Reprint receipt".

Server: booth-print.ts printPaymentReceipt() + receiptFigures(); routes
POST /api/voucher (voucher) + new POST /api/receipt (standalone/reprint).
Both ESC/POS drivers gained printReceipt(). Web: BoothPayModal auto-prints
after a non-voucher payment + reprint button; api.ts printReceipt().

CP852 fixes found on a real printout: (1) uppercase Ë was mapped to 0xEB
(that's ű) — correct byte is 0xD3; (2) Intl.NumberFormat injects a NO-BREAK
SPACE (U+00A0/U+202F) that isn't in CP852 and printed as "?" — line() now
normalises it to a plain space ("1000 Lekë"); (3) grace line wrapped
mid-word — split into two short lines.

Full build green; both receipt modes render-verified; routes live.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 20:46:38 +02:00
julian 9c9f777784 docs(wiki): record the two configured printers (Cashino at lane, Rongta at booth)
The rongta-printer "Deployment" section still described a single
2026-06-14 unit. The live site now runs two: entry-dispenser 10.0.10.9
(Cashino, `cashino` ping-only driver) and booth-receipt 10.0.10.10
(Rongta, full status-page monitoring). Follow-up to 3e6773a.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 20:05:40 +02:00
julian 486f8deae6 fix(shift): update Z-report title to Albanian translation 2026-06-18 20:04:13 +02:00
julian 3e6773a6d5 fix(devices): Cashino printer — ping-only driver (no false status) + Albanian role wording
The Cashino 80mm printer reported wrong status: it ran on the `rongta`
driver, whose readStatus() scrapes the Rongta board's /prn_stat.htm status
page — which the Cashino does not serve — yielding a bogus degraded/page-
error verdict while the printer was online and printing fine. Root cause:
the Cashino is an ESC/POS PRINT clone with no trustworthy STATUS mechanism.

Fix: extract the shared ESC/POS rendering + transport (renderTicket/
renderReport/renderSubscriptionCard/sendRaw/probe + CP852 map + code128/
qrCode) from printer-rongta into drivers/printer-escpos.ts, and add a
dedicated `cashino` driver that reuses that print path but is deliberately
NOT MonitorableDevice (no readStatus). isMonitorable() is then false, so the
device monitor falls back to healthCheck() — a plain TCP reachability ping:
reachable -> ready, unreachable -> offline, never a guessed paper/cover
state it cannot sense. Rongta driver unchanged (still scrapes its page,
still monitorable). Register + re-export cashinoDriver.

Verified at runtime (cashino registered, isMonitorable=false, no readStatus,
healthCheck->offline on unreachable) and live: /api/devices/status shows both
printers ready (lane via ping, booth via page). The live entry-dispenser at
10.0.10.9 was switched rongta->cashino in the operator DB (backed up).

Also fix the Albanian device-role chip wording, which read wrong as a
"{category} {role}" label: access mixed "i përzier" -> "hyrje/dalje"
(it means a barrier spanning both directions); printer lane "korsia" ->
"në korsi"; booth "kabina" -> "në kabinë". English tidied to match
(mixed->entry/exit, lane->at lane, booth->at booth).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 20:00:42 +02:00
julian cf1ff5676d feat(tariff): V2 — legacy-parity pricing (time-of-day, category, seasonal, flat)
Bring the legacy ParkSQL2017 pricing BREADTH onto our engine while keeping
integer-minor-unit money + immutable signed versions (rejecting legacy's
float money / mutable rows). TariffStructure becomes a discriminated union:
V1 = the original bare ladder (UNCHANGED, verbatim algorithm, golden-
regression-tested against the live version); V2 = {version:2, tz, shared
knobs, defaultCard, windowedCards[]} where each card is flat OR a block
ladder and may be scoped by wall-clock hour window / day-of-week / date
range / vehicle category.

computeFeeV2 prices by stepping one increment at a time, advancing the
ladder by ELAPSED minutes (continuous) while selecting the active card by
WALL-CLOCK time in the version's FROZEN tz. Decisions: tz is a per-site
setting (site_config.timezone, default Europe/Tirane) stamped server-side
into each version on publish — never the host clock (reproducibility);
default-card cap governs a mixed day; precedence = specificity
(date>dow>hour) -> priority -> name (total, order-independent), validation
rejects ambiguous ties; category = a card FIELD, frozen in the signed
vehicle_entry payload (site_config.default_vehicle_category default), read
at both pricing call-sites.

Composer: default card front-and-centre (flat/ladder toggle), tiers under
an "Advanced" disclosure; emits BARE V1 when no tiers (back-compat). DB:
migrations 0005 (timezone) + 0006 (default_vehicle_category). Stood up
vitest in @parking/shared (was zero tests on the ledger-feeding fee fn);
36 tests incl. golden V1 regression, happy-hour/overnight/dow/flat/category/
cap edges, precedence shuffle-invariance, Europe/Tirane DST determinism,
validation matrix — all green. No event-chain change.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 20:00:13 +02:00
julian 91cc79b14e feat(web): adopt TRM design-system tokens (tokens only)
Linked Claude Design project "TRM — Tracking & Race Management" is a
race-timing kit, not a parking design. Adopted its TOKENS only — no TRM
components. Aligned the existing term-* accents onto TRM's exact night/
semantic values (surfaces → night scale; amber→#f2a516, green→#2e8c4a,
red→#e8412b flag, cyan→#2563c8 blue) so the whole booth UI shifts palette
with zero component edits. Exposed TRM's full vocabulary (night/ink/paper
scales, flag/amber/green/blue, viz-1..8, 4px spacing, type scale, square
radii, sharp offset shadows) as Tailwind v4 utilities for new work.

Offline appliance: dropped TRM's Google-Fonts @import (no runtime network);
Goldplay display face not self-hosted yet — falls back to a sans stack.

Web build green; login renders on the new palette.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 19:46:56 +02:00
julian dfa76346d6 feat(tariff): complete the progressive ladder — require open-ended last block, hours-based composer
The stepped-block engine already does "first N hrs x X, next N hrs x Y, ...,
24h cap" (ordered blocks, per-block rate, rolling-24h cap). No new axis; this
completes the model and removes its footgun.

- validateTariffStructure (shared) now REQUIRES the last block to be open-ended
  (uptoMin: null). A bounded final block silently inherited its own rate past
  its bound (a hidden, never-stated price — e.g. the live ALL tariff billed
  hour 4+ at the 3rd-hour rate). rateAt() still prices legacy bounded-tail
  versions; validation is publish-only, so published immutable versions are
  unaffected (no migration).
- TariffComposer edits bands as a DURATION in hours ("first 2 hours, then next
  3 hours"), accumulated into the engine's cumulative uptoMin (minutes) on
  submit. The last row is a pinned, non-removable "thereafter (open-ended)"
  band, so a published card always satisfies the open-ended-last rule.
  blocksToForm round-trips stored minutes back to band hours (legacy loads).
- i18n: replaced upToMin/egExample with bandDuration/hoursUnit/egHours (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. Wiki (tariff.md, log.md) updated.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 16:58:47 +02:00
julian c9a2ef81a9 fix(tariff): forbid backdated effectiveFrom — versioning was retroactive
Version selection is "latest tariff_version with effectiveFrom <= entry time",
but the publish handler accepted ANY effectiveFrom (defaulting to now). So an
admin could publish a version with a backdated effectiveFrom and silently
reprice sessions that had already entered — the retroactive rewrite the
versioning exists to prevent. Pricing itself was sound (quote resolves by entry
time; payment records tariffVersionId, freezing completed sessions); the leak
was the publish side only.

Reject effectiveFrom earlier than now (60s skew tolerance); future-dated
(scheduling a price change) stays allowed; bad ISO -> 400. Combined with
entry-time selection this is structural: once a car has entered, no later
publish can reprice it. Did not pin tariffVersionId onto vehicle_entry (not
needed). Verified 5/5 via inject against a copy of the live DB.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 16:58:36 +02:00
julian b8ddda86e7 feat(subscription): RFID enrollment, any-credential exit, prepaid booth handling
Rounds out subscriptions across enrollment, the barrier flow, and the booth.

- RFID credentials enabled with a "Read card" enrollment flow: the operator
  arms ONE chosen reader (CredentialCapture, single-shot + ~30s TTL); that
  reader's next read is captured into the form and NOT dispatched to the access
  flow — the OTHER reader keeps serving live entry/exit. Routes:
  /api/subscriptions/readers + /capture/{arm,cancel} + poll.
- Enter with one credential, exit with another: sessions are keyed by a
  per-occurrence id (SUBSESS-<short>), not the credential value, with
  permitId in the payload. Direction is decided by the barrier the reader sits
  at (entry-lane→entry, exit-lane→exit; "both" infers); a fleet (maxConcurrent>1)
  admits several cars and exits any with any credential, FIFO (oldest first).
- Booth treats a subscription occurrence as PREPAID: never quoted/charged; the
  pay/exit modal shows a subscription mode (snapshots + a single audited
  Open-barrier action) to assist a faulty exit reader / missing card;
  reopenBarrier authorizes paidAt!=null OR subscription. Active Sessions badges
  "abonim" and labels by holder name (not the raw key).
- Plus a per-read diagnostic log in the QR-reader route (serial → device →
  verdict/dir), which surfaced the earlier duplicate-reader-IP misroute.

Verified via buildServer+inject + reader-scan/TCP-capture simulations
(enrollment isolation, cross-credential + FIFO fleet, prepaid-not-charged,
subscription reopen, unpaid-transient guard). Updated wiki (subscription,
booth-exit-flow). No migration.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 16:26:48 +02:00
julian bba988c4e8 feat(subscription): QR credentials — operator-choose (QR-only now), auto-generate, multi-month, printed card
Builds out subscription credentials on top of the rename.

- Operator chooses the credential type; only QR is live (RFID shown disabled
  "soon"). Backend/schema keep accepting both — re-enabling RFID is UI-only.
- QR codes are AUTO-GENERATED server-side (SUB-<base32>, crypto-random,
  globally-unique-checked) — the customer/operator never picks the value.
  RF stays operator-entered (the physical card id). Reader output decided =
  TCP/IP full string (Wiegand-numeric fallback noted).
- Multi-month: form takes a `months` count → server sets validTo =
  validFrom + N months (day-clamp); one record/one window; total = N×monthly.
- The QR card is PRINTED so the operator can hand it over: real ESC/POS 2D QR
  (GS ( k) added to the Rongta driver (printSubscriptionCard); auto-print on
  create (best-effort — never fails the create; returns {printed,printError})
  + reprint via POST /api/subscriptions/:id/print and a "Print code" button.

Verified via buildServer+inject incl. a TCP capture of the on-wire QR bytes
(autogen+uniqueness, Jan31+3mo→Apr30, auto-print, GS ( k QR with embedded
code, reprint, no-QR→409). Updated wiki (subscription, rongta-printer). No
migration.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 14:48:38 +02:00
julian 5697137c52 feat(subscription): rename permit→subscription + monthly pricing
The "permit/lejet" feature is really a subscription. Full rename of the
mutable master data, plus a recurring monthly price.

- DB (migration 0004, data-preserving ALTER RENAME): permits→subscriptions,
  permit_credentials/_plates→subscription_*, sessions.permit_id→subscription_id.
- Pricing: per-subscription priceMinor + period(monthly) + currency, with a
  site default (site_config.subscription_monthly_price_minor) pre-filling the form.
- Server: subscription-flow.ts (SubscriptionFlow), routes/subscriptions.ts
  (/api/subscriptions). Web: SubscriptionManager, route, i18n (sq Abonimet/en).
- The signed ledger `permitId` payload is intentionally kept — immutable
  hash-chained history; renaming it would break verification of past events.

Deferred (wiki notes): fee collection into the ledger/shift (a shift-attributed
payment), LPR/ANPR plate source, time-of-day access windows (overnight subscriber).

Also carries the device-footer UI surface (api DeviceStatus, router mount,
i18n devices) due to shared-file overlap with the preceding footer commit.

Verified end-to-end on a fresh DB and migration on a live-DB copy (sessions
preserved). Live DB migrated. Full monorepo builds clean.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 13:15:04 +02:00
julian ca8c7f2fa2 fix(exit): stuck active session — paid ticket with no vehicle_exit
A paid car that left via a manual barrier re-open kept no vehicle_exit, so
activeSessions() saw it as permanently open and the grace-expiry eviction
(which only ran for exited sessions) never fired — it lingered forever
(ticket T-397815c0).

- reopenBarrier() now signs a vehicle_exit (source:manual) when the session
  is still open, closing it; still no second exit when already exited
  (phantom re-close — no double-count).
- activeSessions() ages out a PAID open session past grace even with no exit
  (unpaid open sessions never age out — a car owing money stays). Pure
  display filter; the signed log is untouched.

Verified both fixes + chain integrity on a fresh DB. A one-off corrective
vehicle_exit was appended to the live ledger to clear T-397815c0.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 13:14:45 +02:00
julian f87e4c0d6b feat(devices): live device-status footer across all categories
Generalise printer-only monitoring to every configured device. New
DeviceMonitor polls all enabled devices each tick (default 8s): printers
via rich readStatus(), relays/readers/cameras via the generic healthCheck()
reachability probe, flattened to one traffic-light (ready/degraded/offline)
+ detail, deduped (emit on change only), fail-toward-offline.

- device-status bus event + GET /api/devices/status snapshot.
- Pushed over the existing /api/ws (hello carries the initial set;
  device-status frame per change).
- Web: live-store devices map, WS handler, DeviceFooter chip-per-device
  (role label not vendor; click a degraded/offline chip for an issues panel).

Verified roleKind resolution + change-only emit on a fresh DB.

Note: the footer's UI surface (api type, router mount, i18n devices) rides
in the subsequent subscription commit due to shared-file overlap.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 13:14:36 +02:00
julian 4e2e4feedb feat(shift): site-wide single-open shift + booth money-path gate
A shift becomes a SITE-WIDE accountability period — at most one open at a
time — so every taking is unambiguously attributed to one operator. Login
stays decoupled from shifts (an operator can log in off-shift to review).

Backend:
- ShiftService.currentOpenShift()/requireOpenShift(); open() refuses when ANY
  shift is open and throws ShiftAlreadyOpenError{heldBy} (self vs. other).
- requireShift preHandler gates /api/pay, /api/exit, /api/voucher,
  /api/barrier/reopen → 409 {code:"no_shift"}; read-only lookups stay open.
- GET /api/shift/current returns site-wide {open:{startedAt,operator},isMine}.
- GET /api/events?since=<iso> for per-shift log scoping (db: re-export gte).

Frontend:
- Header shift button: open / close-mine / disabled-when-another-holds-it.
- Pay/exit modal gate banner (one-click open; "held by X" when another's);
  pay/exit/voucher disabled until this operator's shift is open.
- Active-Sessions barrier re-open gated the same way.
- Live feed scoped to the open shift's window; shared useShift() Query
  invalidated over the WS on shift_open/shift_z_report/cash_movement.
- sq/en strings for the control + gate.

Wiki: shift.md (site-wide single-open + gate; superseded per-operator note),
booth-console.md (header control + gate), log entry.

Verified: site-wide invariant + heldBy + handover + chain integrity on a
fresh migrated DB (11/11); db/server/web build clean.
2026-06-18 12:13:17 +02:00
julian 48660d3ec8 docs(wiki): reconcile with session — booth console, i18n, live WS
File concept pages for the operator-UI architecture ([[booth-console]]: stack,
/api/ws live feed, anti-CSWSH) and [[i18n]] (per-user server-stored language;
resolves a dangling code-comment link). Qualify the stale 'plain React' note on
react-vite-spa. Backfill log entries for the live WebSocket, frontend foundation,
and i18n builds (which had none), plus a reconciliation lint entry. Catalog
booth-exit-flow + the two new pages in index; fix the concept count (27→41).
2026-06-18 11:50:58 +02:00
julian 14c83e182a feat(web): i18n with react-i18next — Albanian default, English second
Add react-i18next with two key-parity-checked catalogs (sq default/fallback, en).
Active language driven by the logged-in user's stored preference (applied after
/me resolves); SQ/EN toggle in the header persists via PUT /api/auth/language.
Translate the booth (screen, pay/exit modal, active sessions, snapshots, status),
Login, ShiftControl, SiteSettings, PermitManager, TariffComposer.

SetupWizard deferred (its content is server-provided; needs backend catalog i18n).
2026-06-18 11:47:39 +02:00
julian 445bca0bf6 feat(auth): per-user UI language preference (sq default, en)
Add users.language ('sq'|'en', default 'sq'; migration 0003). Returned from
/api/auth/login and /api/auth/me (read from the DB, not the JWT — so changing it
needs no re-login). New PUT /api/auth/language for self-service. Loaded on login
and restored from any booth. Printed tickets stay Albanian (customer-facing).
2026-06-18 11:47:30 +02:00
julian 062feeae2f docs(wiki): update index + log for booth console, drawer, and tariff research
Catalog the new concept/source pages and append chronological log entries for the
tariff research, live WebSocket, booth pay/exit, active sessions, and shift drawer
work.
2026-06-18 11:05:43 +02:00
julian 50a3095ef3 feat(shift): cash drawer balance carried across shifts + admin cash movements
New signed cash_movement event (admin-only): load/remove drawer float, signed +
attributed. ShiftService folds cash payments + movements by time into a drawer
balance; shift open auto-inherits the prior shift's expected closing drawer as its
opening float; the Z-report reports opening/taken/added/removed/expected (= next
shift's opening float). Card payments excluded (settle to bank). Routes: POST
/api/cash-movement, drawer in GET /api/shift/current. ShiftControl shows the live
drawer + admin load/remove form + Z-report drawer block. Wiki: shift.md.
2026-06-18 11:05:36 +02:00
julian eb3dc18e67 feat(booth): active sessions panel + audited barrier re-open
Active Sessions panel lists sessions that are open OR exited-but-within-grace
(barrier state is unconfirmed, so a paid car is presumed possibly-present until
grace expires). Row click → pay/exit modal; 'Open barrier' (paid sessions only —
no payment, no button) fires a human-intervention re-pulse signed as an attributed
anomaly, never a second vehicle_exit. Wiki: booth-exit-flow.md.

Note: the backend (PayStation.activeSessions, ExitFlow.reopenBarrier, routes,
api.ts) landed with the prior commit's shared files.
2026-06-18 11:05:26 +02:00
julian 06dab1e790 feat(booth): pay-on-foot at the booth — ticket lookup, pay, exit, voucher, snapshots
Backend: PayStation.lookup (session view + quote in one read); ExitFlow.exitForBooth
reuses the reader path's paid+grace validation (no booth-only unpaid bypass) and
signs vehicle_exit + pulses an exit relay; printExitVoucher reprints the paid ticket
id barcode; site_config.exit_voucher_default (migration 0002) drives the default.
Routes: GET /api/session/:id, POST /api/exit, POST /api/voucher.

Web: BoothPayModal (entry/now/duration/total, tender, 'Printo biletë dalje'),
SnapshotStrip (entry/exit evidence), api.ts client fns, SiteSettings toggle.
2026-06-18 11:05:10 +02:00
julian 9956488fd5 chore: removed graphify 2026-06-18 11:03:45 +02:00
julian 49df2015c8 feat(web): frontend foundation — Tailwind terminal theme, Query, Router, Zustand + live booth screen
Add tailwindcss (Bloomberg-terminal theme in index.css), @tanstack/react-query +
react-router, zustand, and Radix primitives. Router with role-guarded routes;
QueryClient wrapping the existing apiFetch; a small Zustand live store fed by a
/api/ws client that invalidates Query caches. Booth screen: live occupancy gauge
+ streaming entry/exit/payment feed. Vite proxies the WS upgrade.

Note: BoothScreen references the pay/exit modal + active-sessions panel added in
following commits; final HEAD builds.
2026-06-18 11:00:42 +02:00
julian c2f06a5d2a feat(server): live booth WebSocket feed (/api/ws)
Add @fastify/websocket. EventLog fires an onAppended callback after each durable
append; device-events gains a ledger channel (emitLedger). /api/ws fans out
ledger + occupancy + printer-status to authenticated booth clients. Origin
allowlist (WS_ALLOWED_ORIGINS) replaces CSRF for the handshake (anti-CSWSH).

Note: server.ts also reflects later booth route wiring; the final HEAD builds.
2026-06-18 11:00:22 +02:00
julian 58d8f06ba0 docs(wiki): tariff research — legacy ParkSQL2017 schema, time-tiers & validation/sponsorship design
Ingest the predecessor SQL Server schema (raw + source summary) and file design
pages for time-of-day/seasonal tariff tiers and merchant validation/postpaid
sponsorship. Cross-link tariff.md and validation-discounts.md. No code.
2026-06-18 10:59:21 +02:00
julian 71aaad03b9 exit: open free within entry-grace, no pay-station visit
A quick in-and-out the tariff prices at 0 (stay <= gracePeriodEntryMin) now
exits at the gate instead of being refused as "not paid". exit-flow resolves
the active site tariff (same logic as the pay station) and, if computeFee for
entry->now is 0, mints a signed $0 payment event (reason: free entry-grace)
then signs the vehicle_exit and opens. The $0 payment keeps the append-only
ledger invariant that an exit is covered by a payment, so a grace exit stays
attributable in the audit trail. A real payment still takes precedence (the
walk-back grace path is untouched). Sign+open extracted to #signExitAndOpen,
shared by both paths.
2026-06-17 12:17:28 +02:00
julian 727c62da90 ticket: site metadata header + scannable Albanian ticket; widen barcode
- site_config gains optional park identity (park_name, operator_name, nius,
  address, phone, email); additive Drizzle migration 0001. GET/PUT
  /api/site-config read/write the full config (PUT partial patch, admin only);
  SiteSettings + SetupWizard expose the fields.
- renderTicket() prints an Albanian header sourced from site_config, the
  all-numeric 13-digit ticket id (12 random + Luhn) as Code128, large digits,
  and a lost-ticket footer. CP852 codepage so ë/ç render.
- Widen the Code128 module width 2->3 and height 80->100 dots so the
  short-range "Simple" QR/barcode reader decodes reliably (was barely reading
  at module width 2 on the 80mm head).

See wiki/concepts/site-metadata.md and ticket-encoding.md.
2026-06-17 12:17:21 +02:00
111 changed files with 15053 additions and 1372 deletions
+1 -20
View File
@@ -1,24 +1,5 @@
{ {
"hooks": { "hooks": {
"PreToolUse": [ "PreToolUse": []
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "CMD=$(python3 -c \"import json,sys; d=json.load(sys.stdin); print(d.get('tool_input',d).get('command',''))\" 2>/dev/null || true); case \"$CMD\" in *grep*|*rg\\ *|*ripgrep*|*find\\ *|*fd\\ *|*ack\\ *|*ag\\ *) [ -f graphify-out/graph.json ] && echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"additionalContext\":\"MANDATORY: graphify-out/graph.json exists. You MUST run `graphify query \\\"<question>\\\"` before grepping raw files. Only grep after graphify has oriented you, or to modify/debug specific lines.\"}}' || true ;; esac"
}
]
},
{
"matcher": "Read|Glob",
"hooks": [
{
"type": "command",
"command": "HIT=$(python3 -c \"import json,sys;d=json.load(sys.stdin);t=d.get('tool_input',d);s=(str(t.get('file_path') or '')+' '+str(t.get('pattern') or '')+' '+str(t.get('path') or '')).lower().replace(chr(92),'/');exts=('.py','.js','.ts','.tsx','.jsx','.go','.rs','.java','.rb','.c','.h','.cpp','.hpp','.cc','.cs','.kt','.swift','.php','.scala','.lua','.sh','.md','.rst','.txt','.mdx');sys.stdout.write('1' if 'graphify-out/' not in s and any(e in s for e in exts) else '')\" 2>/dev/null || true); if [ \"$HIT\" = 1 ] && [ -f graphify-out/graph.json ]; then echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"additionalContext\":\"MANDATORY: graphify-out/graph.json exists. You MUST run graphify before reading source files. Use: `graphify query \\\"<question>\\\"` (scoped subgraph), `graphify explain \\\"<concept>\\\"`, or `graphify path \\\"<A>\\\" \\\"<B>\\\"`. Only read raw files after graphify has oriented you, or to modify/debug specific lines. This rule applies to subagents too \u2014 include it in every subagent prompt involving code exploration.\"}}'; fi || true"
}
]
}
]
} }
} }
+1
View File
@@ -24,3 +24,4 @@ dist/
# Graphify knowledge-graph output (dev tool; generated, not committed) # Graphify knowledge-graph output (dev tool; generated, not committed)
graphify-out/ graphify-out/
parking.sqlite*.bak-*
-10
View File
@@ -86,13 +86,3 @@ For the full reasoning behind each, follow the links from `wiki/overview.md`.
- TypeScript throughout. Match the style of surrounding code. - TypeScript throughout. Match the style of surrounding code.
- Confirm before destructive or outward-facing actions. Commit/push only when asked. - Confirm before destructive or outward-facing actions. Commit/push only when asked.
## graphify
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
Rules:
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
+11
View File
@@ -8,6 +8,13 @@
# Generate one with: openssl rand -hex 32 # Generate one with: openssl rand -hex 32
JWT_SECRET= JWT_SECRET=
# Dedicated HMAC key for signing the append-only event ledger (>=16 chars).
# Generate with: openssl rand -hex 32
# If unset, the server falls back to JWT_SECRET (logged as a warning) — fine for
# dev, but set a dedicated key before production. Events store the key that signed
# them (keyId), so verifyChain still validates a chain that spans a key change.
EVENT_SIGNING_KEY=
# Optional ---------------------------------------------------------------- # Optional ----------------------------------------------------------------
# PORT=3000 # PORT=3000
# HOST=0.0.0.0 # interface to bind. 127.0.0.1 = loopback only. # HOST=0.0.0.0 # interface to bind. 127.0.0.1 = loopback only.
@@ -18,3 +25,7 @@ JWT_SECRET=
# First admin (seed once): pnpm --filter @parking/server seed-admin # First admin (seed once): pnpm --filter @parking/server seed-admin
# ADMIN_USER=admin # ADMIN_USER=admin
# ADMIN_PASS= # ADMIN_PASS=
# Comma-separated extra origins allowed to open the booth WebSocket (/api/ws).
# In dev, set the Vite SPA origin. Same-origin is always allowed without this.
WS_ALLOWED_ORIGINS=http://localhost:5173
+1
View File
@@ -16,6 +16,7 @@
"@fastify/cors": "11.2.0", "@fastify/cors": "11.2.0",
"@fastify/jwt": "10.1.0", "@fastify/jwt": "10.1.0",
"@fastify/static": "9.1.3", "@fastify/static": "9.1.3",
"@fastify/websocket": "^11.2.0",
"@parking/db": "workspace:*", "@parking/db": "workspace:*",
"@parking/devices": "workspace:*", "@parking/devices": "workspace:*",
"@parking/shared": "workspace:*", "@parking/shared": "workspace:*",
+156
View File
@@ -0,0 +1,156 @@
import { eq, ledgerEvents, siteConfig, type Db } from "@parking/db";
import {
printWithFailover,
registry,
type PrinterDevice,
type PrinterInstance,
type ReceiptData,
type TicketHeader,
} from "@parking/devices";
import type { FastifyBaseLogger } from "fastify";
import { devicesByDirection } from "./device-resolve.js";
// Booth-side printing for the EXIT VOUCHER ("biletë dalje"). When the booth is far
// from the exit, the customer pays at the booth and walks a printed voucher to the
// exit, where they self-scan it. The voucher reprints the SAME ticket id as a
// Code128 barcode (now a paid session) — so the exit reader runs the normal exit
// validation and opens. See wiki/concepts/booth-exit-flow.md, ticket-encoding.md.
//
// This mirrors the entry flow's printer selection + header build, but prints on the
// BOOTH printer (role "booth-receipt") since that's where the operator stands.
/** Park identity for the voucher header, from site_config (all fields optional). */
function ticketHeader(db: Db): TicketHeader | undefined {
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
if (!row) return undefined;
return {
parkName: row.parkName,
operatorName: row.operatorName,
nius: row.nius,
address: row.address,
phone: row.phone,
};
}
/** Build live printer instances for failover selection (entry direction covers the
* booth-receipt role too — the booth printer is configured on the entry side). */
function loadPrinters(db: Db): PrinterInstance[] {
const rows = devicesByDirection(db, "printer", "entry");
const out: PrinterInstance[] = [];
for (const row of rows) {
const driver = registry.get(row.driverId);
if (!driver) continue;
const cfg = row.config as Record<string, unknown>;
const role = cfg.role === "booth-receipt" ? "booth-receipt" : "entry-dispenser";
try {
out.push({
id: row.id,
role,
failoverRank: typeof cfg.failoverRank === "number" ? cfg.failoverRank : 0,
device: driver.create(cfg as never) as PrinterDevice,
});
} catch {
// skip a printer whose config won't build
}
}
return out;
}
/** The receipt figures for a paid session, folded from the SIGNED ledger
* (authoritative). Null if there's no entry or no payment for this id — the
* caller should have validated paid + open before printing. */
function receiptFigures(
db: Db,
ticketId: string,
): Omit<ReceiptData, "voucher" | "header"> | null {
const rows = db
.select()
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, ticketId))
.orderBy(ledgerEvents.index)
.all();
const entry = rows.find((r) => r.type === "vehicle_entry");
if (!entry) return null;
// The LATEST payment is the one we receipt (an overstay top-up re-pays).
let payment: (typeof rows)[number] | undefined;
for (const r of rows) if (r.type === "payment") payment = r;
if (!payment) return null;
const p = (payment.payload ?? {}) as {
amountMinor?: number;
currency?: string;
tender?: "cash" | "card";
graceExitMin?: number;
};
return {
ticketId,
enteredAt: entry.occurredAt,
paidAt: payment.occurredAt,
amountMinor: typeof p.amountMinor === "number" ? p.amountMinor : 0,
currency: p.currency ?? "ALL",
tender: p.tender === "card" ? "card" : "cash",
graceExitMin: typeof p.graceExitMin === "number" ? p.graceExitMin : null,
};
}
/**
* Print a PAYMENT RECEIPT for a paid session on the booth printer (failing over
* to the entry dispenser). The receipt is the customer's transparency record:
* entry time, payment time, duration, amount + tender — folded from the signed
* ledger. In VOUCHER mode it also carries the scannable ticket-id barcode + the
* walk-back grace, so the one slip both proves payment AND self-exits at a
* distant exit reader (this replaces the old barcode-only voucher). In standalone
* mode (`voucher:false`) it is detail-only, printed at payment when the booth is
* at the exit. Returns the id of the printer that printed it.
* Throws NoPrinterAvailableError if none can; throws if the session isn't payable.
*/
export async function printPaymentReceipt(
db: Db,
ticketId: string,
opts: { voucher: boolean },
logger: FastifyBaseLogger,
): Promise<string> {
const figures = receiptFigures(db, ticketId);
if (!figures) {
throw new Error(`no paid session to receipt for ${ticketId}`);
}
const printers = loadPrinters(db);
const data: ReceiptData = {
...figures,
voucher: opts.voucher,
header: ticketHeader(db),
};
// Prefer the booth printer (operator is at the booth); fall back to the dispenser.
const printedBy = await printWithFailover(printers, "booth-receipt", (d: PrinterDevice) =>
d.printReceipt(data),
);
logger.info(
`${opts.voucher ? "exit voucher" : "payment receipt"} for ${ticketId} printed on ${printedBy}`,
);
return printedBy;
}
/**
* Print a SUBSCRIPTION CARD on the booth printer (failing over to the dispenser):
* a scannable QR of the credential code + holder/validity, so the operator can hand
* it to the customer. Used on subscription creation and on a "reprint" action.
* Returns the printer that printed it; throws NoPrinterAvailableError if none can.
*/
export async function printSubscriptionCard(
db: Db,
card: { code: string; holderName?: string | null; validFrom?: string | null; validTo?: string | null },
logger: FastifyBaseLogger,
): Promise<string> {
const printers = loadPrinters(db);
const data = {
code: card.code,
holderName: card.holderName ?? null,
validFrom: card.validFrom ?? null,
validTo: card.validTo ?? null,
header: ticketHeader(db),
};
const printedBy = await printWithFailover(printers, "booth-receipt", (d: PrinterDevice) =>
d.printSubscriptionCard(data),
);
logger.info(`subscription card ${card.code} printed on ${printedBy}`);
return printedBy;
}
+89
View File
@@ -0,0 +1,89 @@
// Credential capture ("enroll a card"): lets an operator present a physical RFID
// card/chip (or a QR) to ONE chosen reader and have its value captured for a
// subscription credential, instead of typing it. SINGLE-SHOT + short TTL so the
// chosen reader is only "borrowed" for one read / a few seconds; the OTHER reader is
// never affected and keeps serving the live entry/exit flow.
//
// Flow: arm(deviceId) → the reader route checks tryConsume() on each read; the next
// read from that armed reader is captured (NOT dispatched to the access flow — the
// barrier must not open for a card being enrolled) and capture auto-disarms. The
// booth form polls result() until the value appears (or it times out / is cancelled).
//
// In-memory + single-site single-writer (one booth) → no DB, no cross-process
// concerns. See wiki/entities/subscription.md.
const CAPTURE_TTL_MS = Number(process.env.CAPTURE_TTL_MS ?? 30_000);
export type CaptureState =
| { status: "idle" }
| { status: "armed"; deviceId: string; armedAt: number; expiresAt: number }
| { status: "captured"; deviceId: string; value: string; capturedAt: number }
| { status: "expired"; deviceId: string };
export class CredentialCapture {
#armedDeviceId: string | null = null;
#expiresAt = 0;
#captured: { deviceId: string; value: string; capturedAt: number } | null = null;
#lastExpiredDeviceId: string | null = null;
/** Arm a single-shot capture on one reader (by its `devices.id`). Replaces any
* prior arming (only one capture at a time). Clears a stale captured/expired
* result so the form starts fresh. */
arm(deviceId: string): { expiresAt: number } {
this.#armedDeviceId = deviceId;
this.#expiresAt = Date.now() + CAPTURE_TTL_MS;
this.#captured = null;
this.#lastExpiredDeviceId = null;
return { expiresAt: this.#expiresAt };
}
/** Cancel any pending arming (operator closed the form / clicked cancel). */
cancel(): void {
this.#armedDeviceId = null;
this.#expiresAt = 0;
}
/**
* Called by the reader route on EVERY read. If this reader is the armed one (and
* not expired), capture the value, disarm, and return true → the caller must NOT
* dispatch this read to the access flow. Otherwise false → dispatch normally.
*/
tryConsume(deviceId: string, value: string): boolean {
if (this.#armedDeviceId == null) return false;
if (Date.now() > this.#expiresAt) {
// Window lapsed before a card was presented — disarm, mark expired.
this.#lastExpiredDeviceId = this.#armedDeviceId;
this.#armedDeviceId = null;
this.#expiresAt = 0;
return false;
}
if (deviceId !== this.#armedDeviceId) return false; // a read from the OTHER reader
if (!value) return false;
this.#captured = { deviceId, value, capturedAt: Date.now() };
this.#armedDeviceId = null; // single-shot
this.#expiresAt = 0;
return true;
}
/** Current state for the booth form's poll. Lazily transitions armed→expired. */
state(): CaptureState {
if (this.#captured) return { status: "captured", ...this.#captured };
if (this.#armedDeviceId != null) {
if (Date.now() > this.#expiresAt) {
this.#lastExpiredDeviceId = this.#armedDeviceId;
this.#armedDeviceId = null;
this.#expiresAt = 0;
return { status: "expired", deviceId: this.#lastExpiredDeviceId };
}
return { status: "armed", deviceId: this.#armedDeviceId, armedAt: this.#expiresAt - CAPTURE_TTL_MS, expiresAt: this.#expiresAt };
}
if (this.#lastExpiredDeviceId) return { status: "expired", deviceId: this.#lastExpiredDeviceId };
return { status: "idle" };
}
/** Clear a consumed/expired result once the form has read it. */
clear(): void {
this.#captured = null;
this.#lastExpiredDeviceId = null;
}
}
+56 -2
View File
@@ -1,5 +1,6 @@
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import type { PrinterStatus } from "@parking/devices"; import type { PrinterStatus } from "@parking/devices";
import type { LedgerEventRow } from "@parking/db";
// Internal event bus for device-originated events (button presses, etc.). // Internal event bus for device-originated events (button presses, etc.).
// Hardware drivers / inbound device pushes emit here; business logic (entry // Hardware drivers / inbound device pushes emit here; business logic (entry
@@ -16,7 +17,7 @@ export interface DeviceInputEvent {
} }
// A credential read: a ticket scanned at exit, a plate from LPR, a card at a reader. // A credential read: a ticket scanned at exit, a plate from LPR, a card at a reader.
// Drives identity-based flows (exit validation, permits, pay-station lookup). `kind` // Drives identity-based flows (exit validation, subscriptions, pay-station lookup). `kind`
// mirrors IdentitySource. See parking-session.md. // mirrors IdentitySource. See parking-session.md.
export interface DeviceReadEvent { export interface DeviceReadEvent {
readonly driverId: string; readonly driverId: string;
@@ -34,7 +35,7 @@ export interface DeviceReadEvent {
export interface ReadOutcome { export interface ReadOutcome {
/** Was the vehicle admitted/exited (barrier opened)? Drives the reader's beep. */ /** Was the vehicle admitted/exited (barrier opened)? Drives the reader's beep. */
readonly accepted: boolean; readonly accepted: boolean;
/** Which way it went, when known (permit/exit infer this). */ /** Which way it went, when known (subscription/exit infer this). */
readonly direction?: "entry" | "exit"; readonly direction?: "entry" | "exit";
/** Human-readable reason (for logs / the reader UI), esp. on reject. */ /** Human-readable reason (for logs / the reader UI), esp. on reject. */
readonly reason?: string; readonly reason?: string;
@@ -48,6 +49,33 @@ export interface PrinterStatusEvent {
readonly status: PrinterStatus; readonly status: PrinterStatus;
} }
/**
* The unified live status of ANY configured device — what the booth footer shows.
* Every enabled device is polled: printers via their rich `readStatus()`
* (paper/cover/cutter), all other categories via the generic `healthCheck()`
* reachability probe. `state` is the common traffic-light; `detail` carries the
* human summary (e.g. "paper out", or an unreachable error). See device-monitor.ts
* and wiki/concepts/device-status-monitoring.md.
*/
export interface DeviceStatusEvent {
readonly deviceId: string; // devices id
readonly driverId: string;
readonly category: "access" | "reader" | "camera" | "printer";
/**
* The device's ROLE descriptor for the footer label — NOT the vendor. A
* direction-style token the client localises and pairs with the category, so the
* chip reads e.g. "Lexuesi hyrje" / "Kamera dalje" / "Printer kabina":
* - reader/camera: "entry" | "exit" | "both" (inherited from its bound relay)
* - access: "entry" | "exit" | "both" | "mixed" (from its relays[])
* - printer: "lane" (entry-dispenser) | "booth" (booth-receipt)
* - undetermined: null (chip shows the category alone)
*/
readonly roleKind: "entry" | "exit" | "both" | "mixed" | "lane" | "booth" | null;
readonly state: "ready" | "degraded" | "offline";
readonly detail?: string;
readonly checkedAt: string; // ISO-8601
}
class DeviceEventBus extends EventEmitter { class DeviceEventBus extends EventEmitter {
emitInput(event: DeviceInputEvent): void { emitInput(event: DeviceInputEvent): void {
this.emit("input", event); this.emit("input", event);
@@ -74,6 +102,32 @@ class DeviceEventBus extends EventEmitter {
this.on("printer-status", cb); this.on("printer-status", cb);
return () => this.off("printer-status", cb); return () => this.off("printer-status", cb);
} }
/** Emitted by the device monitor whenever ANY device's unified status CHANGES
* (all categories — relays, readers, cameras, printers). Drives the booth
* device-status footer over the WS. */
emitDeviceStatus(event: DeviceStatusEvent): void {
this.emit("device-status", event);
}
onDeviceStatus(cb: (event: DeviceStatusEvent) => void): () => void {
this.on("device-status", cb);
return () => this.off("device-status", cb);
}
/**
* Emitted AFTER a signed business event is appended to the ledger (entry, exit,
* payment, void, …). The payload is the persisted row — business facts only, no
* secrets — so it is safe to fan out to authenticated booth clients over the WS.
* This is a read-side notification ONLY: it never feeds back into append/sign/
* chain logic. See event-log.ts (emitted from EventLog.append) and routes/ws.ts.
*/
emitLedger(event: LedgerEventRow): void {
this.emit("ledger", event);
}
onLedger(cb: (event: LedgerEventRow) => void): () => void {
this.on("ledger", cb);
return () => this.off("ledger", cb);
}
} }
/** Process-wide device event bus. */ /** Process-wide device event bus. */
+156
View File
@@ -0,0 +1,156 @@
import type { FastifyBaseLogger } from "fastify";
import { devices, type Db, type DeviceRow } from "@parking/db";
import { isMonitorable, registry } from "@parking/devices";
import { deviceEvents, type DeviceStatusEvent } from "./device-events.js";
import { directionOf, relaysOf } from "./device-resolve.js";
// Unified live DEVICE monitor — the source for the booth's device-status footer.
// Every enabled, configured device is probed on an interval, regardless of
// category: a printer via its rich readStatus() (paper/cover/cutter — reusing the
// same capability the PrinterMonitor uses), and a relay/reader/camera via the
// generic healthCheck() reachability probe every Device implements. The result is
// flattened to a common traffic-light (ready | degraded | offline) + a detail
// string, cached per device id, and emitted on the bus ONLY when it changes.
//
// This is device-agnostic (talks to the adapter interfaces, never a driver SDK)
// and read-only — polling a device never drives a relay or mutates the ledger.
// See wiki/concepts/device-status-monitoring.md, printer-status-monitoring.md.
const POLL_MS = Number(process.env.DEVICE_POLL_MS ?? 8000);
/**
* The device's ROLE descriptor for the footer (never the vendor). Direction-style
* tokens the client localises next to the category:
* - reader/camera → the direction inherited from its bound relay (entry/exit/both)
* - access → entry/exit/both from its relays[]; "mixed" if it spans more
* than one direction; null if it declares none yet
* - printer → "lane" (entry-dispenser) | "booth" (booth-receipt)
*/
function roleKindOf(db: Db, row: DeviceRow): DeviceStatusEvent["roleKind"] {
switch (row.category) {
case "reader":
case "camera": {
const d = directionOf(db, row); // entry | exit | both
return d;
}
case "access": {
const dirs = new Set(relaysOf(row).map((r) => r.direction));
if (dirs.size === 0) return null;
if (dirs.size > 1) return "mixed";
const only = [...dirs][0]; // entry | exit | both
return only ?? null;
}
case "printer": {
const role = (row.config as { role?: string }).role;
if (role === "booth-receipt") return "booth";
if (role === "entry-dispenser") return "lane";
return null;
}
default:
return null;
}
}
export class DeviceMonitor {
readonly #db: Db;
readonly #log: FastifyBaseLogger;
readonly #pollMs: number;
/** Latest unified status per device id. */
readonly #latest = new Map<string, DeviceStatusEvent>();
#timer: ReturnType<typeof setInterval> | null = null;
#ticking = false;
constructor(db: Db, log: FastifyBaseLogger, pollMs = POLL_MS) {
this.#db = db;
this.#log = log;
this.#pollMs = pollMs;
}
/** Begin polling. Idempotent. */
start(): void {
if (this.#timer) return;
void this.#tick(); // immediate first pass so the footer fills without a wait
this.#timer = setInterval(() => void this.#tick(), this.#pollMs);
this.#timer.unref?.();
this.#log.info(`device-monitor: polling every ${this.#pollMs}ms`);
}
stop(): void {
if (this.#timer) {
clearInterval(this.#timer);
this.#timer = null;
}
}
/** Current snapshot for the API / a freshly-connected WS client. */
snapshot(): DeviceStatusEvent[] {
return [...this.#latest.values()];
}
async #tick(): Promise<void> {
if (this.#ticking) return; // never overlap polls
this.#ticking = true;
try {
// Re-read the device set each tick so a newly-assigned/removed device is
// picked up without a restart.
const rows = await this.#db.select().from(devices).all();
const enabled = rows.filter((r) => r.enabled);
const present = new Set(enabled.map((r) => r.id));
// Drop devices that are gone/disabled (so the footer doesn't show stale ones).
for (const id of [...this.#latest.keys()]) {
if (!present.has(id)) this.#latest.delete(id);
}
await Promise.all(enabled.map((r) => this.#poll(r)));
} catch (err) {
this.#log.warn(`device-monitor tick failed: ${(err as Error).message}`);
} finally {
this.#ticking = false;
}
}
async #poll(row: DeviceRow): Promise<void> {
const cfg = (row.config ?? {}) as Record<string, unknown>;
const base = {
deviceId: row.id,
driverId: row.driverId,
category: row.category,
roleKind: roleKindOf(this.#db, row),
};
let next: DeviceStatusEvent;
const driver = registry.get(row.driverId);
if (!driver) {
// Configured against a driver that's no longer registered — surface it,
// don't silently hide it.
next = { ...base, state: "offline", detail: "driver not registered", checkedAt: new Date().toISOString() };
} else {
try {
const device = driver.create(cfg as never);
// Printers expose richer paper/cover/cutter status; everything else uses
// the generic reachability probe. Both flatten to the same traffic-light.
if (isMonitorable(device)) {
const s = await device.readStatus();
next = { ...base, state: s.status, detail: s.detail, checkedAt: s.checkedAt };
} else {
const h = await device.healthCheck();
next = { ...base, state: h.status, detail: h.detail, checkedAt: new Date().toISOString() };
}
} catch (err) {
// A probe that throws (build error, timeout) reads as offline — never crash
// the tick, and fail toward "there's a problem" rather than false-healthy.
next = { ...base, state: "offline", detail: (err as Error).message, checkedAt: new Date().toISOString() };
}
}
const prev = this.#latest.get(row.id);
this.#latest.set(row.id, next);
if (!prev || prev.state !== next.state || prev.detail !== next.detail) {
this.#log.info(
`device-monitor: ${next.category}/${next.roleKind ?? "—"} ${row.id} -> ${next.state}${next.detail ? ` (${next.detail})` : ""}`,
);
deviceEvents.emitDeviceStatus(next);
}
}
}
+80 -8
View File
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto"; import { randomInt } from "node:crypto";
import { sessions, type Db, type DeviceRow } from "@parking/db"; import { eq, sessions, siteConfig, type Db, type DeviceRow } from "@parking/db";
import { import {
NoPrinterAvailableError, NoPrinterAvailableError,
printWithFailover, printWithFailover,
@@ -8,7 +8,9 @@ import {
type PrinterDevice, type PrinterDevice,
type PrinterInstance, type PrinterInstance,
type TicketData, type TicketData,
type TicketHeader,
} from "@parking/devices"; } from "@parking/devices";
import { DEFAULT_VEHICLE_CATEGORY } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
import type { DeviceInputEvent } from "./device-events.js"; import type { DeviceInputEvent } from "./device-events.js";
import { getOccupancy } from "./occupancy.js"; import { getOccupancy } from "./occupancy.js";
@@ -72,9 +74,9 @@ export class EntryFlow {
async #runEntry(resolved: ResolvedRelay): Promise<void> { async #runEntry(resolved: ResolvedRelay): Promise<void> {
// CAPACITY GATE (transient only). When the lot is full, refuse transient entry: // CAPACITY GATE (transient only). When the lot is full, refuse transient entry:
// no ticket, no vehicle_entry, no open — sign an anomaly. Permit holders are NOT // no ticket, no vehicle_entry, no open — sign an anomaly. Subscribers are NOT
// gated here (their flow ignores site-full; their own maxConcurrent applies), so // gated here (their flow ignores site-full; their own maxConcurrent applies), so
// subscribers aren't locked out. "Full" is a soft policy seam for valet over- // they aren't locked out. "Full" is a soft policy seam for valet over-
// capacity later. See wiki/concepts/capacity-occupancy.md. // capacity later. See wiki/concepts/capacity-occupancy.md.
const occ = getOccupancy(this.#db); const occ = getOccupancy(this.#db);
if (occ.full) { if (occ.full) {
@@ -91,7 +93,7 @@ export class EntryFlow {
const printers = this.#loadPrinters(); const printers = this.#loadPrinters();
// 1. PRINT FIRST. The ticket is the transient's session key — no ticket, no entry. // 1. PRINT FIRST. The ticket is the transient's session key — no ticket, no entry.
const ticket: TicketData = { ticketId, issuedAt }; const ticket: TicketData = { ticketId, issuedAt, header: this.#ticketHeader() };
try { try {
const printedBy = await printWithFailover(printers, "entry-dispenser", (d: PrinterDevice) => const printedBy = await printWithFailover(printers, "entry-dispenser", (d: PrinterDevice) =>
d.printTicket(ticket), d.printTicket(ticket),
@@ -112,12 +114,24 @@ export class EntryFlow {
} }
// 2. SIGN the vehicle_entry — BEFORE the relay fires (the core invariant). // 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({ await this.#log.append({
type: "vehicle_entry", type: "vehicle_entry",
direction: "entry", direction: "entry",
source: "ticket", source: "ticket",
identity: ticketId, identity: ticketId,
payload: { sessionRef: ticketId, ticketPrinted: true }, payload: { sessionRef: ticketId, ticketPrinted: true, category },
occurredAt: issuedAt, occurredAt: issuedAt,
}); });
@@ -182,9 +196,67 @@ export class EntryFlow {
} }
return out; return out;
} }
/** Park identity for the ticket header, from site_config (all fields optional;
* the driver prints only what's set). See wiki/concepts/site-metadata.md. */
#ticketHeader(): TicketHeader | undefined {
const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
if (!row) return undefined;
return {
parkName: row.parkName,
operatorName: row.operatorName,
nius: row.nius,
address: row.address,
phone: row.phone,
};
}
} }
/** Opaque, unguessable transient ticket id (wiki/concepts/ticket-encoding.md). */ /**
* Opaque, unguessable transient ticket id (wiki/concepts/ticket-encoding.md).
*
* Format: 13 digits = 12 cryptographically-random digits + 1 trailing Luhn check
* digit. All-numeric so the booth can read it on ANY legacy 1D barcode scanner and
* an operator can hand-key it if every reader is down. RANDOM (not sequential): the
* id must stay unguessable so an attacker can't iterate to claim a cheaper session
* — the anti-fraud property the wiki settles. 12 random digits = 10^12 space, so
* collisions are negligible at lot scale; the unique constraints on
* ledger_events.index / sessions.id are the backstop. The Luhn digit lets a manual
* entry reject a typo (validateTicketCode) instead of failing as "session not found".
*/
function newTicketId(): string { function newTicketId(): string {
return `T-${randomUUID()}`; let body = "";
for (let i = 0; i < 12; i += 1) body += String(randomInt(10));
return body + luhnCheckDigit(body);
}
/** The Luhn (mod-10) check digit for an all-digit string. */
function luhnCheckDigit(digits: string): string {
let sum = 0;
// Walk right-to-left; the check digit sits at position 0 from the right, so the
// last body digit is an "even" position that gets doubled.
let double = true;
for (let i = digits.length - 1; i >= 0; i -= 1) {
let d = digits.charCodeAt(i) - 48;
if (double) {
d *= 2;
if (d > 9) d -= 9;
}
sum += d;
double = !double;
}
return String((10 - (sum % 10)) % 10);
}
/**
* True if `code` is a well-formed ticket code: all digits and a valid Luhn checksum.
* Lets a manual-entry path (operator types the code off the ticket when readers are
* down) reject a typo up front. A scanned/looked-up id that predates this format
* (e.g. legacy `T-<uuid>`) won't pass — callers should only gate MANUAL entry on it,
* never reject an id that already exists in the ledger. See ticket-encoding.md.
*/
export function validateTicketCode(code: string): boolean {
if (!/^\d{13}$/.test(code)) return false;
const body = code.slice(0, 12);
return luhnCheckDigit(body) === code[12];
} }
+46 -4
View File
@@ -81,15 +81,34 @@ export function hashEvent(canonical: string): string {
return createHash("sha256").update(canonical, "utf8").digest("hex"); return createHash("sha256").update(canonical, "utf8").digest("hex");
} }
/** Resolve a verifier for an event's stored `keyId` (see signer.buildVerifier).
* Returns undefined when the key that signed an event is not available. */
export type SignerResolver = (keyId: string) => Signer | undefined;
export class EventLog { export class EventLog {
readonly #db: Db; readonly #db: Db;
readonly #signer: Signer; readonly #signer: Signer;
/** Picks the verifying signer per event keyId; lets a chain span key rotations
* (JWT-fallback → dedicated key → ATECC608). Defaults to the append signer for
* callers that don't pass one (single-key chains, tests). */
readonly #resolveVerifier: SignerResolver;
/** Optional read-side notification, fired AFTER a row is durably inserted. Used
* to fan the event out to live booth clients (WS). It is best-effort and must
* NOT influence the append/sign/chain path — a throwing/absent sink is ignored. */
readonly #onAppended?: (row: LedgerEventRow) => void;
/** Serialize appends: each waits for the previous to finish. */ /** Serialize appends: each waits for the previous to finish. */
#tail: Promise<unknown> = Promise.resolve(); #tail: Promise<unknown> = Promise.resolve();
constructor(db: Db, signer: Signer) { constructor(
db: Db,
signer: Signer,
resolveVerifier?: SignerResolver,
onAppended?: (row: LedgerEventRow) => void,
) {
this.#db = db; this.#db = db;
this.#signer = signer; this.#signer = signer;
this.#resolveVerifier = resolveVerifier ?? (() => signer);
this.#onAppended = onAppended;
} }
/** Append one event to the chain. Returns the persisted row. Serialized. */ /** Append one event to the chain. Returns the persisted row. Serialized. */
@@ -97,7 +116,16 @@ export class EventLog {
const run = this.#tail.then(() => this.#appendNow(input)); const run = this.#tail.then(() => this.#appendNow(input));
// Keep the chain going even if one append rejects (don't wedge the lock). // Keep the chain going even if one append rejects (don't wedge the lock).
this.#tail = run.catch(() => undefined); this.#tail = run.catch(() => undefined);
return run; // Read-side notification, AFTER the row is durably written. Wrapped so a
// failing sink can never reject the append or break the chain lock above.
return run.then((row) => {
try {
this.#onAppended?.(row);
} catch {
// best-effort fan-out only — swallow.
}
return row;
});
} }
#appendNow(input: AppendInput): LedgerEventRow { #appendNow(input: AppendInput): LedgerEventRow {
@@ -146,7 +174,13 @@ export class EventLog {
* Walk the chain oldest→newest and recompute hashes + signatures. Returns the * Walk the chain oldest→newest and recompute hashes + signatures. Returns the
* first detected break, or { ok: true }. This is what reconciliation and an * first detected break, or { ok: true }. This is what reconciliation and an
* integrity self-check call. Catches: tampered content, reordering, a deleted * integrity self-check call. Catches: tampered content, reordering, a deleted
* row (index gap), and a forged/invalid signature. * row (index gap), a forged/invalid signature, and an event signed under a key
* that is no longer configured.
*
* Each row is verified against the signer for ITS OWN `keyId`, not the current
* append signer — so a chain that spans a key rotation (e.g. early events under
* the JWT_SECRET fallback, later ones under a dedicated EVENT_SIGNING_KEY) still
* verifies end to end. See signer.buildVerifier.
*/ */
verifyChain(): { ok: true } | { ok: false; index: number; reason: string } { verifyChain(): { ok: true } | { ok: false; index: number; reason: string } {
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all(); const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
@@ -159,8 +193,16 @@ export class EventLog {
if ((row.prevHash ?? null) !== prevHash) { if ((row.prevHash ?? null) !== prevHash) {
return { ok: false, index: row.index, reason: "prevHash does not match chain" }; return { ok: false, index: row.index, reason: "prevHash does not match chain" };
} }
const verifier = this.#resolveVerifier(row.keyId);
if (!verifier) {
return {
ok: false,
index: row.index,
reason: `no signer for keyId "${row.keyId}" (key not configured)`,
};
}
const canonical = canonicalize(row); const canonical = canonicalize(row);
if (!this.#signer.verify(canonical, row.signature)) { if (!verifier.verify(canonical, row.signature)) {
return { ok: false, index: row.index, reason: "signature invalid (content tampered or wrong key)" }; return { ok: false, index: row.index, reason: "signature invalid (content tampered or wrong key)" };
} }
prevHash = hashEvent(canonical); prevHash = hashEvent(canonical);
+321 -17
View File
@@ -1,8 +1,8 @@
import { eq, ledgerEvents, sessions, type Db, type DeviceRow } from "@parking/db"; import { desc, eq, ledgerEvents, sessions, tariffVersions, tariffs, type Db, type DeviceRow } from "@parking/db";
import { registry, type AccessControlDevice } from "@parking/devices"; import { registry, type AccessControlDevice } from "@parking/devices";
import type { ResolvedRelay } from "./device-resolve.js"; import { firstRelayByDirection, type ResolvedRelay } from "./device-resolve.js";
import { snapshotAsync } from "./snapshot.js"; import { snapshotAsync } from "./snapshot.js";
import type { LedgerPayload } from "@parking/shared"; import { computeFee, type LedgerPayload, type TariffStructure } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js"; import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
import type { EventLog } from "./event-log.js"; import type { EventLog } from "./event-log.js";
@@ -30,9 +30,32 @@ interface SessionView {
readonly enteredAt: string; readonly enteredAt: string;
readonly open: boolean; // no vehicle_exit yet readonly open: boolean; // no vehicle_exit yet
readonly paidAt: string | null; // latest payment time, if any readonly paidAt: string | null; // latest payment time, if any
/** A SUBSCRIPTION occurrence (prepaid; entry payload permit:true). Authorized to
* exit / re-open without a `payment`. */
readonly subscription: boolean;
readonly graceExitMin: number | null; // from the payment's tariff context, if known readonly graceExitMin: number | null; // from the payment's tariff context, if known
// Within the FREE entry-grace window (a quick in-and-out that the tariff prices at
// 0). When true the exit opens without a pay-station visit — we mint a $0 payment so
// the ledger's "an exit is covered by a payment" invariant still holds. Null when no
// active tariff resolves (then we fall back to the normal paid check).
readonly freeGrace: { tariffVersionId: string; currency: string; graceExitMin: number } | null;
} }
/** Result of a booth-driven exit (POST /api/exit). `ok=false` = validation rejected
* (nothing signed beyond an anomaly). `ok=true, opened=false` = exit IS signed but
* the barrier didn't open (payment stands; operator opens manually). */
export type BoothExitResult =
| { ok: false; status: "invalid" | "no_session" | "closed" | "unpaid" | "grace_expired"; reason: string }
| { ok: true; opened: true }
| { ok: true; opened: false; reason: string };
/** Result of a human-intervention barrier re-open (POST /api/barrier/reopen).
* `ok=false` = refused (no session / unpaid). `ok=true, opened=false` = the
* intervention was recorded (signed anomaly) but the relay did not fire. */
export type BoothReopenResult =
| { ok: false; reason: string }
| { ok: true; opened: boolean; reason?: string };
export class ExitFlow { export class ExitFlow {
readonly #db: Db; readonly #db: Db;
readonly #log: EventLog; readonly #log: EventLog;
@@ -45,8 +68,189 @@ export class ExitFlow {
this.#logger = logger; this.#logger = logger;
} }
/**
* BOOTH-driven exit: the operator (not a reader at the lane) opens the barrier for
* a ticket. Runs the SAME validation as the reader path — there is no booth-only
* bypass that admits an unpaid car (see wiki/concepts/booth-exit-flow.md +
* threat-model.md). On a valid session it signs vehicle_exit, resolves AN exit
* relay site-wide, pulses it, and fires the exit snapshot.
*
* Returns a discriminated result so the route can react precisely:
* - { ok: false, status } when validation rejects (unpaid / no session / closed)
* — nothing is signed beyond the existing anomaly; the operator takes payment.
* - { ok: true, opened: true } on a clean exit.
* - { ok: true, opened: false } when the exit IS signed but the relay open FAILED
* (offline controller / no exit relay). The signed payment + vehicle_exit STAND
* (money was taken, the car is owed an exit) and an `anomaly` is appended so the
* operator opens manually. Payment is never rolled back.
*/
async exitForBooth(identity: string): Promise<BoothExitResult> {
const id = identity.trim();
if (!id) return { ok: false, status: "invalid", reason: "ticket id required" };
const key = `booth:${id}`;
if (this.#inFlight.has(key)) return { ok: false, status: "invalid", reason: "exit already in progress" };
this.#inFlight.add(key);
try {
const view = this.#sessionFor(id);
// No open session — unknown/closed ticket. Sign an anomaly (same as the reader
// path) so a booth attempt on a bad ticket is auditable.
if (!view || !view.open) {
const reason = view ? "exit refused — session already closed" : "exit refused — no open session for ticket";
await this.#log.append({ type: "anomaly", identity: id, payload: { reason, exitRefused: true, source: "booth" } });
this.#logger.warn(`booth exit refused (${id}): ${reason}`);
return { ok: false, status: view ? "closed" : "no_session", reason };
}
// PAID + within grace, OR free entry-grace — the same checks the reader uses.
const freeGrace = view.paidAt == null && view.freeGrace != null;
const paid = view.paidAt != null;
const withinGrace =
paid && view.graceExitMin != null && Date.now() - Date.parse(view.paidAt!) <= view.graceExitMin * 60_000;
if (!freeGrace && (!paid || !withinGrace)) {
const reason = !paid
? "exit refused — not paid (take payment first)"
: "exit refused — walk-back grace expired (top-up required)";
await this.#log.append({ type: "anomaly", identity: id, payload: { reason, exitRefused: true, source: "booth" } });
this.#logger.warn(`booth exit refused (${id}): ${reason}`);
return { ok: false, status: paid ? "grace_expired" : "unpaid", reason };
}
// Free entry-grace path: mint the $0 payment first (ledger invariant), as the
// reader path does.
if (freeGrace && view.freeGrace) {
await this.#log.append({
type: "payment",
identity: id,
payload: {
sessionRef: id,
amountMinor: 0,
currency: view.freeGrace.currency,
tariffVersionId: view.freeGrace.tariffVersionId,
graceExitMin: view.freeGrace.graceExitMin,
reason: "free entry-grace (no charge)",
},
});
}
// Resolve AN exit barrier site-wide (no reader binding to follow at the booth).
const resolved = firstRelayByDirection(this.#db, "exit");
// Sign the vehicle_exit regardless of whether a relay resolves — the decision
// to let the car out has been made and validated. Then attempt the open.
await this.#signExit(id);
if (!resolved) {
await this.#openFailedAnomaly(id, "no exit relay configured");
return { ok: true, opened: false, reason: "exit recorded, but no exit barrier is configured — open manually" };
}
const access = this.#buildAccess(resolved.controller);
if (!access) {
await this.#openFailedAnomaly(id, "exit controller would not build");
return { ok: true, opened: false, reason: "exit recorded, but the barrier is unavailable — open manually" };
}
try {
await access.pulseOpen(resolved.relay);
} catch (err) {
await this.#openFailedAnomaly(id, `pulseOpen failed: ${(err as Error).message}`);
return { ok: true, opened: false, reason: "exit recorded, but the barrier did not open — open manually" };
}
this.#fireExitSnapshot(id);
this.#closeSessionCache(id);
return { ok: true, opened: true };
} finally {
this.#inFlight.delete(key);
}
}
/**
* HUMAN-INTERVENTION barrier re-open for an ACTIVE session (booth Active Sessions
* list). The barrier is unconfirmed; a car may be stuck after a damaged-ticket
* read, a dead scanner, or a phantom re-close (animal / bag / box). The operator
* opens the barrier with a signed trace.
*
* Guard: requires a PAYMENT — no payment, no re-open (the no-unpaid-bypass rule;
* the UI also hides the button). It re-pulses the exit relay and signs an `anomaly`
* ("manual barrier open", attributed). Idempotent-safe per identity via #inFlight.
*
* CLOSING THE SESSION (fix 2026-06-18): if the session is still OPEN (no
* `vehicle_exit` yet), the manual re-open *is* this car leaving — so we also sign a
* `vehicle_exit` (attributed as human-intervention). Without it the paid session
* would linger in the Active Sessions list FOREVER, since the grace-expiry eviction
* only applies to already-exited sessions (the T-397815c0 bug). If the session is
* already CLOSED (a prior exit exists — the phantom re-close case), we do NOT sign a
* second exit (that would double-count occupancy): anomaly only, as before.
* See wiki/concepts/booth-exit-flow.md.
*/
async reopenBarrier(identity: string, operator?: string): Promise<BoothReopenResult> {
const id = identity.trim();
if (!id) return { ok: false, reason: "ticket id required" };
const view = this.#sessionFor(id);
if (!view) return { ok: false, reason: "no session for ticket" };
// Authorization to re-open: a PAID transient (paid, or paid-then-exited within
// grace) OR a SUBSCRIPTION occurrence (prepaid — exactly the case the operator must
// assist when the exit reader / card fails). An unpaid TRANSIENT takes the pay/exit
// flow instead — enforced here, not just in the UI (the no-unpaid-bypass rule).
if (view.paidAt == null && !view.subscription) {
return { ok: false, reason: "session not paid — no barrier open without payment" };
}
const key = `reopen:${id}`;
if (this.#inFlight.has(key)) return { ok: false, reason: "re-open already in progress" };
this.#inFlight.add(key);
try {
const resolved = firstRelayByDirection(this.#db, "exit");
// Sign the audited anomaly FIRST (the intervention is recorded whether or not
// the physical open succeeds).
await this.#log.append({
type: "anomaly",
identity: id,
payload: {
reason: "manual barrier open (human intervention)",
source: "booth",
barrierReopen: true,
...(operator ? { operator } : {}),
},
});
// Close an OPEN session: the re-open is the exit. Sign the vehicle_exit so the
// session leaves the active list + occupancy settles. Skip when already exited
// (no double-count). Recorded as a human-intervention exit for the audit trail.
if (view.open) {
await this.#signExit(id, "manual");
this.#closeSessionCache(id);
this.#fireExitSnapshot(id);
this.#logger.info(`barrier re-open also closed open session ${id} (human-intervention exit)`);
}
if (!resolved) {
this.#logger.warn(`barrier re-open for ${id}: no exit relay configured`);
return { ok: true, opened: false, reason: "no exit barrier configured — open manually" };
}
const access = this.#buildAccess(resolved.controller);
if (!access) {
this.#logger.warn(`barrier re-open for ${id}: exit controller would not build`);
return { ok: true, opened: false, reason: "barrier unavailable — open manually" };
}
try {
await access.pulseOpen(resolved.relay);
} catch (err) {
this.#logger.error(`barrier re-open pulseOpen failed (${id}): ${(err as Error).message}`);
return { ok: true, opened: false, reason: "barrier did not open — open manually" };
}
this.#logger.info(`manual barrier open for ${id}${operator ? ` by ${operator}` : ""}`);
return { ok: true, opened: true };
} finally {
this.#inFlight.delete(key);
}
}
/** Handle a transient-ticket read at an exit barrier (the relay pre-resolved by the /** Handle a transient-ticket read at an exit barrier (the relay pre-resolved by the
* read dispatcher from the reader's binding, which has ruled out a permit match). */ * read dispatcher from the reader's binding, which has ruled out a subscription match). */
async handleAt(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> { async handleAt(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> {
const key = `${e.deviceId}:${e.value}`; const key = `${e.deviceId}:${e.value}`;
if (this.#inFlight.has(key)) return { accepted: false, reason: "duplicate read in flight" }; if (this.#inFlight.has(key)) return { accepted: false, reason: "duplicate read in flight" };
@@ -76,6 +280,28 @@ export class ExitFlow {
return { accepted: false, direction: "exit", reason }; return { accepted: false, direction: "exit", reason };
} }
// FREE entry-grace: a quick in-and-out the tariff prices at 0 exits at the gate
// with no pay-station visit. Mint a signed $0 `payment` first so the ledger keeps
// its "an exit is covered by a payment" invariant, then fall through to open.
// Only when NOT already paid (a real payment, walk-back grace, takes precedence).
if (view.paidAt == null && view.freeGrace) {
await this.#log.append({
type: "payment",
// No `source` (not operator-keyed nor a read) — the payload reason marks it.
identity: e.value,
payload: {
sessionRef: e.value,
amountMinor: 0,
currency: view.freeGrace.currency,
tariffVersionId: view.freeGrace.tariffVersionId,
graceExitMin: view.freeGrace.graceExitMin,
reason: "free entry-grace (no charge)",
},
});
this.#logger.info(`exit free within entry-grace (${e.value})`);
return this.#signExitAndOpen(resolved, e);
}
// PAID + within walk-back grace? // PAID + within walk-back grace?
const paid = view.paidAt != null; const paid = view.paidAt != null;
const withinGrace = const withinGrace =
@@ -96,37 +322,73 @@ export class ExitFlow {
return { accepted: false, direction: "exit", reason }; return { accepted: false, direction: "exit", reason };
} }
// Valid: sign the exit BEFORE opening, then open, then update the cache. // Valid (a real payment within walk-back grace): sign + open.
await this.#log.append({ return this.#signExitAndOpen(resolved, e);
type: "vehicle_exit", }
direction: "exit",
source: e.kind === "plate" ? "lpr" : "ticket", /** Sign the vehicle_exit BEFORE opening, then open, snapshot, and update the cache.
identity: e.value, * Shared by the paid-exit and free-entry-grace paths. The caller has already
payload: { sessionRef: e.value }, * established the session is allowed out (and, for grace, minted the $0 payment). */
}); async #signExitAndOpen(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> {
await this.#signExit(e.value, e.kind === "plate" ? "lpr" : "ticket");
const access = this.#buildAccess(resolved.controller); const access = this.#buildAccess(resolved.controller);
if (access) await access.pulseOpen(resolved.relay); if (access) await access.pulseOpen(resolved.relay);
else this.#logger.warn(`exit signed for ${e.value} but the exit relay won't build`); else this.#logger.warn(`exit signed for ${e.value} but the exit relay won't build`);
// SNAPSHOT — fire the exit camera(s), never awaited (evidence, not a gate). this.#fireExitSnapshot(e.value);
this.#closeSessionCache(e.value);
return { accepted: true, direction: "exit" };
}
/** Append the signed vehicle_exit. `source`: "ticket" (booth/reader), "lpr" (plate),
* or "manual" (a human-intervention barrier re-open that closes an open session —
* see reopenBarrier). */
async #signExit(identity: string, source: "ticket" | "lpr" | "manual" = "ticket"): Promise<void> {
await this.#log.append({
type: "vehicle_exit",
direction: "exit",
source,
identity,
payload: {
sessionRef: identity,
...(source === "manual" ? { reason: "human-intervention exit (manual barrier open)" } : {}),
},
});
}
/** Fire the exit camera(s); never awaited (evidence, not a gate). */
#fireExitSnapshot(identity: string): void {
void snapshotAsync({ void snapshotAsync({
db: this.#db, db: this.#db,
direction: "exit", direction: "exit",
identity: e.value, identity,
logger: this.#logger, logger: this.#logger,
}).catch((err) => this.#logger.error(`exit snapshot error: ${(err as Error).message}`)); }).catch((err) => this.#logger.error(`exit snapshot error: ${(err as Error).message}`));
}
/** Update the (rebuildable) session projection cache to closed. */
#closeSessionCache(identity: string): void {
try { try {
this.#db this.#db
.update(sessions) .update(sessions)
.set({ exitedAt: new Date().toISOString(), state: "closed" }) .set({ exitedAt: new Date().toISOString(), state: "closed" })
.where(eq(sessions.id, e.value)) .where(eq(sessions.id, identity))
.run(); .run();
} catch (err) { } catch (err) {
this.#logger.error(`session-cache close failed for ${e.value}: ${(err as Error).message}`); this.#logger.error(`session-cache close failed for ${identity}: ${(err as Error).message}`);
} }
return { accepted: true, direction: "exit" }; }
/** Record an audited anomaly when an exit was signed but the barrier didn't open.
* The payment + exit STAND; this tells the operator to open manually. */
async #openFailedAnomaly(identity: string, detail: string): Promise<void> {
await this.#log.append({
type: "anomaly",
identity,
payload: { reason: "exit signed but barrier open failed", detail, source: "booth", exitOpenFailed: true },
});
this.#logger.error(`booth exit open failed (${identity}): ${detail}`);
} }
/** Fold the signed ledger into a session view for one identity (authoritative). */ /** Fold the signed ledger into a session view for one identity (authoritative). */
@@ -153,15 +415,57 @@ export class ExitFlow {
} }
} }
// Free entry-grace: if the tariff prices entry→now at 0 (a quick in-and-out),
// the exit may open at the gate. Resolve against the tariff in force at entry,
// same as the pay station. Null when no payment is needed yet and no tariff
// resolves — then exit falls back to the normal paid check.
let freeGrace: SessionView["freeGrace"] = null;
if (!exited && paidAt == null) {
const tv = this.#tariffVersionFor(entry.occurredAt);
if (tv) {
const structure = tv.structure as unknown as TariffStructure;
// 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,
currency: tv.currency,
graceExitMin: structure.gracePeriodExitMin,
};
}
}
}
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
const subscription = entryPl.permit === true || entryPl.permitId != null;
return { return {
identity, identity,
enteredAt: entry.occurredAt, enteredAt: entry.occurredAt,
open: !exited, open: !exited,
paidAt, paidAt,
subscription,
graceExitMin, graceExitMin,
freeGrace,
}; };
} }
/** The tariff version in force at `at` — latest effectiveFrom ≤ at, for the
* (single, for now) active site tariff. Mirrors PayStation#tariffVersionFor. */
#tariffVersionFor(at: string) {
const tariff = this.#db.select().from(tariffs).where(eq(tariffs.scope, "site")).get();
if (!tariff) return null;
const versions = this.#db
.select()
.from(tariffVersions)
.where(eq(tariffVersions.tariffId, tariff.id))
.orderBy(desc(tariffVersions.effectiveFrom))
.all();
return versions.find((v) => v.effectiveFrom <= at) ?? null;
}
/** Build a live access adapter from a resolved controller row, or null. */ /** Build a live access adapter from a resolved controller row, or null. */
#buildAccess(row: DeviceRow): AccessControlDevice | null { #buildAccess(row: DeviceRow): AccessControlDevice | null {
const driver = registry.get(row.driverId); const driver = registry.get(row.driverId);
+243 -2
View File
@@ -1,4 +1,4 @@
import { desc, eq, ledgerEvents, sessions, tariffVersions, tariffs, type Db } from "@parking/db"; import { desc, eq, ledgerEvents, sessions, subscriptions, tariffVersions, tariffs, type Db } from "@parking/db";
import { computeFee, type TariffStructure, type Tender } from "@parking/shared"; import { computeFee, type TariffStructure, type Tender } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
import type { EventLog } from "./event-log.js"; import type { EventLog } from "./event-log.js";
@@ -35,6 +35,57 @@ export interface Quote {
readonly graceExitMin: number; readonly graceExitMin: number;
} }
/** One row in the booth Active Sessions list. A session is "active" while it is
* still open OR exited-but-within-grace — because the barrier is UNCONFIRMED, a
* paid/exited car is presumed possibly-still-present until grace expires. The
* "Open barrier" action is offered only when `paidAt != null` (no payment, no
* button — the no-unpaid-bypass rule). See wiki/concepts/booth-exit-flow.md. */
export interface ActiveSession {
readonly identity: string;
readonly source: string | null;
readonly enteredAt: string;
/** null while still inside; set once a vehicle_exit is signed (may still be present). */
readonly exitedAt: string | null;
readonly open: boolean;
readonly paidAt: string | null;
/** Amount owed now (open + unpaid only; null otherwise / no tariff). */
readonly amountMinor: number | null;
readonly currency: string | null;
readonly withinGrace: boolean;
readonly graceExpiresAt: string | null;
/** True for a SUBSCRIPTION occurrence (prepaid — never charged). The booth shows it
* with snapshots + an always-available "open barrier" (assist a faulty exit reader /
* missing card), and never a pay flow. See wiki/entities/subscription.md. */
readonly subscription: boolean;
/** The subscription id (on-chain `permitId`), when `subscription` is true. */
readonly subscriptionId: string | null;
/** The subscriber's holder name (for a friendly label instead of the raw key). */
readonly subscriptionHolder: string | null;
}
/** Booth session view: everything the pay/exit modal needs in one read. */
export interface SessionLookup {
readonly identity: string;
readonly found: boolean;
/** Open = entered, no exit yet. */
readonly open: boolean;
readonly enteredAt: string | null;
readonly exitedAt: string | null;
/** Latest payment time, if paid. */
readonly paidAt: string | null;
/** Amount owed right now (the quote). Null when no session / no active tariff. */
readonly amountMinor: number | null;
readonly currency: string | null;
/** True when paid AND still within the walk-back grace window. */
readonly withinGrace: boolean;
/** ISO time the walk-back grace expires (paidAt + graceExitMin), if paid. */
readonly graceExpiresAt: string | null;
/** True for a SUBSCRIPTION occurrence (prepaid — never charged; barrier-open only). */
readonly subscription: boolean;
readonly subscriptionId: string | null;
readonly subscriptionHolder: string | null;
}
export class PayStation { export class PayStation {
readonly #db: Db; readonly #db: Db;
readonly #log: EventLog; readonly #log: EventLog;
@@ -55,7 +106,11 @@ export class PayStation {
if (!tv) throw new NoTariffError(); if (!tv) throw new NoTariffError();
const structure = tv.structure as unknown as TariffStructure; 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 { return {
identity, identity,
enteredAt: entry.occurredAt, enteredAt: entry.occurredAt,
@@ -109,6 +164,192 @@ export class PayStation {
return { amountMinor, currency: q.currency }; return { amountMinor, currency: q.currency };
} }
/**
* One-read session view for the booth pay/exit modal: entry/exit times, paid
* state, amount owed now, and walk-back-grace status. Read-only — folds the
* signed ledger (authoritative). A quote failure (no tariff) leaves amount null
* rather than throwing, so the modal can still show the session.
*/
lookup(identity: string): SessionLookup {
const id = identity.trim();
const rows = this.#db
.select()
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, id))
.orderBy(ledgerEvents.index)
.all();
const entry = rows.find((r) => r.type === "vehicle_entry");
if (!entry) {
return {
identity: id, found: false, open: false, enteredAt: null, exitedAt: null,
paidAt: null, amountMinor: null, currency: null, withinGrace: false, graceExpiresAt: null,
subscription: false, subscriptionId: null, subscriptionHolder: null,
};
}
// Subscription occurrence? The entry payload carries permit:true + permitId.
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
const isSubscription = entryPl.permit === true || entryPl.permitId != null;
const subscriptionId = isSubscription ? (entryPl.permitId ?? null) : null;
const exitRow = rows.find((r) => r.type === "vehicle_exit");
const open = !exitRow;
let paidAt: string | null = null;
let graceExitMin: number | null = null;
for (const r of rows) {
if (r.type === "payment") {
paidAt = r.occurredAt;
const p = (r.payload ?? {}) as { graceExitMin?: number };
if (typeof p.graceExitMin === "number") graceExitMin = p.graceExitMin;
}
}
const graceExpiresAt =
paidAt && graceExitMin != null ? new Date(Date.parse(paidAt) + graceExitMin * 60_000).toISOString() : null;
const withinGrace = graceExpiresAt != null && Date.now() <= Date.parse(graceExpiresAt);
// Amount owed now (best-effort; null if no tariff resolves). Only meaningful while
// open AND transient — a subscription is prepaid, never quoted/charged.
let amountMinor: number | null = null;
let currency: string | null = null;
if (open && !isSubscription) {
try {
const q = this.quote(id);
amountMinor = q.amountMinor;
currency = q.currency;
} catch {
/* no active tariff — leave null; modal shows session without a price */
}
}
return {
identity: id, found: true, open,
enteredAt: entry.occurredAt, exitedAt: exitRow?.occurredAt ?? null,
paidAt, amountMinor, currency, withinGrace, graceExpiresAt,
subscription: isSubscription, subscriptionId,
subscriptionHolder: this.#holderOf(subscriptionId),
};
}
/**
* All ACTIVE sessions for the booth list: still-open, OR exited-but-within-grace
* (the barrier is unconfirmed, so a paid/exited car is presumed possibly-present
* until grace expires). One ledger scan, grouped by identity (cheaper than N
* lookups). Sorted by entry time, newest first. Folds the SIGNED ledger
* (authoritative — not the sessions projection cache, which can drift).
* See wiki/concepts/booth-exit-flow.md.
*/
activeSessions(): ActiveSession[] {
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
// Group the relevant events per identity in one pass.
type Acc = {
enteredAt?: string;
source: string | null;
exitedAt?: string;
paidAt?: string;
graceExitMin?: number;
subscriptionId?: string | null;
};
const byId = new Map<string, Acc>();
for (const r of rows) {
const id = r.identity;
if (!id) continue;
if (r.type === "vehicle_entry") {
const a = byId.get(id) ?? { source: r.source ?? null };
a.enteredAt = r.occurredAt;
a.source = r.source ?? a.source;
// Subscription occurrence? The entry payload carries permit:true + permitId
// (the on-chain field). Mark it so the booth never tries to charge it.
const pl = (r.payload ?? {}) as { permit?: boolean; permitId?: string };
if (pl.permit === true || pl.permitId) a.subscriptionId = pl.permitId ?? null;
byId.set(id, a);
} else if (r.type === "vehicle_exit") {
const a = byId.get(id);
if (a) a.exitedAt = r.occurredAt;
} else if (r.type === "payment") {
const a = byId.get(id);
if (a) {
a.paidAt = r.occurredAt;
const p = (r.payload ?? {}) as { graceExitMin?: number };
if (typeof p.graceExitMin === "number") a.graceExitMin = p.graceExitMin;
}
}
}
const now = Date.now();
const out: ActiveSession[] = [];
for (const [identity, a] of byId) {
if (!a.enteredAt) continue; // no entry → not a real session
const open = a.exitedAt == null;
const graceExpiresAt =
a.paidAt && a.graceExitMin != null
? new Date(Date.parse(a.paidAt) + a.graceExitMin * 60_000).toISOString()
: null;
const withinGrace = graceExpiresAt != null && now <= Date.parse(graceExpiresAt);
const paid = a.paidAt != null;
// ACTIVE membership:
// - exited + within grace → still shown (barrier unconfirmed, may be present);
// - exited + past grace → presumed gone, omitted;
// - open + UNPAID → always shown (a car owing money never ages out —
// it's genuinely still inside until it pays, however long that takes);
// - open + PAID + past grace → AGE-OUT (omit). A paid car whose walk-back grace
// lapsed has left; if no vehicle_exit was ever signed (e.g. it left via a
// manual barrier re-open before that path closed the session, or a historical
// session like T-397815c0) it would otherwise linger forever. The signed log
// is unchanged — this is purely a display filter. See booth-exit-flow.md.
if (!open && !withinGrace) continue;
if (open && paid && graceExpiresAt != null && !withinGrace) continue;
const isSubscription = a.subscriptionId !== undefined;
// Amount owed now: only meaningful for an open + unpaid TRANSIENT session. A
// subscription is prepaid — never quote/charge it.
let amountMinor: number | null = null;
let currency: string | null = null;
if (open && a.paidAt == null && !isSubscription) {
try {
const q = this.quote(identity);
amountMinor = q.amountMinor;
currency = q.currency;
} catch {
/* no active tariff — leave null */
}
}
out.push({
identity,
source: a.source,
enteredAt: a.enteredAt,
exitedAt: a.exitedAt ?? null,
open,
paidAt: a.paidAt ?? null,
amountMinor,
currency,
withinGrace,
graceExpiresAt,
subscription: isSubscription,
subscriptionId: a.subscriptionId ?? null,
subscriptionHolder: this.#holderOf(a.subscriptionId ?? null),
});
}
// Newest entry first.
out.sort((x, y) => Date.parse(y.enteredAt) - Date.parse(x.enteredAt));
return out;
}
/** The subscriber's holder name for a subscription id (for a friendly UI label),
* or null. Best-effort: a deleted subscription just yields null. */
#holderOf(subscriptionId: string | null): string | null {
if (!subscriptionId) return null;
try {
const row = this.#db.select().from(subscriptions).where(eq(subscriptions.id, subscriptionId)).get();
return row?.holderName ?? null;
} catch {
return null;
}
}
/** The vehicle_entry of an OPEN session for this identity (no later exit), or null. */ /** The vehicle_entry of an OPEN session for this identity (no later exit), or null. */
#openEntry(identity: string) { #openEntry(identity: string) {
const rows = this.#db const rows = this.#db
-222
View File
@@ -1,222 +0,0 @@
import { eq, ledgerEvents, permitCredentials, permitPlates, permits, sessions, type Db, type DeviceRow } from "@parking/db";
import { registry, type AccessControlDevice } from "@parking/devices";
import type { FastifyBaseLogger } from "fastify";
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
import type { EventLog } from "./event-log.js";
import { type FlowDirection, type ResolvedRelay } from "./device-resolve.js";
import { snapshotAsync } from "./snapshot.js";
// PERMIT flow: a subscriber identified by card/QR/plate enters/exits without paying.
// Reached from the read dispatcher when a read matches a permit (not an open ticket).
// See wiki/entities/permit.md.
//
// Two optional, independent bindings:
// - car-count: `maxConcurrent` (default 1, null = unbound) — how many of the
// permit's cars may be inside at once; enforced over the session projection.
// - plate: optional `plates[]` — when set, a matching plate is an accepted identity
// too (card/QR OR plate). When unset, any car may use the permit's card/QR.
//
// Direction is inferred from session state for THAT car (the read credential value
// is the per-car session key): no open session → ENTRY; open session → EXIT. So a
// fleet permit can have several cars in at once, each its own session, and
// anti-passback falls out (a second "entry" on a car already in becomes its exit).
export interface PermitMatch {
readonly permitId: string;
/** The specific credential/plate value read — the per-car session key. */
readonly carKey: string;
readonly via: "card" | "qr" | "plate";
}
export class PermitFlow {
readonly #db: Db;
readonly #log: EventLog;
readonly #logger: FastifyBaseLogger;
readonly #inFlight = new Set<string>();
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
this.#db = db;
this.#log = log;
this.#logger = logger;
}
/** Resolve a read to a permit (by card/QR credential, or by a bound plate), or null. */
match(e: DeviceReadEvent): PermitMatch | null {
// Card / QR / generic credential value.
const cred = this.#db
.select()
.from(permitCredentials)
.where(eq(permitCredentials.value, e.value))
.get();
if (cred) {
return { permitId: cred.permitId, carKey: e.value, via: cred.kind === "qr" ? "qr" : "card" };
}
// Plate binding: a read plate that matches a permit's bound plate is an identity.
if (e.kind === "plate") {
const plate = this.#db.select().from(permitPlates).where(eq(permitPlates.plate, e.value)).get();
if (plate) return { permitId: plate.permitId, carKey: e.value, via: "plate" };
}
return null;
}
/** Run the permit entry/exit for a matched read at a barrier. `resolved` is the
* reader's bound relay; its direction constrains, "both" defers to session state. */
async run(resolved: ResolvedRelay, e: DeviceReadEvent, m: PermitMatch): Promise<ReadOutcome> {
const key = `${m.permitId}:${m.carKey}`;
if (this.#inFlight.has(key)) return { accepted: false, reason: "duplicate read in flight" };
this.#inFlight.add(key);
try {
return await this.#run(resolved, e, m);
} catch (err) {
this.#logger.error(`permit-flow failed: ${(err as Error).message}`);
return { accepted: false, reason: (err as Error).message };
} finally {
this.#inFlight.delete(key);
}
}
async #run(resolved: ResolvedRelay, e: DeviceReadEvent, m: PermitMatch): Promise<ReadOutcome> {
const permit = this.#db.select().from(permits).where(eq(permits.id, m.permitId)).get();
if (!permit) return { accepted: false, reason: "permit not found" };
// Validity: active + within the coverage window.
const now = new Date().toISOString();
const invalid =
permit.status !== "active" ||
(permit.validFrom != null && now < permit.validFrom) ||
(permit.validTo != null && now > permit.validTo);
if (invalid) {
const reason = `permit ${permit.status}/out-of-window`;
await this.#reject(m, reason);
return { accepted: false, reason };
}
// Direction: the car's open-session state is the natural verb (in→exit, out→entry).
// The barrier the car is at (resolved.direction) must AGREE — a car at an exit
// barrier that isn't inside (or at an entry barrier while already in) is a
// wrong-barrier / anti-passback signal, refused + logged. A "both" barrier follows
// the session state.
const carOpen = this.#carHasOpenSession(m.carKey);
const inferred: FlowDirection = carOpen ? "exit" : "entry";
if (resolved.direction !== "both" && resolved.direction !== inferred) {
const reason = `permit wrong barrier — ${resolved.direction} barrier but car would ${inferred}`;
await this.#reject(m, reason);
return { accepted: false, direction: resolved.direction === "exit" ? "exit" : "entry", reason };
}
if (carOpen) {
// EXIT: this car is already inside → the read is its exit.
await this.#log.append({
type: "vehicle_exit",
direction: "exit",
source: m.via === "plate" ? "lpr" : m.via === "qr" ? "qr" : "wiegand",
identity: m.carKey,
payload: { sessionRef: m.carKey, permitId: m.permitId },
});
await this.#open(resolved, "exit", m.carKey, "permit exit");
this.#closeCache(m.carKey);
return { accepted: true, direction: "exit" };
}
// ENTRY: enforce the car-count binding (maxConcurrent), then sign + open.
if (permit.maxConcurrent != null) {
const open = this.#permitOpenCount(m.permitId);
if (open >= permit.maxConcurrent) {
const reason = `permit at capacity (${open}/${permit.maxConcurrent} cars in)`;
await this.#reject(m, reason);
return { accepted: false, direction: "entry", reason };
}
}
await this.#log.append({
type: "vehicle_entry",
direction: "entry",
source: m.via === "plate" ? "lpr" : m.via === "qr" ? "qr" : "wiegand",
identity: m.carKey,
// No ticket, no fee — the permit IS the authorization. Recorded for audit.
payload: { sessionRef: m.carKey, permitId: m.permitId, permit: true },
occurredAt: now,
});
await this.#open(resolved, "entry", m.carKey, "permit entry");
try {
this.#db
.insert(sessions)
.values({ id: m.carKey, identity: m.carKey, source: m.via === "plate" ? "lpr" : "wiegand", permitId: m.permitId, enteredAt: now, state: "open" })
.run();
} catch (err) {
this.#logger.error(`session-cache insert failed for ${m.carKey}: ${(err as Error).message}`);
}
return { accepted: true, direction: "entry" };
}
/** Does this specific car (credential value) have an open session right now? */
#carHasOpenSession(carKey: string): boolean {
const rows = this.#db
.select()
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, carKey))
.orderBy(ledgerEvents.index)
.all();
const entries = rows.filter((r) => r.type === "vehicle_entry").length;
const exits = rows.filter((r) => r.type === "vehicle_exit").length;
return entries > exits;
}
/** How many of this permit's cars are inside right now (fold over the ledger). */
#permitOpenCount(permitId: string): number {
const rows = this.#db
.select()
.from(ledgerEvents)
.where(eq(ledgerEvents.type, "vehicle_entry"))
.all()
.filter((r) => (r.payload as { permitId?: string } | null)?.permitId === permitId);
let open = 0;
for (const entry of rows) {
if (!this.#carHasOpenSession(entry.identity ?? "")) continue;
open += 1;
}
return open;
}
async #reject(m: PermitMatch, reason: string): Promise<void> {
await this.#log.append({
type: "anomaly",
identity: m.carKey,
payload: { reason: `permit refused — ${reason}`, permitId: m.permitId, permitRefused: true },
});
this.#logger.warn(`permit refused (${m.carKey}): ${reason}`);
}
async #open(resolved: ResolvedRelay, dir: FlowDirection, carKey: string, what: string): Promise<void> {
const access = this.#buildAccess(resolved.controller);
if (access) await access.pulseOpen(resolved.relay);
else this.#logger.warn(`${what} signed for ${carKey} but the ${dir} relay won't build`);
// SNAPSHOT — fire the directional camera(s), never awaited (evidence, not a gate).
void snapshotAsync({
db: this.#db,
direction: dir,
identity: carKey,
logger: this.#logger,
}).catch((err) => this.#logger.error(`permit snapshot error: ${(err as Error).message}`));
}
#closeCache(carKey: string): void {
try {
this.#db.update(sessions).set({ exitedAt: new Date().toISOString(), state: "closed" }).where(eq(sessions.id, carKey)).run();
} catch (err) {
this.#logger.error(`session-cache close failed for ${carKey}: ${(err as Error).message}`);
}
}
/** Build a live access adapter from a resolved controller row, or null. */
#buildAccess(row: DeviceRow): AccessControlDevice | null {
const driver = registry.get(row.driverId);
if (!driver) return null;
try {
return driver.create(row.config as never) as AccessControlDevice;
} catch {
return null;
}
}
}
+14 -14
View File
@@ -2,32 +2,32 @@ import { devices, eq, type Db } from "@parking/db";
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js"; import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
import type { ExitFlow } from "./exit-flow.js"; import type { ExitFlow } from "./exit-flow.js";
import type { PermitFlow } from "./permit-flow.js"; import type { SubscriptionFlow } from "./subscription-flow.js";
import { relayForDevice } from "./device-resolve.js"; import { relayForDevice } from "./device-resolve.js";
// Routes a credential read (ticket scan / plate / card) to the right flow. A read // Routes a credential read (ticket scan / plate / card) to the right flow. A read
// can mean a permit entry/exit OR a transient exit, so we dispatch by WHAT the // can mean a subscription entry/exit OR a transient exit, so we dispatch by WHAT the
// credential is (decision 2026-06-15): // credential is (decision 2026-06-15):
// - matches a permit (card/QR/bound plate) → PERMIT flow, // - matches a subscription (card/QR/bound plate) → SUBSCRIPTION flow,
// - else → transient EXIT flow (open ticket session → exit, else reject+log). // - else → transient EXIT flow (open ticket session → exit, else reject+log).
// //
// The reader is BOUND to a controller relay (config.controllerId + relay), so a read // The reader is BOUND to a controller relay (config.controllerId + relay), so a read
// resolves to exactly the barrier it sits at, and the direction is inherited from // resolves to exactly the barrier it sits at, and the direction is inherited from
// that relay (see entry-exit-points.md). The resolved relay is handed to the flow so // that relay (see entry-exit-points.md). The resolved relay is handed to the flow so
// it opens that exact barrier. An "entry" reader drives the entry side, an "exit" // it opens that exact barrier. An "entry" reader drives the entry side, an "exit"
// reader the exit side; "both" defers to the flow's own inference (permit: session // reader the exit side; "both" defers to the flow's own inference (subscription:
// state; transient: exit). // session state; transient: exit).
export class ReadDispatcher { export class ReadDispatcher {
readonly #db: Db; readonly #db: Db;
readonly #exit: ExitFlow; readonly #exit: ExitFlow;
readonly #permit: PermitFlow; readonly #subscription: SubscriptionFlow;
readonly #logger: FastifyBaseLogger; readonly #logger: FastifyBaseLogger;
constructor(db: Db, exit: ExitFlow, permit: PermitFlow, logger: FastifyBaseLogger) { constructor(db: Db, exit: ExitFlow, subscription: SubscriptionFlow, logger: FastifyBaseLogger) {
this.#db = db; this.#db = db;
this.#exit = exit; this.#exit = exit;
this.#permit = permit; this.#subscription = subscription;
this.#logger = logger; this.#logger = logger;
} }
@@ -41,13 +41,13 @@ export class ReadDispatcher {
return { accepted: false, reason: "reader not bound to a barrier (no relay to open)" }; return { accepted: false, reason: "reader not bound to a barrier (no relay to open)" };
} }
const permit = this.#permit.match(e); const sub = this.#subscription.match(e);
if (permit) { if (sub) {
return this.#permit.run(resolved, e, permit); return this.#subscription.run(resolved, e, sub);
} }
// Not a permit → transient ticket exit. An ENTRY reader can't produce a transient // Not a subscription → transient ticket exit. An ENTRY reader can't produce a
// exit (transient entry is the button flow, not a reader), so reject+log rather // transient exit (transient entry is the button flow, not a reader), so reject+log
// than treat an entry scan as an exit. // rather than treat an entry scan as an exit.
if (resolved.direction === "entry") { if (resolved.direction === "entry") {
return { accepted: false, direction: "entry", reason: "entry reader: no transient entry via reader" }; return { accepted: false, direction: "entry", reason: "entry reader: no transient entry via reader" };
} }
+28 -3
View File
@@ -16,6 +16,12 @@ interface LoginBody {
password: string; password: string;
} }
const LANGS = ["sq", "en"] as const;
type Lang = (typeof LANGS)[number];
interface LanguageBody {
language: Lang;
}
export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> { export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
app.post<{ Body: LoginBody }>("/api/auth/login", async (req, reply) => { app.post<{ Body: LoginBody }>("/api/auth/login", async (req, reply) => {
const { username, password } = req.body ?? {}; const { username, password } = req.body ?? {};
@@ -41,7 +47,9 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
csrf, csrf,
}); });
setAuthCookies(reply, token, csrf); setAuthCookies(reply, token, csrf);
return { id: user.id, username: user.username, role: user.role }; // `language` is NOT in the JWT (identity/role only) — it's a mutable preference
// read from the DB, so changing it needs no token refresh.
return { id: user.id, username: user.username, role: user.role, language: user.language };
}); });
app.post("/api/auth/logout", async (_req, reply) => { app.post("/api/auth/logout", async (_req, reply) => {
@@ -49,13 +57,30 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
return { ok: true }; return { ok: true };
}); });
// Who am I — used by the SPA to bootstrap session state on load. // Who am I — used by the SPA to bootstrap session state on load. Reads the live
// `language` preference from the DB (not the token).
app.get( app.get(
"/api/auth/me", "/api/auth/me",
{ preHandler: requireRole("admin", "operator", "cashier", "readonly") }, { preHandler: requireRole("admin", "operator", "cashier", "readonly") },
async (req) => { async (req) => {
const { sub, username, role } = req.user; const { sub, username, role } = req.user;
return { id: sub, username, role }; const row = await db.select().from(users).where(eq(users.id, sub)).get();
return { id: sub, username, role, language: row?.language ?? "sq" };
},
);
// Change MY own UI language preference (any signed-in user). Persisted to the
// users row so it's restored on the next login, from any booth. See i18n.md.
app.put<{ Body: LanguageBody }>(
"/api/auth/language",
{ preHandler: requireRole("admin", "operator", "cashier", "readonly") },
async (req, reply) => {
const language = req.body?.language;
if (!language || !LANGS.includes(language)) {
return reply.code(400).send({ error: `language must be one of: ${LANGS.join(", ")}` });
}
await db.update(users).set({ language }).where(eq(users.id, req.user.sub)).run();
return { language };
}, },
); );
} }
+21
View File
@@ -0,0 +1,21 @@
import type { FastifyInstance } from "fastify";
import { requireRole } from "../auth.js";
import type { DeviceMonitor } from "../device-monitor.js";
// Unified device-status snapshot for the booth footer. The DeviceMonitor polls all
// configured devices (relays/readers/cameras via healthCheck, printers via their
// rich readStatus) in the background; this exposes its cache. Live updates ride the
// booth WebSocket (kind:"device-status") — this REST route is the initial load /
// fallback. Any authenticated role may read (operational, not a setup action).
// See wiki/concepts/device-status-monitoring.md, booth-console.md.
export async function deviceStatusRoutes(
app: FastifyInstance,
monitor: DeviceMonitor,
): Promise<void> {
const guard = requireRole("admin", "operator", "cashier", "readonly");
app.get("/api/devices/status", { preHandler: guard }, async () => ({
devices: monitor.snapshot(),
}));
}
+13 -3
View File
@@ -1,5 +1,5 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { desc, ledgerEvents, type Db } from "@parking/db"; import { desc, gte, ledgerEvents, type Db } from "@parking/db";
import { requireRole } from "../auth.js"; import { requireRole } from "../auth.js";
import type { EventLog } from "../event-log.js"; import type { EventLog } from "../event-log.js";
@@ -17,12 +17,22 @@ export async function eventRoutes(
const guard = requireRole("admin", "operator", "cashier", "readonly"); const guard = requireRole("admin", "operator", "cashier", "readonly");
// Recent events, newest first. `limit` caps the page (default 100, max 1000). // Recent events, newest first. `limit` caps the page (default 100, max 1000).
app.get<{ Querystring: { limit?: string } }>( // Optional `since` (ISO) scopes the page to events at/after that instant — the
// booth passes the current shift's start so the live feed shows ONLY this shift's
// activity (logs are per-shift, not all history). See wiki/concepts/shift.md.
app.get<{ Querystring: { limit?: string; since?: string } }>(
"/api/events", "/api/events",
{ preHandler: guard }, { preHandler: guard },
async (req) => { async (req) => {
const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000); const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000);
const rows = db.select().from(ledgerEvents).orderBy(desc(ledgerEvents.index)).limit(limit).all(); const since = (req.query.since ?? "").trim();
const rows = db
.select()
.from(ledgerEvents)
.where(since ? gte(ledgerEvents.occurredAt, since) : undefined)
.orderBy(desc(ledgerEvents.index))
.limit(limit)
.all();
return { events: rows }; return { events: rows };
}, },
); );
+161 -7
View File
@@ -1,15 +1,22 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import type { Db } from "@parking/db";
import { NoPrinterAvailableError } from "@parking/devices";
import { requireRole } from "../auth.js"; import { requireRole } from "../auth.js";
import { import {
NoOpenSessionError, NoOpenSessionError,
NoTariffError, NoTariffError,
type PayStation, type PayStation,
} from "../pay-station.js"; } from "../pay-station.js";
import type { ExitFlow } from "../exit-flow.js";
import { NoShiftOpenError, type ShiftService } from "../shift-service.js";
import { printPaymentReceipt } from "../booth-print.js";
// Pay-station endpoints (pay-on-foot). The terminal/operator UI quotes a session // Booth endpoints (pay-on-foot): look up a session, quote it, take payment, and —
// then takes payment; the payment becomes a signed ledger event. PCI scope stays // when the booth is at/near the exit — open the barrier. The payment becomes a
// OUT of the app — actual card capture is a standalone P2PE terminal; here `tender` // signed ledger event; PCI scope stays OUT of the app (card capture is a standalone
// just records cash vs. card. See wiki/concepts/tariff.md, parking-session.md, bom.md. // P2PE terminal; `tender` just records cash vs. card). The booth exit reuses the
// SAME validation as the reader path — no booth-only bypass admits an unpaid car.
// See wiki/concepts/tariff.md, parking-session.md, booth-exit-flow.md, bom.md.
interface QuoteQuery { interface QuoteQuery {
identity: string; identity: string;
@@ -20,11 +27,100 @@ interface PayBody {
/** Operator-set amount (lost ticket / dispute) — overrides the computed fee. */ /** Operator-set amount (lost ticket / dispute) — overrides the computed fee. */
overrideMinor?: number; overrideMinor?: number;
} }
interface ExitBody {
identity: string;
}
interface VoucherBody {
identity: string;
}
interface ReceiptBody {
identity: string;
}
export async function payRoutes(app: FastifyInstance, payStation: PayStation): Promise<void> { export async function payRoutes(
// Cashier/operator/admin operate the pay station; readonly may not. app: FastifyInstance,
db: Db,
payStation: PayStation,
exitFlow: ExitFlow,
shift: ShiftService,
): Promise<void> {
// Cashier/operator/admin operate the booth; readonly may not.
const guard = requireRole("admin", "operator", "cashier"); const guard = requireRole("admin", "operator", "cashier");
// Money-path gate: a shift must be open site-wide before any payment/exit/voucher/
// re-open is processed, so every taking is attributed to a shift (one operator's
// accountability period). Read-only lookups (session/active/quote) stay ungated so
// the modal can still DISPLAY the session and prompt the operator to open a shift.
// Returns 409 { error, code: "no_shift" } so the UI can show the "open a shift"
// prompt rather than a generic failure. See wiki/concepts/shift.md.
const requireShift = async (
_req: import("fastify").FastifyRequest,
reply: import("fastify").FastifyReply,
) => {
try {
shift.requireOpenShift();
} catch (err) {
if (err instanceof NoShiftOpenError) {
return reply.code(409).send({ error: err.message, code: "no_shift" });
}
throw err;
}
};
// Active sessions for the booth list: still-open OR exited-but-within-grace
// (barrier unconfirmed → a paid/exited car is presumed possibly-present until
// grace expires). Read-only. See wiki/concepts/booth-exit-flow.md.
app.get("/api/sessions/active", { preHandler: guard }, async () => ({
sessions: payStation.activeSessions(),
}));
// Session lookup for the booth pay/exit modal: entry/exit times, paid state,
// amount owed now, walk-back-grace status. Read-only (no side effect).
app.get<{ Params: { identity: string } }>(
"/api/session/:identity",
{ preHandler: guard },
async (req, reply) => {
const identity = (req.params.identity ?? "").trim();
if (!identity) return reply.code(400).send({ error: "identity required" });
return payStation.lookup(identity);
},
);
// Booth-driven exit: validate (paid + grace, or free entry-grace) THEN sign
// vehicle_exit + open the barrier. Maps the discriminated result to HTTP:
// - validation reject → 409 with a reason (operator takes payment first),
// - exit signed but barrier didn't open → 200 { opened:false } (payment stands;
// operator opens manually; an anomaly is already signed),
// - clean exit → 200 { opened:true }.
app.post<{ Body: ExitBody }>(
"/api/exit",
{ preHandler: [guard, requireShift] },
async (req, reply) => {
const identity = (req.body?.identity ?? "").trim();
if (!identity) return reply.code(400).send({ error: "identity required" });
const res = await exitFlow.exitForBooth(identity);
if (!res.ok) return reply.code(409).send({ error: res.reason, status: res.status });
return reply.code(200).send(res);
},
);
// Human-intervention barrier re-open for an ACTIVE (paid) session — damaged
// ticket / dead scanner / phantom re-close. Re-pulses the exit relay + signs an
// anomaly (attributed); NEVER a second vehicle_exit. Refused without a payment
// (no-unpaid-bypass). See wiki/concepts/booth-exit-flow.md.
app.post<{ Body: ExitBody }>(
"/api/barrier/reopen",
{ preHandler: [guard, requireShift] },
async (req, reply) => {
const identity = (req.body?.identity ?? "").trim();
if (!identity) return reply.code(400).send({ error: "identity required" });
const operator = req.user?.username;
const res = await exitFlow.reopenBarrier(identity, operator);
if (!res.ok) return reply.code(409).send({ error: res.reason });
return reply.code(200).send(res);
},
);
// Quote: what does this session owe right now? (No side effect.) // Quote: what does this session owe right now? (No side effect.)
app.get<{ Querystring: QuoteQuery }>( app.get<{ Querystring: QuoteQuery }>(
"/api/pay/quote", "/api/pay/quote",
@@ -43,7 +139,7 @@ export async function payRoutes(app: FastifyInstance, payStation: PayStation): P
// Pay: take payment and append the signed `payment` event. // Pay: take payment and append the signed `payment` event.
app.post<{ Body: PayBody }>( app.post<{ Body: PayBody }>(
"/api/pay", "/api/pay",
{ preHandler: guard }, { preHandler: [guard, requireShift] },
async (req, reply) => { async (req, reply) => {
const { identity, tender, overrideMinor } = req.body ?? {}; const { identity, tender, overrideMinor } = req.body ?? {};
if (!identity || (tender !== "cash" && tender !== "card")) { if (!identity || (tender !== "cash" && tender !== "card")) {
@@ -60,6 +156,64 @@ export async function payRoutes(app: FastifyInstance, payStation: PayStation): P
} }
}, },
); );
// Print an exit voucher (the paid ticket id reprinted as a barcode) on the booth
// printer. Used when the booth is far from the exit — the customer self-scans the
// voucher at the exit reader, which runs the normal validated exit. Requires the
// session to be PAID (no free vouchers for unpaid sessions). See booth-exit-flow.md.
app.post<{ Body: VoucherBody }>(
"/api/voucher",
{ preHandler: [guard, requireShift] },
async (req, reply) => {
const identity = (req.body?.identity ?? "").trim();
if (!identity) return reply.code(400).send({ error: "identity required" });
const view = payStation.lookup(identity);
if (!view.found || !view.open) {
return reply.code(404).send({ error: "no open session for ticket" });
}
if (view.paidAt == null) {
return reply.code(409).send({ error: "session not paid — take payment before printing a voucher" });
}
try {
const printedBy = await printPaymentReceipt(db, identity, { voucher: true }, app.log);
return reply.code(200).send({ ok: true, printedBy });
} catch (err) {
if (err instanceof NoPrinterAvailableError) {
return reply.code(503).send({ error: err.message });
}
return reply.code(500).send({ error: (err as Error).message });
}
},
);
// Print a standalone PAYMENT RECEIPT (transparency: entry/paid/duration/amount,
// no barcode) on the booth printer. Used (a) auto, right after a payment when no
// voucher is issued, and (b) on-demand "reprint" if the slip jammed. Requires the
// session to be PAID. See wiki/concepts/booth-exit-flow.md.
app.post<{ Body: ReceiptBody }>(
"/api/receipt",
{ preHandler: [guard, requireShift] },
async (req, reply) => {
const identity = (req.body?.identity ?? "").trim();
if (!identity) return reply.code(400).send({ error: "identity required" });
const view = payStation.lookup(identity);
if (!view.found) {
return reply.code(404).send({ error: "no session for ticket" });
}
if (view.paidAt == null) {
return reply.code(409).send({ error: "session not paid — nothing to receipt" });
}
try {
const printedBy = await printPaymentReceipt(db, identity, { voucher: false }, app.log);
return reply.code(200).send({ ok: true, printedBy });
} catch (err) {
if (err instanceof NoPrinterAvailableError) {
return reply.code(503).send({ error: err.message });
}
return reply.code(500).send({ error: (err as Error).message });
}
},
);
} }
function mapError(reply: import("fastify").FastifyReply, err: unknown) { function mapError(reply: import("fastify").FastifyReply, err: unknown) {
-160
View File
@@ -1,160 +0,0 @@
import { randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify";
import { eq, permitCredentials, permitPlates, permits, type Db } from "@parking/db";
import { requireRole } from "../auth.js";
// Permit (subscription) admin CRUD. A permit is mutable master data — admins
// grant/edit/revoke — but every USE of it is a signed ledger event, so the audit
// trail stays append-only (see wiki/entities/permit.md). A permit is an aggregate:
// the permit row + its credentials (card/QR) + its bound plates. The API treats them
// as one unit (create/update replace the child sets; delete removes all).
interface Credential {
kind: "rf" | "qr";
value: string;
}
interface PermitBody {
holderName?: string;
contact?: string;
/** Car-count binding: cars inside at once. Default 1; null = unbound. */
maxConcurrent?: number | null;
validFrom?: string | null;
validTo?: string | null;
status?: "active" | "suspended" | "revoked";
credentials?: Credential[];
/** Plate binding (optional): bound plates that also serve as identity. */
plates?: string[];
}
export async function permitRoutes(app: FastifyInstance, db: Db): Promise<void> {
// Admin manages permits; operator/cashier/readonly may LIST (to look one up).
const readGuard = requireRole("admin", "operator", "cashier", "readonly");
const writeGuard = requireRole("admin");
// Validate the body; returns problems (empty = ok). Shared by create + update.
function validate(b: PermitBody): string[] {
const errs: string[] = [];
if (b.maxConcurrent != null) {
if (!Number.isInteger(b.maxConcurrent) || b.maxConcurrent < 1) {
errs.push("maxConcurrent must be a positive integer, or null for unbound");
}
}
if (b.status && !["active", "suspended", "revoked"].includes(b.status)) {
errs.push("status must be active|suspended|revoked");
}
for (const c of b.credentials ?? []) {
if ((c.kind !== "rf" && c.kind !== "qr") || !c.value?.trim()) {
errs.push("each credential needs kind (rf|qr) and a non-empty value");
break;
}
}
if ((b.credentials?.length ?? 0) === 0 && (b.plates?.length ?? 0) === 0) {
errs.push("a permit needs at least one credential or one bound plate (else nothing identifies it)");
}
return errs;
}
function loadAggregate(id: string) {
const permit = db.select().from(permits).where(eq(permits.id, id)).get();
if (!permit) return null;
const credentials = db.select().from(permitCredentials).where(eq(permitCredentials.permitId, id)).all();
const plates = db.select().from(permitPlates).where(eq(permitPlates.permitId, id)).all();
return {
...permit,
credentials: credentials.map((c) => ({ kind: c.kind, value: c.value })),
plates: plates.map((p) => p.plate),
};
}
// Replace a permit's child rows (credentials + plates) from the body.
function writeChildren(id: string, b: PermitBody) {
db.delete(permitCredentials).where(eq(permitCredentials.permitId, id)).run();
db.delete(permitPlates).where(eq(permitPlates.permitId, id)).run();
for (const c of b.credentials ?? []) {
db.insert(permitCredentials).values({ id: randomUUID(), permitId: id, kind: c.kind, value: c.value.trim() }).run();
}
for (const p of b.plates ?? []) {
if (p.trim()) db.insert(permitPlates).values({ id: randomUUID(), permitId: id, plate: p.trim() }).run();
}
}
// List all permits (with their credentials + plates).
app.get("/api/permits", { preHandler: readGuard }, async () => {
const rows = db.select().from(permits).all();
return { permits: rows.map((r) => loadAggregate(r.id)) };
});
// Create a permit.
app.post<{ Body: PermitBody }>("/api/permits", { preHandler: writeGuard }, async (req, reply) => {
const b = req.body ?? {};
const problems = validate(b);
if (problems.length) return reply.code(400).send({ error: "invalid permit", problems });
const id = randomUUID();
db.insert(permits)
.values({
id,
holderName: b.holderName ?? null,
contact: b.contact ?? null,
maxConcurrent: b.maxConcurrent === undefined ? 1 : b.maxConcurrent,
validFrom: b.validFrom ?? null,
validTo: b.validTo ?? null,
status: b.status ?? "active",
})
.run();
writeChildren(id, b);
return reply.code(201).send(loadAggregate(id));
});
// Update a permit (replaces fields + child sets).
app.put<{ Params: { id: string }; Body: PermitBody }>(
"/api/permits/:id",
{ preHandler: writeGuard },
async (req, reply) => {
const existing = db.select().from(permits).where(eq(permits.id, req.params.id)).get();
if (!existing) return reply.code(404).send({ error: "permit not found" });
const b = req.body ?? {};
const problems = validate(b);
if (problems.length) return reply.code(400).send({ error: "invalid permit", problems });
db.update(permits)
.set({
holderName: b.holderName ?? null,
contact: b.contact ?? null,
maxConcurrent: b.maxConcurrent === undefined ? existing.maxConcurrent : b.maxConcurrent,
validFrom: b.validFrom ?? null,
validTo: b.validTo ?? null,
status: b.status ?? existing.status,
})
.where(eq(permits.id, req.params.id))
.run();
writeChildren(req.params.id, b);
return loadAggregate(req.params.id);
},
);
// Revoke (soft): the common case — keeps the permit + its history, just bars it.
// A revoked permit fails the entry check (see permit-flow.ts). Use DELETE only to
// fully remove a permit created in error.
app.post<{ Params: { id: string } }>(
"/api/permits/:id/revoke",
{ preHandler: writeGuard },
async (req, reply) => {
const r = db.update(permits).set({ status: "revoked" }).where(eq(permits.id, req.params.id)).run();
if (r.changes === 0) return reply.code(404).send({ error: "permit not found" });
return loadAggregate(req.params.id);
},
);
// Hard delete a permit + its child rows. (Past ledger events that reference it
// are untouched — the audit trail is append-only and independent of this row.)
app.delete<{ Params: { id: string } }>(
"/api/permits/:id",
{ preHandler: writeGuard },
async (req, reply) => {
const r = db.delete(permits).where(eq(permits.id, req.params.id)).run();
if (r.changes === 0) return reply.code(404).send({ error: "permit not found" });
db.delete(permitCredentials).where(eq(permitCredentials.permitId, req.params.id)).run();
db.delete(permitPlates).where(eq(permitPlates.permitId, req.params.id)).run();
return reply.code(204).send();
},
);
}
+21 -2
View File
@@ -2,6 +2,7 @@ import type { FastifyInstance } from "fastify";
import { eq, devices, type Db } from "@parking/db"; import { eq, devices, type Db } from "@parking/db";
import type { DeviceReadEvent } from "../device-events.js"; import type { DeviceReadEvent } from "../device-events.js";
import type { ReadDispatcher } from "../read-dispatch.js"; import type { ReadDispatcher } from "../read-dispatch.js";
import type { CredentialCapture } from "../credential-capture.js";
// GEE/Dingtian QR reader endpoint. The reader is configured (vendor tool) with our // GEE/Dingtian QR reader endpoint. The reader is configured (vendor tool) with our
// host as its "server"; on each scan it sends an HTTP GET and BEEPS/acts based on // host as its "server"; on each scan it sends an HTTP GET and BEEPS/acts based on
@@ -30,6 +31,7 @@ export async function qrReaderRoutes(
app: FastifyInstance, app: FastifyInstance,
db: Db, db: Db,
dispatcher: ReadDispatcher, dispatcher: ReadDispatcher,
capture: CredentialCapture,
): Promise<void> { ): Promise<void> {
// Resolve the lane_devices row whose config.serial matches the reader's reported // Resolve the lane_devices row whose config.serial matches the reader's reported
// serial (cjihao). The row id is a normal UUID; the serial is config the admin // serial (cjihao). The row id is a normal UUID; the serial is config the admin
@@ -59,10 +61,19 @@ export async function qrReaderRoutes(
// Map the reader's serial → its assigned lane_devices row id (the dispatcher // Map the reader's serial → its assigned lane_devices row id (the dispatcher
// resolves the lane from that row). If unassigned, deviceId stays the serial so // resolves the lane from that row). If unassigned, deviceId stays the serial so
// the dispatcher simply finds no lane and rejects (status:0) — never crashes. // the dispatcher simply finds no lane and rejects (status:0) — never crashes.
const deviceId = readerRowIdForSerial(serial) ?? serial; const matchedRowId = readerRowIdForSerial(serial);
const deviceId = matchedRowId ?? serial;
let accepted = false; let accepted = false;
if (cardid) { if (cardid) {
// ENROLLMENT INTERCEPT: if THIS reader is armed for credential capture, grab the
// value for the subscription form and do NOT run the access flow (we must not
// open a barrier for a card being enrolled). Single-shot — capture auto-disarms.
// Reads from the OTHER reader are untouched and dispatch normally below.
if (capture.tryConsume(deviceId, cardid)) {
app.log.info(`CAPTURE serial=${serial || "?"} device=${matchedRowId ? matchedRowId.slice(0, 8) : "?"} value=${cardid}`);
accepted = true; // beep "ok" so the operator knows the card was read
} else {
const read: DeviceReadEvent = { const read: DeviceReadEvent = {
driverId: "gee-qr-reader", driverId: "gee-qr-reader",
deviceId, deviceId,
@@ -73,10 +84,18 @@ export async function qrReaderRoutes(
try { try {
const outcome = await dispatcher.dispatch(read); const outcome = await dispatcher.dispatch(read);
accepted = outcome.accepted; accepted = outcome.accepted;
if (!accepted) app.log.info(`QR ${cardid} rejected: ${outcome.reason ?? "?"}`); // Per-read diagnostic: which reader (serial) sent it, which configured device
// it mapped to, and the verdict — so a barrier/serial mismatch is visible in
// the logs (e.g. an entry-side scan resolving to the exit relay).
app.log.info(
`READ serial=${serial || "?"} → device=${matchedRowId ? matchedRowId.slice(0, 8) : "UNASSIGNED"} ` +
`card=${cardid} verdict=${accepted ? "ACCEPT" : "REJECT"}${outcome.direction ? ` dir=${outcome.direction}` : ""}` +
`${accepted ? "" : ` reason="${outcome.reason ?? "?"}"`}`,
);
} catch (err) { } catch (err) {
app.log.error(`QR dispatch failed for ${cardid}: ${(err as Error).message}`); app.log.error(`QR dispatch failed for ${cardid}: ${(err as Error).message}`);
} }
}
} }
// Reply the SDK verdict. status 1 → beep 2× (valid) / 0 → beep 1× (invalid). // Reply the SDK verdict. status 1 → beep 2× (valid) / 0 → beep 1× (invalid).
+172 -82
View File
@@ -51,6 +51,127 @@ function redactSecrets(config: Record<string, unknown>): Record<string, unknown>
return out; return out;
} }
/** Result of the device configure pipeline: a ready-to-persist config, or an
* HTTP error to send back. Shared by assign (create) and patch (edit). */
type ConfigureOutcome =
| { config: Record<string, unknown>; warnings: string[] }
| { error: { code: number; message: string } };
/**
* Validate + configure a device, returning the config to persist. Runs the same
* pipeline for both create and edit: validate the driver config, fix
* preconditions, harden (relay password + protocol lockdown), and set up input
* push (Digest creds + push URLs). Each step is a device write (the device
* reboots on apply). The caller owns the DB row; this never touches the DB.
*
* `id` is the assignment id (stable across an edit) — it's baked into the push
* URL, so editing in place keeps the device pushing to the same path.
* `existingConfig` carries forward secrets the client never sees on edit
* (push/relay passwords), so a PATCH that omits them doesn't wipe them.
*/
async function configureDevice(
app: FastifyInstance,
args: {
id: string;
driverId: string;
config: DeviceConfig;
backendIp?: string;
existingConfig?: Record<string, unknown>;
},
): Promise<ConfigureOutcome> {
const { id, driverId, config, backendIp, existingConfig } = args;
// Start from any machine-only secrets already on the row (push/relay passwords
// are redacted out of the client's copy, so an edit would otherwise drop them),
// then layer the submitted config on top.
const fullConfig: Record<string, unknown> = { ...existingConfig, ...config };
// The web password the admin typed is a DESIRED value, not a stored fact:
// it's passed to the driver (via create(config) below) as the rotation
// target, but we do NOT persist it from the form. Only harden()'s VERIFIED
// secrets.webPassword gets saved — otherwise a failed rotation would leave
// the DB claiming a password the device never accepted (login stays old).
delete fullConfig.webPassword;
// webPasswordCurrent is an input-only credential (the OLD password used to
// authorize the change) — never persist it as typed.
delete fullConfig.webPasswordCurrent;
// Residual-risk warnings from device hardening (shown to the admin; the
// save still succeeds — these are "configured, but note X" advisories).
const hardenWarnings: string[] = [];
let device;
try {
device = registry.create(driverId, config); // validates required fields
} catch (err) {
return { error: { code: 400, message: (err as Error).message } };
}
// Configure the device on save (before persisting, so we don't store a row
// for a device we couldn't configure):
// 1. fix preconditions (e.g. disable input_link_relay so a button press
// doesn't auto-fire its relay — host must decide first),
// 2. harden (relay password + disable unused protocol channels), and
// 3. set up input push (Digest creds + push URLs).
// Each step is a device config write (the device reboots on apply).
try {
if (hasPreconditions(device)) {
const fixed = await device.fixPreconditions();
if (!fixed.ok) {
const unfixable = fixed.issues.find((i) => !i.fixable);
return {
error: {
code: 502,
message: `device precondition not satisfied: ${unfixable?.message ?? fixed.issues[0]?.message}`,
},
};
}
}
if (isHardenable(device)) {
const { secrets, warnings } = await device.harden();
Object.assign(fullConfig, secrets); // e.g. relayPassword
// Surface residual-risk warnings (e.g. firmware that won't disable the
// password-less string protocol) so the admin can act (web-UI step).
for (const w of warnings ?? []) {
app.log.warn(`harden(${driverId} ${id}): ${w}`);
hardenWarnings.push(w);
}
}
if (hasPushConfig(device)) {
const host = String(config.host ?? "");
// Admin-provided backend IP wins; else auto-derive (on-subnet NIC).
const pushHost = backendIp ?? backendIpForDevice(host);
if (!pushHost) {
return {
error: {
code: 400,
message: `cannot determine the backend IP on the device's subnet (${host}). Pick one in setup or set BACKEND_HOST_IP.`,
},
};
}
const pushUser = "dingtian";
// 24 hex chars = 96 bits. The Dingtian `pass` field caps at 31 chars
// (longer is silently truncated → auth mismatch), so keep it short.
const pushPassword = randomBytes(12).toString("hex");
await device.configureInputPush({
host: pushHost,
port: backendPort(),
pathBase: `/api/devices/${driverId}/${id}/input`,
auth: { user: pushUser, password: pushPassword },
});
fullConfig.pushUser = pushUser;
fullConfig.pushPassword = pushPassword;
// Record the backend IP the device was told to push to — lets us detect
// a later mismatch if the host's IP changes.
fullConfig.backendIp = pushHost;
}
} catch (err) {
return { error: { code: 502, message: `device configuration failed: ${(err as Error).message}` } };
}
return { config: fullConfig, warnings: hardenWarnings };
}
export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> { export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
registerBuiltinDrivers(); registerBuiltinDrivers();
setDeviceLogSink((line) => app.log.info(line)); setDeviceLogSink((line) => app.log.info(line));
@@ -166,100 +287,69 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
} }
const id = randomUUID(); const id = randomUUID();
const fullConfig: Record<string, unknown> = { ...config }; const outcome = await configureDevice(app, { id, driverId, config, backendIp });
// The web password the admin typed is a DESIRED value, not a stored fact: if ("error" in outcome) {
// it's passed to the driver (via create(config) below) as the rotation return reply.code(outcome.error.code).send({ error: outcome.error.message });
// target, but we do NOT persist it from the form. Only harden()'s VERIFIED
// secrets.webPassword gets saved — otherwise a failed rotation would leave
// the DB claiming a password the device never accepted (login stays old).
delete fullConfig.webPassword;
// webPasswordCurrent is an input-only credential (the OLD password used to
// authorize the change) — never persist it as typed.
delete fullConfig.webPasswordCurrent;
// Residual-risk warnings from device hardening (shown to the admin; the
// save still succeeds — these are "configured, but note X" advisories).
const hardenWarnings: string[] = [];
let device;
try {
device = registry.create(driverId, config); // validates required fields
} catch (err) {
return reply.code(400).send({ error: (err as Error).message });
}
// Configure the device on save (before persisting, so we don't store a row
// for a device we couldn't configure):
// 1. fix preconditions (e.g. disable input_link_relay so a button press
// doesn't auto-fire its relay — host must decide first),
// 2. harden (relay password + disable unused protocol channels), and
// 3. set up input push (Digest creds + push URLs).
// Each step is a device config write (the device reboots on apply).
try {
if (hasPreconditions(device)) {
const fixed = await device.fixPreconditions();
if (!fixed.ok) {
const unfixable = fixed.issues.find((i) => !i.fixable);
return reply.code(502).send({
error: `device precondition not satisfied: ${unfixable?.message ?? fixed.issues[0]?.message}`,
});
}
}
if (isHardenable(device)) {
const { secrets, warnings } = await device.harden();
Object.assign(fullConfig, secrets); // e.g. relayPassword
// Surface residual-risk warnings (e.g. firmware that won't disable the
// password-less string protocol) so the admin can act (web-UI step).
for (const w of warnings ?? []) {
app.log.warn(`harden(${driverId} ${id}): ${w}`);
hardenWarnings.push(w);
}
}
if (hasPushConfig(device)) {
const host = String(config.host ?? "");
// Admin-provided backend IP wins; else auto-derive (on-subnet NIC).
const pushHost = backendIp ?? backendIpForDevice(host);
if (!pushHost) {
return reply.code(400).send({
error: `cannot determine the backend IP on the device's subnet (${host}). Pick one in setup or set BACKEND_HOST_IP.`,
});
}
const pushUser = "dingtian";
// 24 hex chars = 96 bits. The Dingtian `pass` field caps at 31 chars
// (longer is silently truncated → auth mismatch), so keep it short.
const pushPassword = randomBytes(12).toString("hex");
await device.configureInputPush({
host: pushHost,
port: backendPort(),
pathBase: `/api/devices/${driverId}/${id}/input`,
auth: { user: pushUser, password: pushPassword },
});
fullConfig.pushUser = pushUser;
fullConfig.pushPassword = pushPassword;
// Record the backend IP the device was told to push to — lets us detect
// a later mismatch if the host's IP changes.
fullConfig.backendIp = pushHost;
}
} catch (err) {
return reply
.code(502)
.send({ error: `device configuration failed: ${(err as Error).message}` });
} }
const row = { const row = {
id, id,
category, category,
driverId, driverId,
config: fullConfig, config: outcome.config,
enabled: true, enabled: true,
}; };
await db.insert(devices).values(row); await db.insert(devices).values(row);
// Don't echo device secrets back (push Digest password, web-UI login, …). // Don't echo device secrets back (push Digest password, web-UI login, …).
return reply.code(201).send({ return reply.code(201).send({
...row, ...row,
config: redactSecrets(fullConfig), config: redactSecrets(outcome.config),
...(hardenWarnings.length ? { warnings: hardenWarnings } : {}), ...(outcome.warnings.length ? { warnings: outcome.warnings } : {}),
});
},
);
// Edit an assigned device in place. Same configure pipeline as assign, but it
// UPDATEs the existing row and KEEPS the id — which matters for controllers,
// since the id is baked into the device's input-push URL
// (/api/devices/:driverId/:id/input). Delete+re-add would mint a new id and
// break push until reconfigured; PATCH re-runs harden/push against the same id.
// The category and driver are fixed at create time (an edit can't change what
// KIND of device a slot is); only config changes. Admin-only.
app.patch<{ Params: { id: string }; Body: Omit<AssignBody, "category" | "driverId"> }>(
"/api/setup/assign/:id",
{ preHandler: adminGuard },
async (req, reply) => {
const existing = await db
.select()
.from(devices)
.where(eq(devices.id, req.params.id))
.get();
if (!existing) return reply.code(404).send({ error: "no such device assignment" });
const { config, backendIp } = req.body;
const outcome = await configureDevice(app, {
id: existing.id,
driverId: existing.driverId,
config,
backendIp,
// Carry forward machine-only secrets the client never received, so an
// edit that omits them doesn't blank out push/relay passwords.
existingConfig: existing.config,
});
if ("error" in outcome) {
return reply.code(outcome.error.code).send({ error: outcome.error.message });
}
await db.update(devices).set({ config: outcome.config }).where(eq(devices.id, existing.id));
app.log.info(`reconfigured device ${existing.id} (${existing.category}/${existing.driverId})`);
return reply.code(200).send({
id: existing.id,
category: existing.category,
driverId: existing.driverId,
config: redactSecrets(outcome.config),
enabled: existing.enabled,
...(outcome.warnings.length ? { warnings: outcome.warnings } : {}),
}); });
}, },
); );
+42 -4
View File
@@ -1,11 +1,19 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { requireRole } from "../auth.js"; import { requireRole } from "../auth.js";
import { import {
InvalidCashMovementError,
NoOpenShiftError, NoOpenShiftError,
ShiftAlreadyOpenError, ShiftAlreadyOpenError,
type ShiftService, type ShiftService,
} from "../shift-service.js"; } from "../shift-service.js";
interface CashMovementBody {
/** Signed minor units: positive = load INTO drawer, negative = remove FROM drawer. */
amountMinor: number;
reason?: string;
currency?: string;
}
// Shift endpoints (manned mode). The operator is the logged-in user; a shift is // Shift endpoints (manned mode). The operator is the logged-in user; a shift is
// opened/closed explicitly (not time-based — see wiki/concepts/shift.md and // opened/closed explicitly (not time-based — see wiki/concepts/shift.md and
// local-jwt-auth.md "until logout"). End Shift signs a shift_z_report + prints it. // local-jwt-auth.md "until logout"). End Shift signs a shift_z_report + prints it.
@@ -14,13 +22,43 @@ export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Pr
// Cashier/operator/admin run shifts; readonly can't. // Cashier/operator/admin run shifts; readonly can't.
const guard = requireRole("admin", "operator", "cashier"); const guard = requireRole("admin", "operator", "cashier");
// Is the current operator's shift open? (For the UI to show Start vs. End.) // The SITE-WIDE shift state (at most one shift open at a time). The UI uses this
// to render the header control: no shift → "Open"; my shift → "Close" (enabled);
// someone else's shift → disabled. Also returns the live drawer balance.
// - open: the open shift { startedAt, operator } or null (site-wide)
// - isMine: true iff the open shift belongs to the requesting operator
// - operator: the requesting user (for the UI's own identity)
app.get("/api/shift/current", { preHandler: guard }, async (req) => { app.get("/api/shift/current", { preHandler: guard }, async (req) => {
const operator = req.user.username; const me = req.user.username;
const open = shift.openShiftFor(operator); const open = shift.currentOpenShift();
return { operator, open: open ? { startedAt: open.occurredAt } : null }; const heldBy = open?.identity ?? null;
const drawer = shift.drawerBalance();
return {
operator: me,
open: open ? { startedAt: open.occurredAt, operator: heldBy } : null,
isMine: open != null && heldBy === me,
drawerMinor: drawer.balanceMinor,
currency: drawer.currency,
};
}); });
// Admin loads/removes physical drawer cash (the float). Signed cash_movement
// event. ADMIN ONLY — an operator takes payments but cannot move the float.
// amountMinor is signed: + load IN, − remove OUT. See wiki/concepts/shift.md.
app.post<{ Body: CashMovementBody }>(
"/api/cash-movement",
{ preHandler: requireRole("admin") },
async (req, reply) => {
const { amountMinor, reason, currency } = req.body ?? ({} as CashMovementBody);
try {
return await shift.recordCashMovement(req.user.username, amountMinor, reason ?? "", currency);
} catch (err) {
if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message });
return reply.code(500).send({ error: (err as Error).message });
}
},
);
app.post("/api/shift/open", { preHandler: guard }, async (req, reply) => { app.post("/api/shift/open", { preHandler: guard }, async (req, reply) => {
try { try {
return await shift.open(req.user.username); return await shift.open(req.user.username);
+79 -10
View File
@@ -7,9 +7,53 @@ import { getOccupancy } from "../occupancy.js";
// ledger; capacity is an admin-set knob. The FULL gate (refuse transient entry at // ledger; capacity is an admin-set knob. The FULL gate (refuse transient entry at
// capacity) lives in the entry flow. See wiki/concepts/capacity-occupancy.md. // capacity) lives in the entry flow. See wiki/concepts/capacity-occupancy.md.
interface SiteConfigBody { // Optional park-metadata text fields (all nullable). Trimmed; "" → null.
const TEXT_FIELDS = [
"parkName",
"operatorName",
"nius",
"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];
interface SiteConfigBody extends Partial<Record<TextField, string | null>> {
/** Nominal capacity; null = no limit. */ /** Nominal capacity; null = no limit. */
capacity?: number | null;
/** Default for the booth "print exit ticket" checkbox (booth-geography knob). */
exitVoucherDefault?: boolean;
/** Site default monthly subscription price in minor units (pre-fills the form). */
subscriptionMonthlyPriceMinor?: number | null;
}
/** Shape returned by GET/PUT: capacity + the booth flag + the subscription default
* + every metadata field. */
type SiteConfig = {
capacity: number | null; capacity: number | null;
exitVoucherDefault: boolean;
subscriptionMonthlyPriceMinor: number | null;
} & Record<TextField, string | null>;
function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig {
const out = {
capacity: row?.capacity ?? null,
exitVoucherDefault: row?.exitVoucherDefault ?? false,
subscriptionMonthlyPriceMinor: row?.subscriptionMonthlyPriceMinor ?? null,
} as SiteConfig;
for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null;
return out;
}
/** Trim a text field; empty string becomes null so blank input clears it. */
function normText(v: unknown): string | null {
if (v == null) return null;
const s = String(v).trim();
return s === "" ? null : s;
} }
export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> { export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
@@ -19,25 +63,50 @@ export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
// Live occupancy: cars inside, capacity, free, full. Any signed-in role. // Live occupancy: cars inside, capacity, free, full. Any signed-in role.
app.get("/api/occupancy", { preHandler: readGuard }, async () => getOccupancy(db)); app.get("/api/occupancy", { preHandler: readGuard }, async () => getOccupancy(db));
// Read site config (capacity). // Read site config (capacity + park metadata).
app.get("/api/site-config", { preHandler: readGuard }, async () => { app.get("/api/site-config", { preHandler: readGuard }, async () => {
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get(); const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
return { capacity: row?.capacity ?? null }; return toSiteConfig(row);
}); });
// Set capacity (admin). null or 0+ integer. // Set site config (admin). Capacity: null or 0+ integer. Metadata: optional text
// (only the fields PRESENT in the body are updated; absent fields are untouched).
app.put<{ Body: SiteConfigBody }>("/api/site-config", { preHandler: writeGuard }, async (req, reply) => { app.put<{ Body: SiteConfigBody }>("/api/site-config", { preHandler: writeGuard }, async (req, reply) => {
const { capacity } = req.body ?? ({} as SiteConfigBody); const body = req.body ?? ({} as SiteConfigBody);
if (capacity != null && (!Number.isInteger(capacity) || capacity < 0)) {
return reply.code(400).send({ error: "capacity must be a non-negative integer or null" }); const patch: Partial<typeof siteConfig.$inferInsert> = {};
if ("capacity" in body) {
const c = body.capacity;
if (c != null && (!Number.isInteger(c) || c < 0)) {
return reply.code(400).send({ error: "capacity must be a non-negative integer or null" });
}
patch.capacity = c ?? null;
} }
if ("exitVoucherDefault" in body) {
if (typeof body.exitVoucherDefault !== "boolean") {
return reply.code(400).send({ error: "exitVoucherDefault must be a boolean" });
}
patch.exitVoucherDefault = body.exitVoucherDefault;
}
if ("subscriptionMonthlyPriceMinor" in body) {
const p = body.subscriptionMonthlyPriceMinor;
if (p != null && (!Number.isInteger(p) || p < 0)) {
return reply.code(400).send({ error: "subscriptionMonthlyPriceMinor must be a non-negative integer or null" });
}
patch.subscriptionMonthlyPriceMinor = p ?? null;
}
for (const f of TEXT_FIELDS) {
if (f in body) patch[f] = normText(body[f]);
}
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get(); const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
const updatedAt = new Date().toISOString(); const updatedAt = new Date().toISOString();
if (existing) { if (existing) {
db.update(siteConfig).set({ capacity: capacity ?? null, updatedAt }).where(eq(siteConfig.id, 1)).run(); db.update(siteConfig).set({ ...patch, updatedAt }).where(eq(siteConfig.id, 1)).run();
} else { } else {
db.insert(siteConfig).values({ id: 1, capacity: capacity ?? null, updatedAt }).run(); db.insert(siteConfig).values({ id: 1, ...patch, updatedAt }).run();
} }
return { capacity: capacity ?? null }; const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
return toSiteConfig(row);
}); });
} }
+366
View File
@@ -0,0 +1,366 @@
import { randomBytes, randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify";
import { eq, devices, subscriptionCredentials, subscriptionPlates, subscriptions, type Db } from "@parking/db";
import { NoPrinterAvailableError } from "@parking/devices";
import { requireRole } from "../auth.js";
import { printSubscriptionCard } from "../booth-print.js";
import type { CredentialCapture } from "../credential-capture.js";
import { directionOf } from "../device-resolve.js";
// Subscription admin CRUD. A subscription is mutable master data — admins
// grant/edit/revoke — but every USE of it is a signed ledger event, so the audit
// trail stays append-only (see wiki/entities/subscription.md). A subscription is an
// aggregate: the row + its credentials (card/QR) + its bound plates. The API treats
// them as one unit (create/update replace the child sets; delete removes all).
//
// Pricing: priceMinor + period ("monthly") + currency record the recurring plan
// (e.g. 10,000 ALL / month). Collecting the fee into the ledger/shift is deferred —
// here we just store the agreed price and the coverage window.
interface Credential {
kind: "rf" | "qr";
/** For RF: the physical card/tag id (required). For QR: optional — left blank, the
* server AUTO-GENERATES an unguessable code (the customer never picks it). */
value?: string;
}
interface SubscriptionBody {
holderName?: string;
contact?: string;
/** Recurring price in minor units (e.g. 1000000 = 10,000.00). null = no price set. */
priceMinor?: number | null;
period?: "monthly";
/** ISO-4217 currency of priceMinor (e.g. "ALL"). */
currency?: string | null;
/** Car-count binding: cars inside at once. Default 1; null = unbound. */
maxConcurrent?: number | null;
validFrom?: string | null;
validTo?: string | null;
/** Months paid for. When set (with validFrom), validTo = validFrom + months — the
* multi-month case (e.g. 3 months). Takes precedence over an explicit validTo. */
months?: number | null;
status?: "active" | "suspended" | "revoked";
credentials?: Credential[];
/** Plate binding (optional): bound plates that also serve as identity. */
plates?: string[];
}
/** Mint an unguessable QR credential value. Namespaced + crypto-random; the reader
* delivers the full string over TCP/IP (the host-in-the-loop path), so length is
* free. base32 (Crockford-ish, no 0/1/O/I ambiguity), uppercased. */
function newQrCode(): string {
const alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
const bytes = randomBytes(15);
let out = "";
for (const b of bytes) out += alphabet[b % 32];
return `SUB-${out}`;
}
/** Add whole months to an ISO datetime, clamping day overflow (e.g. Jan 31 +1mo →
* Feb 28/29). Returns ISO. */
function addMonths(iso: string, months: number): string {
const d = new Date(iso);
const day = d.getUTCDate();
d.setUTCMonth(d.getUTCMonth() + months);
// If the month rolled past (e.g. day 31 → next month had fewer days), clamp back.
if (d.getUTCDate() < day) d.setUTCDate(0);
return d.toISOString();
}
export async function subscriptionRoutes(
app: FastifyInstance,
db: Db,
capture: CredentialCapture,
): Promise<void> {
// Admin manages subscriptions; operator/cashier/readonly may LIST (to look one up).
const readGuard = requireRole("admin", "operator", "cashier", "readonly");
const writeGuard = requireRole("admin");
// Validate the body; returns problems (empty = ok). Shared by create + update.
function validate(b: SubscriptionBody): string[] {
const errs: string[] = [];
if (b.maxConcurrent != null) {
if (!Number.isInteger(b.maxConcurrent) || b.maxConcurrent < 1) {
errs.push("maxConcurrent must be a positive integer, or null for unbound");
}
}
if (b.priceMinor != null) {
if (!Number.isInteger(b.priceMinor) || b.priceMinor < 0) {
errs.push("priceMinor must be a non-negative integer (minor units), or null");
}
if (!b.currency?.trim()) {
errs.push("currency is required when a price is set");
}
}
if (b.period != null && b.period !== "monthly") {
errs.push("period must be 'monthly' (the only period supported today)");
}
if (b.months != null) {
if (!Number.isInteger(b.months) || b.months < 1) {
errs.push("months must be a positive integer");
}
if (!b.validFrom?.trim()) {
errs.push("validFrom is required when months is set (validTo = validFrom + months)");
}
}
if (b.status && !["active", "suspended", "revoked"].includes(b.status)) {
errs.push("status must be active|suspended|revoked");
}
for (const c of b.credentials ?? []) {
if (c.kind !== "rf" && c.kind !== "qr") {
errs.push("each credential needs kind (rf|qr)");
break;
}
// RF must carry the physical card id; QR may be blank (server auto-generates).
if (c.kind === "rf" && !c.value?.trim()) {
errs.push("an RF credential needs a non-empty value (the card/tag id)");
break;
}
}
if ((b.credentials?.length ?? 0) === 0 && (b.plates?.length ?? 0) === 0) {
errs.push("a subscription needs at least one credential or one bound plate (else nothing identifies it)");
}
return errs;
}
function loadAggregate(id: string) {
const sub = db.select().from(subscriptions).where(eq(subscriptions.id, id)).get();
if (!sub) return null;
const credentials = db.select().from(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, id)).all();
const plates = db.select().from(subscriptionPlates).where(eq(subscriptionPlates.subscriptionId, id)).all();
return {
...sub,
credentials: credentials.map((c) => ({ kind: c.kind, value: c.value })),
plates: plates.map((p) => p.plate),
};
}
/** Is this credential value already used by ANY subscription? (Global uniqueness —
* a value is the lane identity, so it must resolve to one subscription.) */
function valueTaken(value: string): boolean {
return db.select().from(subscriptionCredentials).where(eq(subscriptionCredentials.value, value)).get() != null;
}
/** A fresh, collision-free QR code (retries on the astronomically unlikely clash). */
function mintQrCode(): string {
for (let i = 0; i < 5; i += 1) {
const code = newQrCode();
if (!valueTaken(code)) return code;
}
throw new Error("could not mint a unique QR code");
}
// Replace a subscription's child rows (credentials + plates) from the body. QR
// credentials with no value are SERVER-GENERATED here (the customer never picks the
// code). The generated value is returned via loadAggregate so the UI can print it.
function writeChildren(id: string, b: SubscriptionBody) {
db.delete(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, id)).run();
db.delete(subscriptionPlates).where(eq(subscriptionPlates.subscriptionId, id)).run();
for (const c of b.credentials ?? []) {
const supplied = c.value?.trim();
// QR + blank → auto-generate; otherwise use the supplied value (RF card id, or a
// QR being preserved on edit).
const value = supplied && supplied.length > 0 ? supplied : c.kind === "qr" ? mintQrCode() : "";
if (!value) continue; // guarded by validate(); defensive
db.insert(subscriptionCredentials).values({ id: randomUUID(), subscriptionId: id, kind: c.kind, value }).run();
}
for (const p of b.plates ?? []) {
if (p.trim()) db.insert(subscriptionPlates).values({ id: randomUUID(), subscriptionId: id, plate: p.trim() }).run();
}
}
/** Resolve the coverage end: months (validFrom + months) wins over an explicit validTo. */
function resolveValidTo(b: SubscriptionBody, fallback: string | null): string | null {
if (b.months != null && b.validFrom?.trim()) return addMonths(b.validFrom.trim(), b.months);
if (b.validTo !== undefined) return b.validTo ?? null;
return fallback;
}
// List all subscriptions (with their credentials + plates).
app.get("/api/subscriptions", { preHandler: readGuard }, async () => {
const rows = db.select().from(subscriptions).all();
return { subscriptions: rows.map((r) => loadAggregate(r.id)) };
});
// --- Credential capture ("enroll a card") -------------------------------
// The operator picks a reader and presents an RFID card to it; the next read on
// that reader is captured for the form instead of opening a barrier. The OTHER
// reader keeps serving the live flow. Single-shot + TTL. See credential-capture.ts.
// The readers the operator can capture on (entry/exit by their bound relay).
app.get("/api/subscriptions/readers", { preHandler: readGuard }, async () => {
const rows = db.select().from(devices).where(eq(devices.category, "reader")).all();
return {
readers: rows
.filter((r) => r.enabled)
.map((r) => ({ id: r.id, driverId: r.driverId, direction: directionOf(db, r) })),
};
});
// Arm capture on a reader (by devices.id). Operator-or-admin (booth action).
app.post<{ Body: { deviceId?: string } }>(
"/api/subscriptions/capture/arm",
{ preHandler: readGuard },
async (req, reply) => {
const deviceId = (req.body?.deviceId ?? "").trim();
if (!deviceId) return reply.code(400).send({ error: "deviceId required" });
const reader = db.select().from(devices).where(eq(devices.id, deviceId)).get();
if (!reader || reader.category !== "reader" || !reader.enabled) {
return reply.code(404).send({ error: "no such enabled reader" });
}
return capture.arm(deviceId);
},
);
// Poll the capture state (idle | armed | captured | expired). The form polls this
// and, on "captured", reads `value` into the credential field then clears it.
app.get("/api/subscriptions/capture", { preHandler: readGuard }, async () => capture.state());
// Operator cancelled / closed the form — disarm and clear any result.
app.post("/api/subscriptions/capture/cancel", { preHandler: readGuard }, async () => {
capture.cancel();
capture.clear();
return { ok: true };
});
// Create a subscription.
app.post<{ Body: SubscriptionBody }>("/api/subscriptions", { preHandler: writeGuard }, async (req, reply) => {
const b = req.body ?? {};
const problems = validate(b);
if (problems.length) return reply.code(400).send({ error: "invalid subscription", problems });
const id = randomUUID();
db.insert(subscriptions)
.values({
id,
holderName: b.holderName ?? null,
contact: b.contact ?? null,
priceMinor: b.priceMinor ?? null,
period: b.period ?? "monthly",
currency: b.priceMinor != null ? (b.currency ?? null) : null,
maxConcurrent: b.maxConcurrent === undefined ? 1 : b.maxConcurrent,
validFrom: b.validFrom ?? null,
validTo: resolveValidTo(b, null),
status: b.status ?? "active",
})
.run();
writeChildren(id, b);
const sub = loadAggregate(id);
// Auto-print the QR card so the operator can hand it to the customer. Best-effort:
// a print failure NEVER fails the create (the subscription + its code are saved);
// the response carries { printed, printError } so the UI can warn + offer reprint.
const printResult = await tryPrintCard(sub);
return reply.code(201).send({ ...sub, ...printResult });
});
/** The first QR credential's code for a subscription aggregate, or null. */
function qrCodeOf(sub: ReturnType<typeof loadAggregate>): string | null {
const cred = sub?.credentials.find((c) => c.kind === "qr");
return cred?.value ?? null;
}
/** Best-effort print of a subscription's QR card. Returns a flag + optional error
* (never throws). No QR credential → nothing to print (printed:false, no error). */
async function tryPrintCard(
sub: ReturnType<typeof loadAggregate>,
): Promise<{ printed: boolean; printedBy?: string; printError?: string }> {
const code = qrCodeOf(sub);
if (!sub || !code) return { printed: false };
try {
const printedBy = await printSubscriptionCard(
db,
{ code, holderName: sub.holderName, validFrom: sub.validFrom, validTo: sub.validTo },
app.log,
);
return { printed: true, printedBy };
} catch (err) {
const printError = err instanceof NoPrinterAvailableError ? err.message : (err as Error).message;
app.log.warn(`subscription card print failed for ${sub.id}: ${printError}`);
return { printed: false, printError };
}
}
// Update a subscription (replaces fields + child sets).
app.put<{ Params: { id: string }; Body: SubscriptionBody }>(
"/api/subscriptions/:id",
{ preHandler: writeGuard },
async (req, reply) => {
const existing = db.select().from(subscriptions).where(eq(subscriptions.id, req.params.id)).get();
if (!existing) return reply.code(404).send({ error: "subscription not found" });
const b = req.body ?? {};
const problems = validate(b);
if (problems.length) return reply.code(400).send({ error: "invalid subscription", problems });
db.update(subscriptions)
.set({
holderName: b.holderName ?? null,
contact: b.contact ?? null,
priceMinor: b.priceMinor === undefined ? existing.priceMinor : b.priceMinor,
period: b.period ?? existing.period,
currency:
b.priceMinor === undefined
? existing.currency
: b.priceMinor != null
? (b.currency ?? null)
: null,
maxConcurrent: b.maxConcurrent === undefined ? existing.maxConcurrent : b.maxConcurrent,
validFrom: b.validFrom ?? null,
validTo: resolveValidTo(b, existing.validTo),
status: b.status ?? existing.status,
})
.where(eq(subscriptions.id, req.params.id))
.run();
writeChildren(req.params.id, b);
return loadAggregate(req.params.id);
},
);
// Re-print the subscription's QR card (failed auto-print, lost card, re-hand to the
// customer). Operator-or-admin (it's a booth action, not a master-data edit). 404 if
// the subscription is gone; 409 if it has no QR credential; 503 if no printer.
app.post<{ Params: { id: string } }>(
"/api/subscriptions/:id/print",
{ preHandler: readGuard },
async (req, reply) => {
const sub = loadAggregate(req.params.id);
if (!sub) return reply.code(404).send({ error: "subscription not found" });
const code = qrCodeOf(sub);
if (!code) return reply.code(409).send({ error: "subscription has no QR credential to print" });
try {
const printedBy = await printSubscriptionCard(
db,
{ code, holderName: sub.holderName, validFrom: sub.validFrom, validTo: sub.validTo },
app.log,
);
return reply.code(200).send({ ok: true, printedBy });
} catch (err) {
if (err instanceof NoPrinterAvailableError) return reply.code(503).send({ error: err.message });
return reply.code(500).send({ error: (err as Error).message });
}
},
);
// Revoke (soft): the common case — keeps the subscription + its history, just bars
// it. A revoked subscription fails the entry check (see subscription-flow.ts). Use
// DELETE only to fully remove one created in error.
app.post<{ Params: { id: string } }>(
"/api/subscriptions/:id/revoke",
{ preHandler: writeGuard },
async (req, reply) => {
const r = db.update(subscriptions).set({ status: "revoked" }).where(eq(subscriptions.id, req.params.id)).run();
if (r.changes === 0) return reply.code(404).send({ error: "subscription not found" });
return loadAggregate(req.params.id);
},
);
// Hard delete a subscription + its child rows. (Past ledger events that reference it
// are untouched — the audit trail is append-only and independent of this row.)
app.delete<{ Params: { id: string } }>(
"/api/subscriptions/:id",
{ preHandler: writeGuard },
async (req, reply) => {
const r = db.delete(subscriptions).where(eq(subscriptions.id, req.params.id)).run();
if (r.changes === 0) return reply.code(404).send({ error: "subscription not found" });
db.delete(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, req.params.id)).run();
db.delete(subscriptionPlates).where(eq(subscriptionPlates.subscriptionId, req.params.id)).run();
return reply.code(204).send();
},
);
}
+43 -5
View File
@@ -1,9 +1,12 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { desc, eq, tariffVersions, tariffs, type Db } from "@parking/db"; import { desc, eq, siteConfig, tariffVersions, tariffs, type Db } from "@parking/db";
import { validateTariffStructure, type TariffStructure } from "@parking/shared"; import { isTariffV2, validateTariffStructure, type TariffStructure } from "@parking/shared";
import { requireRole } from "../auth.js"; 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 // Tariff composer API — the admin builds + edits the rate card at runtime. Tariffs
// are EFFECTIVE-DATED IMMUTABLE VERSIONS: editing publishes a new version, never // 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 // mutates one; a session reprices against the version in force at its entry, and
@@ -58,18 +61,53 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
if (!currency || typeof currency !== "string" || currency.length < 3) { if (!currency || typeof currency !== "string" || currency.length < 3) {
return reply.code(400).send({ error: "currency (ISO 4217) required" }); 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) { if (problems.length) {
return reply.code(400).send({ error: "invalid tariff structure", problems }); return reply.code(400).send({ error: "invalid tariff structure", problems });
} }
// effectiveFrom must NOT be in the past. A version is selected by
// "latest effectiveFrom <= entry time", so a backdated effectiveFrom would
// retroactively reprice already-entered sessions — exactly the immutability
// the versioning exists to prevent (wiki/concepts/tariff.md). So we forbid
// backdating: a new version applies only from publish (now) forward; a future
// effectiveFrom (scheduling a price change) is allowed. A small skew tolerance
// absorbs client/server clock drift + request round-trip. Once a car has
// entered, no later publish can reprice it (no effectiveFrom can predate it).
const now = Date.now();
const SKEW_MS = 60_000; // 1 min: clock skew + round-trip slack
let effective = new Date().toISOString();
if (effectiveFrom != null) {
const t = Date.parse(effectiveFrom);
if (Number.isNaN(t)) {
return reply.code(400).send({ error: "effectiveFrom must be a valid ISO-8601 timestamp" });
}
if (t < now - SKEW_MS) {
return reply.code(400).send({
error: "effectiveFrom cannot be in the past — backdating a tariff would retroactively reprice entered sessions",
});
}
effective = new Date(t).toISOString();
}
const tariffId = ensureSiteTariff(); const tariffId = ensureSiteTariff();
const id = randomUUID(); const id = randomUUID();
const row = { const row = {
id, id,
tariffId, tariffId,
effectiveFrom: effectiveFrom ?? new Date().toISOString(), effectiveFrom: effective,
currency, currency,
structure: structure as unknown as Record<string, unknown>, structure: toStore as unknown as Record<string, unknown>,
createdBy: req.user?.username ?? null, createdBy: req.user?.username ?? null,
}; };
db.insert(tariffVersions).values(row).run(); db.insert(tariffVersions).values(row).run();
+113
View File
@@ -0,0 +1,113 @@
import type { FastifyInstance } from "fastify";
import type { Db } from "@parking/db";
import type { Role } from "@parking/shared";
import { deviceEvents } from "../device-events.js";
import type { DeviceMonitor } from "../device-monitor.js";
import { getOccupancy } from "../occupancy.js";
// Live booth feed over a WebSocket. The booth UI opens ONE socket and receives
// server-pushed updates instead of polling: each signed ledger append (entry,
// exit, payment, void) is fanned out, and the recomputed occupancy rides along
// so the screen's count stays exact (occupancy is a fold over the same ledger,
// never a counter). Printer-status changes are forwarded too.
//
// Auth: the handshake is a normal GET through Fastify's lifecycle, so the same
// HttpOnly JWT cookie that guards the REST API guards this. We verify the JWT and
// role here. A browser's WebSocket constructor cannot set custom headers, so the
// CSRF double-submit header the REST mutations use is unavailable — which would
// leave the socket open to Cross-Site WebSocket Hijacking: a malicious page in the
// operator's browser could open ws://<booth>/api/ws, the browser would auto-attach
// the HttpOnly cookie, and the attacker would receive the live entry/exit/payment
// stream. The cookie alone is NOT a control here. So we replace the CSRF check with
// an Origin allowlist: the handshake's Origin must be same-origin (or an explicitly
// allowed booth UI origin). Non-browser clients (no Origin) are rejected too.
// See auth.ts, event-log.ts (emitLedger), capacity-occupancy.md.
/** Roles allowed to watch the live feed (everyone signed in; readonly included —
* it's a read-only stream). */
const WATCH_ROLES: Role[] = ["admin", "operator", "cashier", "readonly"];
/**
* Is the handshake's Origin trusted? Same-origin (Origin host === Host header) is
* always allowed; additional origins can be allowlisted via WS_ALLOWED_ORIGINS
* (comma-separated) for a booth UI served from a different origin. A missing or
* mismatched Origin is rejected — that is the anti-CSWSH control.
*/
function isAllowedOrigin(origin: string | undefined, host: string | undefined): boolean {
if (!origin) return false; // no Origin → not a same-origin browser request
let originHost: string;
try {
originHost = new URL(origin).host;
} catch {
return false; // malformed Origin
}
if (host && originHost === host) return true; // same-origin (any scheme/port match via host)
const allow = (process.env.WS_ALLOWED_ORIGINS ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
return allow.includes(origin);
}
type OutMsg =
| { kind: "hello"; occupancy: ReturnType<typeof getOccupancy>; devices: unknown }
| { kind: "ledger"; event: unknown; occupancy: ReturnType<typeof getOccupancy> }
| { kind: "printer-status"; event: unknown }
| { kind: "device-status"; event: unknown };
export async function wsRoutes(app: FastifyInstance, db: Db, deviceMonitor: DeviceMonitor): Promise<void> {
app.get(
"/api/ws",
{
websocket: true,
// Origin allowlist (anti-CSWSH, replaces CSRF — see file header) THEN JWT +
// role. Reject a cross/absent origin before touching the token, so a hijack
// attempt never reaches an authenticated socket. jwtVerify reads the cookie.
preHandler: async (req) => {
if (!isAllowedOrigin(req.headers.origin, req.headers.host)) {
throw Object.assign(new Error("forbidden origin"), { statusCode: 403 });
}
await req.jwtVerify();
if (!req.user || !WATCH_ROLES.includes(req.user.role)) {
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
}
},
},
(socket) => {
const send = (msg: OutMsg) => {
// readyState 1 = OPEN; never throw out of an event-bus callback.
if (socket.readyState === 1) {
try {
socket.send(JSON.stringify(msg));
} catch {
/* drop on a broken socket */
}
}
};
// Initial snapshot so the client renders immediately, before any event:
// occupancy AND the current device-status set (for the footer).
send({ kind: "hello", occupancy: getOccupancy(db), devices: deviceMonitor.snapshot() });
// Subscribe to the live buses. Each handler recomputes occupancy from the
// ledger (cheap fold) so the pushed count is always authoritative.
const offLedger = deviceEvents.onLedger((event) => {
send({ kind: "ledger", event, occupancy: getOccupancy(db) });
});
const offPrinter = deviceEvents.onPrinterStatus((event) => {
send({ kind: "printer-status", event });
});
// Unified device status (all categories) for the booth footer — pushed on
// change; the initial set rode the hello above.
const offDevice = deviceEvents.onDeviceStatus((event) => {
send({ kind: "device-status", event });
});
socket.on("close", () => {
offLedger();
offPrinter();
offDevice();
});
},
);
}
+56 -18
View File
@@ -1,5 +1,6 @@
import cookie from "@fastify/cookie"; import cookie from "@fastify/cookie";
import jwt from "@fastify/jwt"; import jwt from "@fastify/jwt";
import websocket from "@fastify/websocket";
import Fastify, { type FastifyInstance } from "fastify"; import Fastify, { type FastifyInstance } from "fastify";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { createDb, deviceEvents as deviceEventsTable, type Db } from "@parking/db"; import { createDb, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
@@ -9,16 +10,18 @@ import { EntryFlow } from "./entry-flow.js";
import { EventLog } from "./event-log.js"; import { EventLog } from "./event-log.js";
import { ExitFlow } from "./exit-flow.js"; import { ExitFlow } from "./exit-flow.js";
import { PayStation } from "./pay-station.js"; import { PayStation } from "./pay-station.js";
import { PermitFlow } from "./permit-flow.js"; import { SubscriptionFlow } from "./subscription-flow.js";
import { ShiftService } from "./shift-service.js"; import { ShiftService } from "./shift-service.js";
import { ReadDispatcher } from "./read-dispatch.js"; import { ReadDispatcher } from "./read-dispatch.js";
import { CredentialCapture } from "./credential-capture.js";
import { PrinterMonitor } from "./printer-monitor.js"; import { PrinterMonitor } from "./printer-monitor.js";
import { buildSigner } from "./signer.js"; import { DeviceMonitor } from "./device-monitor.js";
import { buildSigner, buildVerifier } from "./signer.js";
import { authRoutes } from "./routes/auth.js"; import { authRoutes } from "./routes/auth.js";
import { deviceRoutes } from "./routes/devices.js"; import { deviceRoutes } from "./routes/devices.js";
import { eventRoutes } from "./routes/events.js"; import { eventRoutes } from "./routes/events.js";
import { payRoutes } from "./routes/pay.js"; import { payRoutes } from "./routes/pay.js";
import { permitRoutes } from "./routes/permits.js"; import { subscriptionRoutes } from "./routes/subscriptions.js";
import { qrReaderRoutes } from "./routes/qr-reader.js"; import { qrReaderRoutes } from "./routes/qr-reader.js";
import { shiftRoutes } from "./routes/shift.js"; import { shiftRoutes } from "./routes/shift.js";
import { siteRoutes } from "./routes/site.js"; import { siteRoutes } from "./routes/site.js";
@@ -26,6 +29,8 @@ import { snapshotRoutes } from "./routes/snapshots.js";
import { tariffRoutes } from "./routes/tariffs.js"; import { tariffRoutes } from "./routes/tariffs.js";
import { printerRoutes } from "./routes/printers.js"; import { printerRoutes } from "./routes/printers.js";
import { setupRoutes } from "./routes/setup.js"; import { setupRoutes } from "./routes/setup.js";
import { deviceStatusRoutes } from "./routes/device-status.js";
import { wsRoutes } from "./routes/ws.js";
// The backend is Fastify (Node). Hardware drivers live as isolated Fastify // The backend is Fastify (Node). Hardware drivers live as isolated Fastify
// plugins emitting onto a shared internal event bus; auth is fully local // plugins emitting onto a shared internal event bus; auth is fully local
@@ -44,6 +49,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
await app.register(cookie); await app.register(cookie);
// WebSocket support for the live booth feed (/api/ws). Registered before the
// routes so the `{ websocket: true }` route option is available.
await app.register(websocket);
// Local JWT signing with a local secret — no external identity provider. // Local JWT signing with a local secret — no external identity provider.
// Fail fast rather than fall back to a known default: a booth machine started // Fail fast rather than fall back to a known default: a booth machine started
// without a real secret would sign tokens anyone could forge (incl. an admin // without a real secret would sign tokens anyone could forge (incl. an admin
@@ -80,15 +89,32 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
app.addHook("onReady", async () => printerMonitor.start()); app.addHook("onReady", async () => printerMonitor.start());
app.addHook("onClose", async () => printerMonitor.stop()); app.addHook("onClose", async () => printerMonitor.stop());
// Unified device-status monitor: polls EVERY configured device (relays/readers/
// cameras via healthCheck, printers via rich readStatus) and feeds the booth's
// device-status footer over the WS. Read-only — never drives a relay.
// See wiki/concepts/device-status-monitoring.md.
const deviceMonitor = new DeviceMonitor(db, app.log);
await deviceStatusRoutes(app, deviceMonitor);
app.addHook("onReady", async () => deviceMonitor.start());
app.addHook("onClose", async () => deviceMonitor.stop());
// Append-only signed business LEDGER (ledger_events). Holds only business facts // Append-only signed business LEDGER (ledger_events). Holds only business facts
// (vehicle_entry/exit, payment, void, …) — the anti-fraud audit trail. A raw // (vehicle_entry/exit, payment, void, …) — the anti-fraud audit trail. A raw
// button press is NOT a business fact: it's device telemetry, recorded UNSIGNED // button press is NOT a business fact: it's device telemetry, recorded UNSIGNED
// in device_events. The entry flow (TODO) turns an input into a signed // in device_events. The entry flow (TODO) turns an input into a signed
// vehicle_entry once a ticket prints + the barrier is commanded. // vehicle_entry once a ticket prints + the barrier is commanded.
// See wiki/decisions/event-streams-split.md. // See wiki/decisions/event-streams-split.md.
const eventLog = new EventLog(db, buildSigner(app.log)); // The 4th arg is a read-side fan-out fired AFTER each durable append — used to
// push the event to live booth clients (WS). It cannot affect the sign/chain path.
const eventLog = new EventLog(db, buildSigner(app.log), buildVerifier, (row) =>
deviceEvents.emitLedger(row),
);
await eventRoutes(app, db, eventLog); await eventRoutes(app, db, eventLog);
// Live booth feed: server-pushed ledger + occupancy + printer-status over a
// single authenticated WebSocket (/api/ws). See routes/ws.ts.
await wsRoutes(app, db, deviceMonitor);
// Entry/exit camera snapshots (BLOB-in-DB), read-only. See snapshot.ts. // Entry/exit camera snapshots (BLOB-in-DB), read-only. See snapshot.ts.
await snapshotRoutes(app, db); await snapshotRoutes(app, db);
@@ -103,36 +129,48 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
app.addHook("onClose", async () => unsubscribeEntry()); app.addHook("onClose", async () => unsubscribeEntry());
// Read-driven flows: a credential read (ticket scan / plate / card) routes via the // Read-driven flows: a credential read (ticket scan / plate / card) routes via the
// dispatcher to either the PERMIT flow (if it matches a permit) or the transient // dispatcher to either the SUBSCRIPTION flow (if it matches a subscription) or the
// EXIT flow. See read-dispatch.ts, exit-flow.ts, permit-flow.ts, parking-session.md. // transient EXIT flow. See read-dispatch.ts, exit-flow.ts, subscription-flow.ts,
// parking-session.md.
const exitFlow = new ExitFlow(db, eventLog, app.log); const exitFlow = new ExitFlow(db, eventLog, app.log);
const permitFlow = new PermitFlow(db, eventLog, app.log); const subscriptionFlow = new SubscriptionFlow(db, eventLog, app.log);
const readDispatcher = new ReadDispatcher(db, exitFlow, permitFlow, app.log); const readDispatcher = new ReadDispatcher(db, exitFlow, subscriptionFlow, app.log);
const unsubscribeRead = deviceEvents.onRead((e) => { const unsubscribeRead = deviceEvents.onRead((e) => {
void readDispatcher.dispatch(e); void readDispatcher.dispatch(e);
}); });
app.addHook("onClose", async () => unsubscribeRead()); app.addHook("onClose", async () => unsubscribeRead());
// Credential capture ("enroll a card"): lets the operator present an RFID card to a
// CHOSEN reader to populate a subscription credential, without blocking the other
// reader's live flow. Single-shot + TTL. See credential-capture.ts.
const credentialCapture = new CredentialCapture();
// GEE/Dingtian QR reader: it HTTP-GETs on each scan and beeps/acts on our JSON // GEE/Dingtian QR reader: it HTTP-GETs on each scan and beeps/acts on our JSON
// verdict (host-in-the-loop, synchronous). Routes the read through the dispatcher // verdict (host-in-the-loop, synchronous). The capture service can intercept a read
// and replies the SDK verdict. See wiki/entities/gee-qr-er80.md, qrcode-sdk.md. // on an armed reader for enrollment; otherwise the read routes through the
await qrReaderRoutes(app, db, readDispatcher); // dispatcher. See wiki/entities/gee-qr-er80.md, qrcode-sdk.md.
await qrReaderRoutes(app, db, readDispatcher, credentialCapture);
// Shifts (manned mode): explicit open/close → signed shift_open / shift_z_report
// (sum payments by tender, print the Z-report). Constructed before the pay routes
// because the booth money path is GATED on an open shift. See wiki/concepts/shift.md.
const shiftService = new ShiftService(db, eventLog, app.log);
// Pay station (pay-on-foot): quote an open session against the active tariff + // Pay station (pay-on-foot): quote an open session against the active tariff +
// take payment → signed `payment` event. See wiki/concepts/tariff.md. // take payment → signed `payment` event. The booth pay/exit/voucher/re-open
// endpoints require an open shift (passed in). See wiki/concepts/tariff.md.
const payStation = new PayStation(db, eventLog, app.log); const payStation = new PayStation(db, eventLog, app.log);
await payRoutes(app, payStation); await payRoutes(app, db, payStation, exitFlow, shiftService);
// Tariff composer: admin publishes effective-dated, immutable rate-card versions // Tariff composer: admin publishes effective-dated, immutable rate-card versions
// the pay station prices against. See wiki/concepts/tariff.md. // the pay station prices against. See wiki/concepts/tariff.md.
await tariffRoutes(app, db); await tariffRoutes(app, db);
// Permit (subscription) admin CRUD. See wiki/entities/permit.md. // Subscription admin CRUD + credential capture (arm/poll/cancel). See
await permitRoutes(app, db); // wiki/entities/subscription.md.
await subscriptionRoutes(app, db, credentialCapture);
// Shifts (manned mode): explicit open/close → signed shift_open / shift_z_report // Shift open/close + drawer endpoints (shiftService constructed above).
// (sum payments by tender, print the Z-report). See wiki/concepts/shift.md.
const shiftService = new ShiftService(db, eventLog, app.log);
await shiftRoutes(app, shiftService); await shiftRoutes(app, shiftService);
// Site config (capacity) + live occupancy. The FULL gate (refuse transient entry // Site config (capacity) + live occupancy. The FULL gate (refuse transient entry
+202 -20
View File
@@ -11,9 +11,16 @@ import type { EventLog } from "./event-log.js";
// See wiki/concepts/shift.md. // See wiki/concepts/shift.md.
export class ShiftAlreadyOpenError extends Error { export class ShiftAlreadyOpenError extends Error {
constructor(operator: string) { /** The operator who currently holds the open shift (may be someone else). */
super(`operator ${operator} already has an open shift`); readonly heldBy: string;
constructor(operator: string, heldBy: string) {
super(
heldBy === operator
? `operator ${operator} already has an open shift`
: `another operator (${heldBy}) has an open shift; only one shift may be open at a time`,
);
this.name = "ShiftAlreadyOpenError"; this.name = "ShiftAlreadyOpenError";
this.heldBy = heldBy;
} }
} }
export class NoOpenShiftError extends Error { export class NoOpenShiftError extends Error {
@@ -22,6 +29,14 @@ export class NoOpenShiftError extends Error {
this.name = "NoOpenShiftError"; this.name = "NoOpenShiftError";
} }
} }
/** Thrown by the booth money path when NO shift is open site-wide — an operator
* must open a shift before any payment/exit can be attributed to a shift. */
export class NoShiftOpenError extends Error {
constructor() {
super("no shift is open — open a shift before processing tickets");
this.name = "NoShiftOpenError";
}
}
export interface ShiftReport { export interface ShiftReport {
readonly operator: string; readonly operator: string;
@@ -31,9 +46,25 @@ export interface ShiftReport {
readonly cardTotalMinor: number; readonly cardTotalMinor: number;
readonly currency: string | null; readonly currency: string | null;
readonly paymentCount: number; readonly paymentCount: number;
// --- Drawer (physical cash till; carries across shifts) ---
/** Cash in the drawer at shift start = prior shift's expected closing drawer. */
readonly openingFloatMinor: number;
/** Admin cash LOADED into the drawer during the shift (sum of + movements). */
readonly cashAddedMinor: number;
/** Admin cash REMOVED from the drawer during the shift (sum of − movements, as +). */
readonly cashRemovedMinor: number;
/** Expected drawer at close = opening + cashTaken + added − removed. Carries forward. */
readonly expectedDrawerMinor: number;
readonly printed: boolean; readonly printed: boolean;
} }
export class InvalidCashMovementError extends Error {
constructor(msg: string) {
super(msg);
this.name = "InvalidCashMovementError";
}
}
export class ShiftService { export class ShiftService {
readonly #db: Db; readonly #db: Db;
readonly #log: EventLog; readonly #log: EventLog;
@@ -45,6 +76,12 @@ export class ShiftService {
this.#logger = logger; this.#logger = logger;
} }
/** Current physical drawer balance (cash payments + cash_movements, by time). For
* the UI to show "inherited / in the drawer now". */
drawerBalance(): { balanceMinor: number; currency: string | null } {
return this.#drawerBalanceAt(new Date().toISOString());
}
/** Is there an open shift for this operator? Returns the open `shift_open` row or null. */ /** Is there an open shift for this operator? Returns the open `shift_open` row or null. */
openShiftFor(operator: string) { openShiftFor(operator: string) {
// Scan shift events for this operator; the shift is open if the most recent // Scan shift events for this operator; the shift is open if the most recent
@@ -60,19 +97,116 @@ export class ShiftService {
return last && last.type === "shift_open" ? last : null; return last && last.type === "shift_open" ? last : null;
} }
/** Open a shift for the operator (explicit start). */ /**
async open(operator: string): Promise<{ startedAt: string }> { * The SINGLE site-wide open shift, or null. A shift is a site-wide accountability
if (this.openShiftFor(operator)) throw new ShiftAlreadyOpenError(operator); * period: at most ONE may be open at a time (so booth takings are unambiguously
* attributed to one operator). It's open iff the most recent shift event on the
* whole chain is a `shift_open` (the matching `shift_z_report` hasn't been
* appended yet). Returns that row so callers can read its operator/startedAt.
*/
currentOpenShift() {
const rows = this.#db
.select()
.from(ledgerEvents)
.orderBy(ledgerEvents.index)
.all()
.filter((r) => r.type === "shift_open" || r.type === "shift_z_report");
const last = rows[rows.length - 1];
return last && last.type === "shift_open" ? last : null;
}
/** Require an open shift for the booth money path; returns it or throws. */
requireOpenShift() {
const open = this.currentOpenShift();
if (!open) throw new NoShiftOpenError();
return open;
}
/**
* The physical drawer balance at `at`: a fold over the SIGNED chain BY TIME (not
* by operator — a cash_movement is the admin's, not the shift operator's). Cash
* payments add to the drawer; card payments never touch it; cash_movement amounts
* (signed: + load, − removal) adjust it. This is what carries across shifts.
*/
#drawerBalanceAt(at: string): { balanceMinor: number; currency: string | null } {
const rows = this.#db
.select()
.from(ledgerEvents)
.orderBy(ledgerEvents.index)
.all()
.filter((r) => r.occurredAt <= at && (r.type === "payment" || r.type === "cash_movement"));
let balanceMinor = 0;
let currency: string | null = null;
for (const r of rows) {
const pl = (r.payload ?? {}) as LedgerPayload;
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
if (r.type === "payment") {
// Only CASH enters the till; card settles to the bank.
if (pl.tender !== "card") balanceMinor += amt;
} else {
// cash_movement amount is signed (+ load, − removal).
balanceMinor += amt;
}
if (pl.currency) currency = pl.currency;
}
return { balanceMinor, currency };
}
/**
* Record an admin cash movement (load/remove drawer float). `amountMinor` is
* signed: positive = cash loaded IN, negative = cash taken OUT. Signed +
* attributed. Admin-only is enforced at the route. Returns the new drawer balance.
*/
async recordCashMovement(
operator: string,
amountMinor: number,
reason: string,
currency?: string,
): Promise<{ amountMinor: number; balanceMinor: number }> {
if (!Number.isInteger(amountMinor) || amountMinor === 0) {
throw new InvalidCashMovementError("amountMinor must be a non-zero integer (minor units)");
}
const now = new Date().toISOString();
await this.#log.append({
type: "cash_movement",
source: "manual",
identity: operator, // who moved the cash (admin)
payload: {
amountMinor,
...(reason ? { reason } : {}),
...(currency ? { currency } : {}),
operator,
},
occurredAt: now,
});
const { balanceMinor } = this.#drawerBalanceAt(now);
this.#logger.info(
`cash_movement ${amountMinor >= 0 ? "+" : ""}${amountMinor} by ${operator} (${reason || "no reason"}) → drawer ${balanceMinor}`,
);
return { amountMinor, balanceMinor };
}
/** Open a shift for the operator (explicit start). The opening float is auto-
* inherited from the chain = the drawer balance at the start instant. */
async open(operator: string): Promise<{ startedAt: string; openingFloatMinor: number }> {
// Site-wide single-open invariant: refuse if ANY shift is open — whether this
// operator's own (double-open) or another operator's (handover not done). Only
// one accountability period at a time.
const current = this.currentOpenShift();
if (current) throw new ShiftAlreadyOpenError(operator, current.identity ?? operator);
const startedAt = new Date().toISOString(); const startedAt = new Date().toISOString();
const { balanceMinor: openingFloatMinor } = this.#drawerBalanceAt(startedAt);
await this.#log.append({ await this.#log.append({
type: "shift_open", type: "shift_open",
source: "manual", source: "manual",
identity: operator, // the shift's operator; `identity` keys the shift to them identity: operator, // the shift's operator; `identity` keys the shift to them
payload: { operator }, // Record the inherited opening float on the shift_open so it's reproducible
// and the next operator's handover figure is fixed in the chain.
payload: { operator, openingFloatMinor },
occurredAt: startedAt, occurredAt: startedAt,
}); });
this.#logger.info(`shift opened for ${operator}`); this.#logger.info(`shift opened for ${operator} (opening float ${openingFloatMinor})`);
return { startedAt }; return { startedAt, openingFloatMinor };
} }
/** Close the operator's open shift: sum payments in the window, sign + print the Z-report. */ /** Close the operator's open shift: sum payments in the window, sign + print the Z-report. */
@@ -102,6 +236,50 @@ export class ShiftService {
if (pl.currency) currency = pl.currency; if (pl.currency) currency = pl.currency;
} }
// --- Drawer figures ---
// Opening float was fixed on shift_open (inherited from the chain at start);
// fall back to a fresh fold if an older shift_open lacks it.
const openPl = (open.payload ?? {}) as LedgerPayload & { openingFloatMinor?: number };
const openingFloatMinor =
typeof openPl.openingFloatMinor === "number"
? openPl.openingFloatMinor
: this.#drawerBalanceAt(startedAt).balanceMinor;
// Cash movements within the shift window, split into added (+) and removed (−).
const movements = this.#db
.select()
.from(ledgerEvents)
.where(eq(ledgerEvents.type, "cash_movement"))
.all()
.filter((r) => r.occurredAt >= startedAt && r.occurredAt <= endedAt);
let cashAddedMinor = 0;
let cashRemovedMinor = 0;
for (const m of movements) {
const pl = (m.payload ?? {}) as LedgerPayload;
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
if (amt >= 0) cashAddedMinor += amt;
else cashRemovedMinor += -amt; // store as a positive magnitude
if (pl.currency) currency = pl.currency;
}
// Expected drawer at close = opening + cash taken + added − removed. This is the
// figure the NEXT shift inherits as its opening float.
const expectedDrawerMinor = openingFloatMinor + cashTotalMinor + cashAddedMinor - cashRemovedMinor;
const report: Omit<ShiftReport, "printed"> = {
operator,
startedAt,
endedAt,
cashTotalMinor,
cardTotalMinor,
currency,
paymentCount: payments.length,
openingFloatMinor,
cashAddedMinor,
cashRemovedMinor,
expectedDrawerMinor,
};
await this.#log.append({ await this.#log.append({
type: "shift_z_report", type: "shift_z_report",
source: "manual", source: "manual",
@@ -114,23 +292,20 @@ export class ShiftService {
cardTotalMinor, cardTotalMinor,
currency: currency ?? undefined, currency: currency ?? undefined,
paymentCount: payments.length, paymentCount: payments.length,
openingFloatMinor,
cashAddedMinor,
cashRemovedMinor,
expectedDrawerMinor,
}, },
}); });
const printed = await this.#printZReport({ const printed = await this.#printZReport(report);
operator,
startedAt,
endedAt,
cashTotalMinor,
cardTotalMinor,
currency,
paymentCount: payments.length,
});
this.#logger.info( this.#logger.info(
`shift closed for ${operator}: cash ${cashTotalMinor} card ${cardTotalMinor} (${payments.length} payments)`, `shift closed for ${operator}: cash ${cashTotalMinor} card ${cardTotalMinor} (${payments.length} payments); ` +
`drawer open ${openingFloatMinor} +${cashAddedMinor} −${cashRemovedMinor} → expected ${expectedDrawerMinor}`,
); );
return { operator, startedAt, endedAt, cashTotalMinor, cardTotalMinor, currency, paymentCount: payments.length, printed }; return { ...report, printed };
} }
/** Print the Z-report on a booth-receipt printer (best-effort; the signed event /** Print the Z-report on a booth-receipt printer (best-effort; the signed event
@@ -151,9 +326,16 @@ export class ShiftService {
`Payments: ${r.paymentCount}`, `Payments: ${r.paymentCount}`,
`Cash: ${money(r.cashTotalMinor)} ${cur}`, `Cash: ${money(r.cashTotalMinor)} ${cur}`,
`Card: ${money(r.cardTotalMinor)} ${cur}`, `Card: ${money(r.cardTotalMinor)} ${cur}`,
"",
"-- Drawer --",
`Opening float: ${money(r.openingFloatMinor)} ${cur}`,
`Cash taken: ${money(r.cashTotalMinor)} ${cur}`,
`Cash added: ${money(r.cashAddedMinor)} ${cur}`,
`Cash removed: ${money(r.cashRemovedMinor)} ${cur}`,
`Expected drawer: ${money(r.expectedDrawerMinor)} ${cur}`,
]; ];
try { try {
await printer.printReport({ title: "SHIFT Z-REPORT", lines }); await printer.printReport({ title: "RAPORT TURNI", lines });
return true; return true;
} catch (err) { } catch (err) {
this.#logger.warn(`Z-report print failed for ${r.operator}: ${(err as Error).message} (event recorded)`); this.#logger.warn(`Z-report print failed for ${r.operator}: ${(err as Error).message} (event recorded)`);
+27
View File
@@ -58,3 +58,30 @@ export function buildSigner(log?: { warn: (msg: string) => void }): Signer {
"event signing: no signing key. Set EVENT_SIGNING_KEY (>=16 chars) for the append-only event chain.", "event signing: no signing key. Set EVENT_SIGNING_KEY (>=16 chars) for the append-only event chain.",
); );
} }
/**
* Resolve the signer that can VERIFY an existing event, by its stored `keyId`.
* Appends always use the one signer from buildSigner(), but a chain can contain
* events signed under different keys across a rotation (e.g. the JWT_SECRET
* fallback before a dedicated EVENT_SIGNING_KEY was set, or an ATECC608 swap).
* Each event stores its own `keyId`, so verifyChain() must check each row against
* the key that produced it — not the current append-signer. Returns undefined for
* an unknown keyId (the key is gone / not configured), which verifyChain surfaces
* as a distinct failure rather than a false "tampered" alarm.
*
* TODO(atecc608): add an "atecc608-slotN" case returning a public-key verifier.
*/
export function buildVerifier(keyId: string): Signer | undefined {
switch (keyId) {
case "sw-hmac-v2": {
const k = process.env.EVENT_SIGNING_KEY;
return k && k.length >= 16 ? new SoftwareSigner(k, "sw-hmac-v2") : undefined;
}
case "sw-hmac-jwtfallback": {
const k = process.env.JWT_SECRET;
return k && k.length >= 16 ? new SoftwareSigner(k, "sw-hmac-jwtfallback") : undefined;
}
default:
return undefined;
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ import { devicesByDirection, type FlowDirection } from "./device-resolve.js";
interface SnapshotJob { interface SnapshotJob {
readonly db: Db; readonly db: Db;
readonly direction: FlowDirection; readonly direction: FlowDirection;
/** Session/credential ref (ticket id, plate, permit car key) — links to the ledger. */ /** Session/credential ref (ticket id, plate, subscription car key) — links to the ledger. */
readonly identity: string; readonly identity: string;
readonly logger: FastifyBaseLogger; readonly logger: FastifyBaseLogger;
} }
+271
View File
@@ -0,0 +1,271 @@
import { randomUUID } from "node:crypto";
import {
eq,
ledgerEvents,
sessions,
subscriptionCredentials,
subscriptionPlates,
subscriptions,
type Db,
type DeviceRow,
} from "@parking/db";
import { registry, type AccessControlDevice } from "@parking/devices";
import type { FastifyBaseLogger } from "fastify";
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
import type { EventLog } from "./event-log.js";
import { type FlowDirection, type ResolvedRelay } from "./device-resolve.js";
import { snapshotAsync } from "./snapshot.js";
// SUBSCRIPTION flow: a subscriber identified by card/QR/plate enters/exits without
// paying per stay (they're on a recurring plan). Reached from the read dispatcher
// when a read matches a subscription (not an open ticket). See
// wiki/entities/subscription.md.
//
// Two optional, independent bindings:
// - car-count: `maxConcurrent` (default 1, null = unbound) — how many of the
// subscription's cars may be inside at once; enforced over the session projection.
// - plate: optional `plates[]` — when set, a matching plate is an accepted identity
// too (card/QR OR plate). When unset, any car may use the subscription's card/QR.
//
// Direction is inferred from the SUBSCRIPTION's open-session state, NOT the specific
// credential read — so ANY of a subscription's credentials (QR / RFID / NFC / plate)
// may open or close a session. Entry mints a fresh per-occurrence session id (the
// ledger `identity`); a read with no open occurrence → ENTRY; with ≥1 open → EXIT the
// OLDEST open occurrence (FIFO). A fleet (maxConcurrent > 1) thus has several open
// occurrences at once; each read closes one. This decouples exit from the entry
// credential (you can enter with QR and leave with the card).
//
// NB: the SIGNED ledger payload still carries `permitId` (immutable history — see the
// schema note); the per-occurrence `identity` is the session key. The mutable master
// data / code is "subscription"; the on-chain field name is left as-is so historical
// events keep verifying.
export interface SubscriptionMatch {
readonly subscriptionId: string;
/** The specific credential/plate value read (for logging/anomalies). NOT the
* session key — sessions are keyed by subscription occurrence, so a different
* credential of the same subscription can close the session it opened. */
readonly carKey: string;
readonly via: "card" | "qr" | "plate";
}
export class SubscriptionFlow {
readonly #db: Db;
readonly #log: EventLog;
readonly #logger: FastifyBaseLogger;
readonly #inFlight = new Set<string>();
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
this.#db = db;
this.#log = log;
this.#logger = logger;
}
/** Resolve a read to a subscription (by card/QR credential, or a bound plate), or null. */
match(e: DeviceReadEvent): SubscriptionMatch | null {
// Card / QR / generic credential value.
const cred = this.#db
.select()
.from(subscriptionCredentials)
.where(eq(subscriptionCredentials.value, e.value))
.get();
if (cred) {
return { subscriptionId: cred.subscriptionId, carKey: e.value, via: cred.kind === "qr" ? "qr" : "card" };
}
// Plate binding: a read plate that matches a subscription's bound plate is an identity.
if (e.kind === "plate") {
const plate = this.#db.select().from(subscriptionPlates).where(eq(subscriptionPlates.plate, e.value)).get();
if (plate) return { subscriptionId: plate.subscriptionId, carKey: e.value, via: "plate" };
}
return null;
}
/** Run the subscription entry/exit for a matched read at a barrier. `resolved` is the
* reader's bound relay; its direction constrains, "both" defers to session state. */
async run(resolved: ResolvedRelay, e: DeviceReadEvent, m: SubscriptionMatch): Promise<ReadOutcome> {
const key = `${m.subscriptionId}:${m.carKey}`;
if (this.#inFlight.has(key)) return { accepted: false, reason: "duplicate read in flight" };
this.#inFlight.add(key);
try {
return await this.#run(resolved, e, m);
} catch (err) {
this.#logger.error(`subscription-flow failed: ${(err as Error).message}`);
return { accepted: false, reason: (err as Error).message };
} finally {
this.#inFlight.delete(key);
}
}
async #run(resolved: ResolvedRelay, e: DeviceReadEvent, m: SubscriptionMatch): Promise<ReadOutcome> {
const sub = this.#db.select().from(subscriptions).where(eq(subscriptions.id, m.subscriptionId)).get();
if (!sub) return { accepted: false, reason: "subscription not found" };
// Validity: active + within the coverage window.
const now = new Date().toISOString();
const invalid =
sub.status !== "active" ||
(sub.validFrom != null && now < sub.validFrom) ||
(sub.validTo != null && now > sub.validTo);
if (invalid) {
const reason = `subscription ${sub.status}/out-of-window`;
await this.#reject(m, reason);
return { accepted: false, reason };
}
// Direction: the BARRIER the reader sits at decides the verb — an entry-lane read
// is an ENTRY, an exit-lane read is an EXIT. (The credential is decoupled from the
// session, so we can't and needn't infer from "which credential".) A "both" barrier
// has no physical side, so there we infer from state: open occurrence → exit, else
// entry. This is what lets a FLEET admit several cars (each entry-lane read is an
// entry) yet exit any of them with ANY credential (FIFO).
const open = this.#openOccurrences(m.subscriptionId);
const verb: FlowDirection =
resolved.direction === "entry"
? "entry"
: resolved.direction === "exit"
? "exit"
: open.length > 0
? "exit"
: "entry";
const source = m.via === "plate" ? "lpr" : m.via === "qr" ? "qr" : "wiegand";
if (verb === "exit") {
// EXIT: close the OLDEST open occurrence (FIFO). Its occurrence id is the session
// key; the credential just read may differ from the one that opened it. If the
// subscription has NOTHING open, an exit read is a no-op anti-passback signal.
const oldest = open[0];
if (!oldest) {
const reason = "subscription exit with no open session (already out / never entered)";
await this.#reject(m, reason);
return { accepted: false, direction: "exit", reason };
}
const occurrenceId = oldest.identity;
await this.#log.append({
type: "vehicle_exit",
direction: "exit",
source,
identity: occurrenceId,
// `permitId` carries the subscription id; `via` records which credential left.
payload: { sessionRef: occurrenceId, permitId: m.subscriptionId, via: m.via },
});
await this.#open(resolved, "exit", occurrenceId, "subscription exit");
this.#closeCache(occurrenceId);
return { accepted: true, direction: "exit" };
}
// ENTRY: enforce the car-count binding (maxConcurrent), then sign + open. Mint a
// fresh per-occurrence id so a fleet can have several open at once.
if (sub.maxConcurrent != null && open.length >= sub.maxConcurrent) {
const reason = `subscription at capacity (${open.length}/${sub.maxConcurrent} cars in)`;
await this.#reject(m, reason);
return { accepted: false, direction: "entry", reason };
}
// A short, unique occurrence id. The subscription id is NOT embedded — it rides in
// the payload's `permitId` (which every fold matches on), so the key stays compact.
const occurrenceId = `SUBSESS-${randomUUID().replace(/-/g, "").slice(0, 12)}`;
await this.#log.append({
type: "vehicle_entry",
direction: "entry",
source,
identity: occurrenceId,
// No ticket, no fee — the subscription IS the authorization. Recorded for audit.
// `permitId`/`permit` are the on-chain field names (immutable).
payload: { sessionRef: occurrenceId, permitId: m.subscriptionId, permit: true, via: m.via },
occurredAt: now,
});
await this.#open(resolved, "entry", occurrenceId, "subscription entry");
try {
this.#db
.insert(sessions)
.values({
id: occurrenceId,
identity: occurrenceId,
source: m.via === "plate" ? "lpr" : "wiegand",
subscriptionId: m.subscriptionId,
enteredAt: now,
state: "open",
})
.run();
} catch (err) {
this.#logger.error(`session-cache insert failed for ${occurrenceId}: ${(err as Error).message}`);
}
return { accepted: true, direction: "entry" };
}
/**
* The OPEN occurrences of a subscription right now, **oldest first** (FIFO) — a
* fold over the signed ledger. An occurrence is a `vehicle_entry` (whose
* `payload.permitId` is this subscription) with no later `vehicle_exit` on the same
* `identity`. Used to (a) infer entry vs. exit for ANY credential of the
* subscription, (b) pick which occurrence a read closes, and (c) enforce
* `maxConcurrent`. The on-chain field is `permitId`, so we match against that.
*/
#openOccurrences(subscriptionId: string): { identity: string; index: number }[] {
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
// Net entries−exits per occurrence identity, keeping the entry order (oldest first).
const net = new Map<string, number>();
const firstIndex = new Map<string, number>();
for (const r of rows) {
const id = r.identity;
if (!id) continue;
const pl = (r.payload ?? {}) as { permitId?: string };
if (r.type === "vehicle_entry") {
if (pl.permitId !== subscriptionId) continue;
net.set(id, (net.get(id) ?? 0) + 1);
if (!firstIndex.has(id)) firstIndex.set(id, r.index);
} else if (r.type === "vehicle_exit") {
if (!net.has(id)) continue; // not one of this subscription's occurrences
net.set(id, (net.get(id) ?? 0) - 1);
}
}
const open: { identity: string; index: number }[] = [];
for (const [id, n] of net) if (n > 0) open.push({ identity: id, index: firstIndex.get(id) ?? 0 });
open.sort((a, b) => a.index - b.index); // oldest first → FIFO
return open;
}
async #reject(m: SubscriptionMatch, reason: string): Promise<void> {
await this.#log.append({
type: "anomaly",
identity: m.carKey,
// `permitId`/`permitRefused` are the on-chain field names (immutable).
payload: { reason: `subscription refused — ${reason}`, permitId: m.subscriptionId, permitRefused: true },
});
this.#logger.warn(`subscription refused (${m.carKey}): ${reason}`);
}
async #open(resolved: ResolvedRelay, dir: FlowDirection, carKey: string, what: string): Promise<void> {
const access = this.#buildAccess(resolved.controller);
if (access) await access.pulseOpen(resolved.relay);
else this.#logger.warn(`${what} signed for ${carKey} but the ${dir} relay won't build`);
// SNAPSHOT — fire the directional camera(s), never awaited (evidence, not a gate).
void snapshotAsync({
db: this.#db,
direction: dir,
identity: carKey,
logger: this.#logger,
}).catch((err) => this.#logger.error(`subscription snapshot error: ${(err as Error).message}`));
}
#closeCache(carKey: string): void {
try {
this.#db.update(sessions).set({ exitedAt: new Date().toISOString(), state: "closed" }).where(eq(sessions.id, carKey)).run();
} catch (err) {
this.#logger.error(`session-cache close failed for ${carKey}: ${(err as Error).message}`);
}
}
/** Build a live access adapter from a resolved controller row, or null. */
#buildAccess(row: DeviceRow): AccessControlDevice | null {
const driver = registry.get(row.driverId);
if (!driver) return null;
try {
return driver.create(row.config as never) as AccessControlDevice;
} catch {
return null;
}
}
}
+12 -1
View File
@@ -12,13 +12,24 @@
}, },
"dependencies": { "dependencies": {
"@parking/shared": "workspace:*", "@parking/shared": "workspace:*",
"@radix-ui/react-dialog": "^1.1.17",
"@radix-ui/react-dropdown-menu": "^2.1.18",
"@radix-ui/react-tabs": "^1.1.15",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-router": "^1.170.16",
"i18next": "^26.3.1",
"react": "19.2.7", "react": "19.2.7",
"react-dom": "19.2.7" "react-dom": "19.2.7",
"react-i18next": "^17.0.8",
"zustand": "^5.0.14"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.3.1",
"@tanstack/react-router-devtools": "^1.167.0",
"@types/react": "19.2.17", "@types/react": "19.2.17",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.2", "@vitejs/plugin-react": "6.0.2",
"tailwindcss": "^4.3.1",
"typescript": "6.0.3", "typescript": "6.0.3",
"vite": "8.0.16" "vite": "8.0.16"
} }
+132
View File
@@ -0,0 +1,132 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchActiveSessions, reopenBarrier, type ActiveSession } from "./api.js";
import { qk } from "./lib/query.js";
import { useShift } from "./lib/use-shift.js";
import { formatDuration, formatTime } from "./lib/format.js";
import { Panel } from "./ui/Panel.js";
// Active Sessions panel. A session is "active" while still inside OR exited-but-
// within-grace (the barrier is UNCONFIRMED, so a paid/exited car is presumed
// possibly-present until grace runs out). Lets the operator find a stuck car —
// damaged ticket, dead scanner, or a phantom barrier re-close — without a scan:
// - click a row → the pay/exit modal (pay an unpaid car, or review),
// - "Open barrier" (PAID sessions only) → an audited human-intervention re-pulse.
// No payment → no Open barrier button (the no-unpaid-bypass rule).
// See wiki/concepts/booth-exit-flow.md.
function statusBadge(s: ActiveSession): { key: string; cls: string } {
if (s.subscription) return { key: "booth.badgeSubscription", cls: "text-term-cyan" };
if (!s.open && s.withinGrace) return { key: "booth.badgeExiting", cls: "text-term-cyan" };
if (s.paidAt) return { key: "booth.badgePaid", cls: "text-term-green" };
return { key: "booth.badgeUnpaid", cls: "text-term-amber" };
}
export function ActiveSessions({ onPick }: { onPick: (identity: string) => void }) {
const { t } = useTranslation();
const qc = useQueryClient();
// The audited barrier re-open is a money-path action (server-gated on an open
// shift); disable it unless this operator's shift is open.
const { isOpen: shiftOpen, isMine: shiftMine } = useShift();
const shiftReady = shiftOpen && shiftMine;
const { data, isLoading } = useQuery({
queryKey: qk.activeSessions,
queryFn: fetchActiveSessions,
// Belt-and-braces refresh in case a grace window expires with no ledger event
// to invalidate the cache (the WS only pushes on appends).
refetchInterval: 15_000,
});
const reopen = useMutation({
mutationFn: (identity: string) => reopenBarrier(identity),
onSettled: () => {
void qc.invalidateQueries({ queryKey: qk.activeSessions });
void qc.invalidateQueries({ queryKey: qk.events });
},
});
const [reopenMsg, setReopenMsg] = useState<{ id: string; text: string; ok: boolean } | null>(null);
const sessions = data?.sessions ?? [];
async function handleReopen(s: ActiveSession) {
setReopenMsg(null);
try {
const r = await reopen.mutateAsync(s.identity);
setReopenMsg({
id: s.identity,
ok: r.opened,
text: r.opened ? t("booth.barrierOpened") : r.reason ?? t("booth.openManually"),
});
} catch (e) {
setReopenMsg({ id: s.identity, ok: false, text: (e as Error).message });
}
}
return (
<Panel
title={t("booth.activeSessions")}
right={
<span className="text-[10px] uppercase tracking-wider text-term-muted">
{sessions.length} {t("booth.insideCount")}
</span>
}
className="min-h-0"
>
<div className="h-full overflow-y-auto pr-1">
{sessions.length === 0 ? (
<div className="text-term-muted">{isLoading ? t("common.loading") : t("booth.noActiveSessions")}</div>
) : (
sessions.map((s) => {
const badge = statusBadge(s);
const msg = reopenMsg?.id === s.identity ? reopenMsg : null;
return (
<div
key={s.identity}
className="flex items-center gap-3 border-b border-term-border/50 py-1.5 text-[12px] tabular-nums"
>
<button
type="button"
onClick={() => onPick(s.identity)}
className="flex flex-1 items-center gap-3 text-left hover:text-term-amber"
title={t("booth.openPayExit")}
>
<span className="text-term-text">
{s.subscription ? `★ ${s.subscriptionHolder ?? t("subs.unnamed")}` : s.identity}
</span>
<span className="text-term-muted">
{t("booth.inAt")} {formatTime(s.enteredAt)}
</span>
<span className="text-term-muted">{formatDuration(s.enteredAt, new Date().toISOString())}</span>
<span className={`ml-auto w-16 text-right font-semibold uppercase ${badge.cls}`}>{t(badge.key)}</span>
</button>
{/* Open barrier — PAID transient OR a SUBSCRIPTION (prepaid). An
unpaid transient has no button (no-unpaid-bypass). */}
{s.paidAt || s.subscription ? (
<button
type="button"
disabled={reopen.isPending || !shiftReady}
onClick={() => handleReopen(s)}
className="shrink-0 rounded-term border border-term-cyan px-2 py-0.5 text-[10px] uppercase tracking-wider text-term-cyan hover:bg-term-cyan/10 disabled:opacity-50"
title={shiftReady ? t("booth.openBarrierTitle") : t("shift.gateTitle")}
>
{t("booth.openBarrier")}
</button>
) : (
<span className="w-[88px] shrink-0" />
)}
{msg && (
<span className={`shrink-0 text-[10px] ${msg.ok ? "text-term-green" : "text-term-red"}`}>
{msg.text}
</span>
)}
</div>
);
})
)}
</div>
</Panel>
);
}
+29 -39
View File
@@ -1,15 +1,16 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { fetchMe, logout, type SessionUser } from "./api.js"; import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider } from "@tanstack/react-router";
import { fetchMe, type SessionUser } from "./api.js";
import { Login } from "./Login.js"; import { Login } from "./Login.js";
import { PermitManager } from "./PermitManager.js"; import { queryClient } from "./lib/query.js";
import { SetupWizard } from "./SetupWizard.js"; import { setLanguage } from "./lib/i18n/index.js";
import { ShiftControl } from "./ShiftControl.js"; import { router } from "./router.js";
import { SiteSettings } from "./SiteSettings.js";
import { TariffComposer } from "./TariffComposer.js";
// Operator UI shell. Plain React (no admin framework) — the operator UI is // App root: bootstraps the session (cookie-based, from /api/auth/me), then hands
// simple enough that a framework's abstractions cost more than they save. // off to TanStack Router inside the QueryClient provider. The router renders the
// Auth is cookie-based; the SPA bootstraps the session from /api/auth/me. // terminal chrome + screens; auth gating stays here (Login until signed in), and
// the signed-in user flows into the router context for role-based route guards.
// See wiki/entities/react-vite-spa.md and local-jwt-auth.md. // See wiki/entities/react-vite-spa.md and local-jwt-auth.md.
export function App() { export function App() {
@@ -22,37 +23,26 @@ export function App() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
if (loading) return <p style={{ fontFamily: "system-ui", padding: "2rem" }}>Loading…</p>; // Apply the signed-in user's preferred language whenever it resolves/changes
if (!user) return <Login onLoggedIn={setUser} />; // (login, bootstrap, or a toggle). Albanian is the default before auth resolves.
useEffect(() => {
if (user) setLanguage(user.language);
}, [user]);
if (loading) {
return <div className="flex h-screen items-center justify-center text-term-muted">loading…</div>;
}
if (!user) {
return (
<QueryClientProvider client={queryClient}>
<Login onLoggedIn={setUser} />
</QueryClientProvider>
);
}
return ( return (
<main style={{ fontFamily: "system-ui", padding: "2rem", maxWidth: 720 }}> <QueryClientProvider client={queryClient}>
<header style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline" }}> <RouterProvider router={router} context={{ user, setUser }} />
<h1 style={{ margin: 0 }}>Parking System</h1> </QueryClientProvider>
<span style={{ color: "#555" }}>
{user.username} ({user.role}){" "}
<button
type="button"
onClick={async () => {
await logout();
setUser(null);
}}
>
Log out
</button>
</span>
</header>
<SiteSettings canEdit={user.role === "admin"} />
{user.role !== "readonly" && <ShiftControl />}
{user.role === "admin" ? (
<>
<SetupWizard />
<TariffComposer />
<PermitManager />
</>
) : (
<p style={{ marginTop: "1rem" }}>Signed in. (Operator console coming soon.)</p>
)}
</main>
); );
} }
+381
View File
@@ -0,0 +1,381 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import * as Dialog from "@radix-ui/react-dialog";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
boothExit,
fetchSiteConfig,
lookupSession,
openShift,
paySession,
printReceipt,
printVoucher,
reopenBarrier,
type SessionLookup,
} from "./api.js";
import { qk } from "./lib/query.js";
import { useShift } from "./lib/use-shift.js";
import { formatDuration, formatMoney, formatTime } from "./lib/format.js";
import { SnapshotStrip } from "./ui/SnapshotStrip.js";
// The booth pay/exit modal. Opened when the operator submits a ticket id. Shows the
// session (entry, exit=now, duration, total owed) + entry/exit snapshots, takes
// payment, then EITHER prints an exit voucher (customer self-exits at a distant
// exit) OR fires the exit immediately (booth at/near the exit) — controlled by a
// checkbox defaulting from site_config.exitVoucherDefault. See booth-exit-flow.md.
type Phase = "review" | "paying" | "finishing" | "done" | "error";
export function BoothPayModal({ identity, onClose }: { identity: string; onClose: () => void }) {
const { t } = useTranslation();
const qc = useQueryClient();
const session = useQuery({ queryKey: ["session", identity], queryFn: () => lookupSession(identity) });
const config = useQuery({ queryKey: qk.siteConfig, queryFn: fetchSiteConfig });
// A shift must be open (and mine) before any pay/exit/voucher action — the booth
// money path is gated. The server enforces this too (409 no_shift); the modal
// surfaces it up front and offers a one-click open. See wiki/concepts/shift.md.
const { isOpen: shiftOpen, isMine: shiftMine, blockedByOther, heldBy } = useShift();
const shiftReady = shiftOpen && shiftMine;
const [tender, setTender] = useState<"cash" | "card">("cash");
const [printVoucherChecked, setPrintVoucherChecked] = useState<boolean | null>(null);
const [phase, setPhase] = useState<Phase>("review");
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<string | null>(null);
const [openingShift, setOpeningShift] = useState(false);
const [reprinting, setReprinting] = useState(false);
const s: SessionLookup | undefined = session.data;
// Checkbox default comes from config the first time it loads; operator can toggle.
const voucher = printVoucherChecked ?? config.data?.exitVoucherDefault ?? false;
const alreadyPaid = s?.paidAt != null;
const isSubscription = s?.subscription === true;
// A subscription is prepaid: never charged. The only booth action is an audited
// barrier open to ASSIST (faulty exit reader / lost card). Transient pay path is off.
const canPay = shiftReady && s?.found && s.open && !alreadyPaid && !isSubscription;
async function handleOpenBarrier() {
if (!s) return;
setError(null);
setPhase("finishing");
try {
const r = await reopenBarrier(identity);
setResult(r.opened ? t("pay.subBarrierOpened") : t("pay.paidExitRecorded", { reason: r.reason ?? t("booth.openManually") }));
void qc.invalidateQueries({ queryKey: qk.events });
void qc.invalidateQueries({ queryKey: qk.activeSessions });
setPhase("done");
} catch (e) {
setError((e as Error).message);
setPhase("error");
}
}
async function handleOpenShift() {
setOpeningShift(true);
setError(null);
try {
await openShift();
void qc.invalidateQueries({ queryKey: qk.shift });
void qc.invalidateQueries({ queryKey: qk.events });
} catch (e) {
setError((e as Error).message);
} finally {
setOpeningShift(false);
}
}
async function handleReprintReceipt() {
setReprinting(true);
setError(null);
try {
const r = await printReceipt(identity);
setResult(t("pay.receiptReprinted", { printer: r.printedBy }));
} catch (e) {
setError((e as Error).message);
} finally {
setReprinting(false);
}
}
async function handlePayAndExit() {
if (!s) return;
setError(null);
try {
// 1. Take payment (unless already paid — e.g. paid earlier at a kiosk).
if (!alreadyPaid) {
setPhase("paying");
await paySession(identity, tender);
}
// 2. Voucher OR immediate exit.
setPhase("finishing");
if (voucher) {
// The voucher slip carries the payment detail + barcode + grace.
const r = await printVoucher(identity);
setResult(t("pay.voucherPrinted", { printer: r.printedBy }));
} else {
const r = await boothExit(identity);
// No voucher → auto-print a standalone payment receipt for transparency.
// Best-effort: a printer fault must NOT block the exit that already happened;
// the operator can reprint from the done screen.
let receiptNote = "";
try {
await printReceipt(identity);
} catch {
receiptNote = ` ${t("pay.receiptPrintFailed")}`;
}
setResult(
(r.opened
? t("pay.paidBarrierOpened")
: t("pay.paidExitRecorded", { reason: r.reason ?? t("booth.openManually") })) +
receiptNote,
);
}
// Refresh the live views.
void qc.invalidateQueries({ queryKey: qk.events });
void qc.invalidateQueries({ queryKey: qk.occupancy });
setPhase("done");
} catch (e) {
setError((e as Error).message);
setPhase("error");
}
}
return (
<Dialog.Root open onOpenChange={(o) => !o && onClose()}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-40 bg-black/70" />
<Dialog.Content
className="fixed left-1/2 top-1/2 z-50 w-[560px] max-w-[95vw] -translate-x-1/2 -translate-y-1/2 rounded-term border border-term-border bg-term-panel font-mono text-term-text shadow-2xl"
aria-describedby={undefined}
>
<div className="flex items-center justify-between border-b border-term-border bg-term-panel-2 px-4 py-2">
<Dialog.Title className="m-0 text-[12px] font-semibold uppercase tracking-wider text-term-amber">
{isSubscription
? `${t("pay.subscription")} · ${s?.subscriptionHolder ?? t("subs.unnamed")}`
: `${t("pay.ticket")} ${identity}`}
</Dialog.Title>
<Dialog.Close className="text-term-muted hover:text-term-text" aria-label={t("common.close")}>
✕
</Dialog.Close>
</div>
<div className="flex flex-col gap-3 p-4">
{/* Shift gate — block all actions until THIS operator has a shift open.
Another operator's open shift can't be operated under (no shared
till); only an "open mine" path when no shift is open at all. */}
{!shiftReady && (
<div className="rounded-term border border-term-amber bg-term-amber/5 px-3 py-2">
{blockedByOther ? (
<>
<div className="text-[12px] font-semibold uppercase tracking-wider text-term-amber">
{t("shift.gateOtherTitle")}
</div>
<div className="mt-1 text-[12px] text-term-text">
{t("shift.gateOtherBody", { operator: heldBy ?? "?" })}
</div>
</>
) : (
<>
<div className="text-[12px] font-semibold uppercase tracking-wider text-term-amber">
{t("shift.gateTitle")}
</div>
<div className="mt-1 text-[12px] text-term-text">{t("shift.gateBody")}</div>
<button
type="button"
onClick={handleOpenShift}
disabled={openingShift}
className="mt-2 rounded-term border border-term-green bg-term-green/10 px-3 py-1 text-[12px] font-semibold uppercase tracking-wider text-term-green disabled:opacity-50"
>
{openingShift ? t("shift.opening") : t("shift.openNow")}
</button>
</>
)}
</div>
)}
{session.isLoading && <div className="text-term-muted">{t("pay.lookingUp")}</div>}
{s && !s.found && (
<div className="rounded-term border border-term-red px-3 py-2 text-term-red">
{t("pay.noSessionFound")}
</div>
)}
{s && s.found && !s.open && (
<div className="rounded-term border border-term-amber px-3 py-2 text-term-amber">
{t("pay.alreadyClosed", { time: formatTime(s.exitedAt) })}
</div>
)}
{s && s.found && s.open && (
<>
{/* Session figures */}
<div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums">
<Row label={t("pay.entry")} value={formatTime(s.enteredAt)} />
<Row label={t("pay.now")} value={formatTime(new Date().toISOString())} />
<Row
label={t("pay.duration")}
value={s.enteredAt ? formatDuration(s.enteredAt, new Date().toISOString()) : "—"}
/>
<Row
label={t("pay.statusLabel")}
value={isSubscription ? t("pay.subscription") : alreadyPaid ? t("pay.paid") : t("pay.unpaid")}
valueClass={isSubscription ? "text-term-cyan" : alreadyPaid ? "text-term-green" : "text-term-amber"}
/>
</div>
{/* Total — a subscription is prepaid (no amount); show a badge. */}
<div className="flex items-end justify-between rounded-term bg-term-panel-2 px-3 py-2">
<span className="text-[11px] uppercase tracking-wider text-term-muted">
{isSubscription ? t("pay.plan") : t("pay.total")}
</span>
<span className="text-3xl font-bold text-term-cyan">
{isSubscription
? t("pay.prepaid")
: s.amountMinor != null && s.currency
? formatMoney(s.amountMinor, s.currency)
: alreadyPaid
? t("booth.badgePaid")
: t("pay.noTariff")}
</span>
</div>
{/* For a subscription, explain the only available action. */}
{isSubscription && (
<div className="rounded-term border border-term-cyan/40 bg-term-cyan/5 px-3 py-2 text-[12px] text-term-text">
{t("pay.subAssistHint")}
</div>
)}
{/* Snapshots */}
<SnapshotStrip identity={identity} />
{phase !== "done" && !isSubscription && (
<>
{/* Tender */}
{canPay && (
<div className="flex items-center gap-2">
<span className="text-[11px] uppercase tracking-wider text-term-muted">{t("pay.tender")}</span>
{(["cash", "card"] as const).map((tn) => (
<button
key={tn}
type="button"
onClick={() => setTender(tn)}
className={`rounded-term border px-3 py-1 text-[12px] uppercase tracking-wider ${
tender === tn
? "border-term-amber text-term-amber"
: "border-term-border text-term-muted hover:text-term-text"
}`}
>
{t(`pay.${tn}`)}
</button>
))}
</div>
)}
{/* Voucher checkbox (default from site config) */}
<label className="flex items-center gap-2 text-[12px]">
<input
type="checkbox"
checked={voucher}
onChange={(e) => setPrintVoucherChecked(e.target.checked)}
/>
{t("pay.printExitVoucher")}
<span className="text-term-muted">{t("pay.selfExitHint")}</span>
</label>
</>
)}
{error && <div className="rounded-term border border-term-red px-3 py-2 text-term-red">{error}</div>}
{result && (
<div className="rounded-term border border-term-green px-3 py-2 text-term-green">{result}</div>
)}
{/* Actions */}
<div className="flex justify-end gap-2 pt-1">
{phase === "done" ? (
<>
{/* Reprint the payment receipt (slip jammed / customer asks).
Only for a charged session — a subscription has no payment. */}
{!isSubscription && (
<button
type="button"
onClick={handleReprintReceipt}
disabled={reprinting}
className="rounded-term border border-term-border px-3 py-1.5 text-[12px] uppercase tracking-wider text-term-muted hover:text-term-text disabled:opacity-50"
>
{reprinting ? t("pay.reprinting") : t("pay.reprintReceipt")}
</button>
)}
<button
type="button"
onClick={onClose}
className="rounded-term border border-term-amber px-4 py-1.5 text-[12px] uppercase tracking-wider text-term-amber"
>
{t("common.close")}
</button>
</>
) : (
<>
<button
type="button"
onClick={onClose}
className="rounded-term border border-term-border px-3 py-1.5 text-[12px] uppercase tracking-wider text-term-muted hover:text-term-text"
>
{t("common.cancel")}
</button>
{isSubscription ? (
// Prepaid — the only action is the audited barrier open (assist
// a faulty exit reader / missing card). Gated on an open shift.
<button
type="button"
onClick={handleOpenBarrier}
disabled={!shiftReady || phase === "finishing"}
className="rounded-term border border-term-cyan bg-term-cyan/10 px-4 py-1.5 text-[12px] font-semibold uppercase tracking-wider text-term-cyan disabled:opacity-50"
>
{phase === "finishing" ? t("pay.opening") : t("booth.openBarrier")}
</button>
) : (
<button
type="button"
onClick={handlePayAndExit}
disabled={!shiftReady || phase === "paying" || phase === "finishing"}
className="rounded-term border border-term-green bg-term-green/10 px-4 py-1.5 text-[12px] font-semibold uppercase tracking-wider text-term-green disabled:opacity-50"
>
{phase === "paying"
? t("pay.takingPayment")
: phase === "finishing"
? voucher
? t("pay.printingVoucher")
: t("pay.opening")
: alreadyPaid
? voucher
? t("pay.printVoucher")
: t("pay.openBarrier")
: voucher
? t("pay.payAndVoucher")
: t("pay.payAndOpen")}
</button>
)}
</>
)}
</div>
</>
)}
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
function Row({ label, value, valueClass = "" }: { label: string; value: string; valueClass?: string }) {
return (
<div className="flex items-baseline justify-between">
<span className="text-[11px] uppercase tracking-wider text-term-muted">{label}</span>
<span className={`text-sm ${valueClass}`}>{value}</span>
</div>
);
}
+209
View File
@@ -0,0 +1,209 @@
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { fetchEvents, fetchOccupancy, type LedgerEvent, type Occupancy } from "./api.js";
import { qk } from "./lib/query.js";
import { useLiveStore } from "./lib/live-store.js";
import { useShift } from "./lib/use-shift.js";
import { Panel } from "./ui/Panel.js";
import { StatusDot } from "./ui/StatusDot.js";
import { BoothPayModal } from "./BoothPayModal.js";
import { ActiveSessions } from "./ActiveSessions.js";
// The live operator booth view — the real-time heart of the console. Occupancy
// gauge + a streaming entry/exit/payment ticker. Query owns the initial load and
// the authoritative numbers; the WS-fed live store overlays real-time updates so
// the screen reacts the instant a car enters or exits. Dense, dark, glanceable.
/** Per-event-type display: i18n label key + accent colour for the ticker. */
const EVENT_STYLE: Record<string, { labelKey: string; color: string }> = {
vehicle_entry: { labelKey: "booth.evtEntry", color: "text-term-green" },
vehicle_exit: { labelKey: "booth.evtExit", color: "text-term-red" },
payment: { labelKey: "booth.evtPay", color: "text-term-cyan" },
void: { labelKey: "booth.evtVoid", color: "text-term-amber" },
barrier_open_command: { labelKey: "booth.evtOpenCmd", color: "text-term-muted" },
barrier_open_observed: { labelKey: "booth.evtOpenObserved", color: "text-term-muted" },
shift_open: { labelKey: "booth.evtShiftOpen", color: "text-term-amber" },
shift_z_report: { labelKey: "booth.evtShiftZ", color: "text-term-amber" },
cash_movement: { labelKey: "booth.evtCashMovement", color: "text-term-cyan" },
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
};
function hhmmss(iso: string): string {
// Local time-of-day, terminal style. Defensive against a bad timestamp.
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? "--:--:--" : d.toTimeString().slice(0, 8);
}
function OccupancyGauge({ occ }: { occ: Occupancy }) {
const { t } = useTranslation();
const pct = occ.capacity ? Math.min(100, Math.round((occ.count / occ.capacity) * 100)) : null;
const barColor = occ.full ? "bg-term-red" : pct != null && pct >= 85 ? "bg-term-amber" : "bg-term-green";
return (
<div className="flex flex-col gap-3">
<div className="flex items-end gap-4">
<div className="text-6xl font-bold leading-none tabular-nums text-term-text">{occ.count}</div>
<div className="pb-1 text-term-muted">
<div className="text-[11px] uppercase tracking-wider">{t("booth.inside")}</div>
<div className="text-sm tabular-nums">
{occ.capacity == null ? t("booth.uncapped") : `${t("booth.of")} ${occ.capacity}`}
</div>
</div>
<div className="ml-auto text-right">
<div className="text-[11px] uppercase tracking-wider text-term-muted">{t("booth.free")}</div>
<div className={`text-3xl font-bold tabular-nums ${occ.full ? "text-term-red" : "text-term-green"}`}>
{occ.free == null ? "∞" : occ.free}
</div>
</div>
</div>
{pct != null && (
<div className="h-2 w-full overflow-hidden rounded-term bg-term-panel-2">
<div className={`h-full ${barColor} transition-[width] duration-300`} style={{ width: `${pct}%` }} />
</div>
)}
{occ.full && (
<div className="rounded-term border border-term-red px-2 py-1 text-center text-[11px] font-bold uppercase tracking-widest text-term-red">
{t("booth.lotFull")}
</div>
)}
</div>
);
}
function EventRow({ e }: { e: LedgerEvent }) {
const { t } = useTranslation();
const style = EVENT_STYLE[e.type];
const label = style ? t(style.labelKey) : e.type.toUpperCase();
return (
<div className="flex items-center gap-3 border-b border-term-border/50 py-1 text-[12px] tabular-nums">
<span className="text-term-muted">{hhmmss(e.occurredAt)}</span>
<span className={`w-20 shrink-0 font-semibold ${style?.color ?? "text-term-text"}`}>{label}</span>
<span className="truncate text-term-text">{e.identity ?? "—"}</span>
<span className="ml-auto text-term-muted">#{e.index}</span>
</div>
);
}
/** Ticket entry: an HID barcode scanner types the id and presses Enter; a manual
* operator types it. Either way, submit opens the pay/exit modal for that id. The
* input auto-focuses and re-focuses after a scan so the scanner always lands here. */
function TicketInput({ onSubmit }: { onSubmit: (identity: string) => void }) {
const { t } = useTranslation();
const [value, setValue] = useState("");
const ref = useRef<HTMLInputElement>(null);
return (
<form
className="flex items-center gap-2"
onSubmit={(e) => {
e.preventDefault();
const id = value.trim();
if (id) {
onSubmit(id);
setValue("");
ref.current?.focus();
}
}}
>
<input
ref={ref}
autoFocus
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={t("booth.scanPlaceholder")}
inputMode="numeric"
className="flex-1 rounded-term border border-term-border bg-term-bg px-3 py-2 text-lg tabular-nums text-term-text placeholder:text-term-muted focus:border-term-amber"
/>
<button
type="submit"
className="rounded-term border border-term-amber bg-term-amber/10 px-4 py-2 text-[12px] font-semibold uppercase tracking-wider text-term-amber"
>
{t("booth.open")}
</button>
</form>
);
}
export function BoothScreen() {
const { t } = useTranslation();
// The site-wide shift drives the log scope: the feed shows ONLY the open shift's
// window (per-shift logs, not all history). When no shift is open, the feed is
// empty and the operator is prompted to open one.
const { isOpen: shiftOpen, startedAt: shiftStart } = useShift();
// Initial load via Query (also the fallback if the WS is briefly down). The events
// query is scoped to the current shift's start so it never shows prior shifts.
const occQuery = useQuery({ queryKey: qk.occupancy, queryFn: fetchOccupancy });
const eventsQuery = useQuery({
queryKey: [...qk.events, shiftStart ?? "none"],
queryFn: () => fetchEvents(100, shiftStart ?? undefined),
enabled: shiftOpen,
});
// The ticket currently open in the pay/exit modal (null = no modal).
const [activeTicket, setActiveTicket] = useState<string | null>(null);
// Live overlays from the WS store.
const liveOcc = useLiveStore((s) => s.occupancy);
const liveFeed = useLiveStore((s) => s.feed);
// Prefer the live-pushed occupancy; fall back to the query.
const occ = liveOcc ?? occQuery.data ?? null;
// Merge: live events first (newest), then the queried history, de-duped by id —
// then clip to the current shift window (the live store spans shifts; the feed
// must not show events from before this shift's start). No shift → no feed.
const seen = new Set(liveFeed.map((e) => e.id));
const history = (eventsQuery.data?.events ?? []).filter((e) => !seen.has(e.id));
const merged = [...liveFeed, ...history].slice(0, 200);
const events =
shiftOpen && shiftStart
? merged.filter((e) => e.occurredAt >= shiftStart)
: [];
return (
<div className="grid h-full grid-cols-1 gap-3 lg:grid-cols-[minmax(320px,1fr)_2fr] lg:grid-rows-[auto_1fr]">
{/* Ticket input spans both columns at the top — the operator's primary action. */}
<div className="lg:col-span-2">
<Panel title={t("booth.processTicket")}>
<TicketInput onSubmit={setActiveTicket} />
</Panel>
</div>
{/* Left column: occupancy gauge above the active-sessions list. */}
<div className="flex min-h-0 flex-col gap-3">
<Panel title={t("booth.occupancy")} right={<StatusDot />}>
{occ ? (
<OccupancyGauge occ={occ} />
) : (
<div className="text-term-muted">{occQuery.isError ? t("booth.occUnavailable") : t("common.loading")}</div>
)}
</Panel>
<div className="min-h-0 flex-1">
<ActiveSessions onPick={setActiveTicket} />
</div>
</div>
<Panel
title={t("booth.liveFeed")}
right={
<span className="text-[10px] uppercase tracking-wider text-term-muted">
{events.length} {t("booth.events")}
</span>
}
className="min-h-0"
>
<div className="h-full overflow-y-auto pr-1">
{!shiftOpen ? (
<div className="text-term-amber">{t("shift.gateTitle")}</div>
) : events.length === 0 ? (
<div className="text-term-muted">{eventsQuery.isLoading ? t("common.loading") : t("booth.noEventsYet")}</div>
) : (
events.map((e) => <EventRow key={e.id} e={e} />)
)}
</div>
</Panel>
{activeTicket && <BoothPayModal identity={activeTicket} onClose={() => setActiveTicket(null)} />}
</div>
);
}
+6 -4
View File
@@ -1,7 +1,9 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next";
import { login, type SessionUser } from "./api.js"; import { login, type SessionUser } from "./api.js";
export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void }) { export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void }) {
const { t } = useTranslation();
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -22,11 +24,11 @@ export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void })
return ( return (
<main style={{ fontFamily: "system-ui", maxWidth: 320, margin: "4rem auto" }}> <main style={{ fontFamily: "system-ui", maxWidth: 320, margin: "4rem auto" }}>
<h1>Parking System</h1> <h1>{t("auth.title")}</h1>
<form onSubmit={submit}> <form onSubmit={submit}>
<div style={{ margin: "0.5rem 0" }}> <div style={{ margin: "0.5rem 0" }}>
<label> <label>
Username {t("auth.username")}
<br /> <br />
<input <input
value={username} value={username}
@@ -39,7 +41,7 @@ export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void })
</div> </div>
<div style={{ margin: "0.5rem 0" }}> <div style={{ margin: "0.5rem 0" }}>
<label> <label>
Password {t("auth.password")}
<br /> <br />
<input <input
type="password" type="password"
@@ -52,7 +54,7 @@ export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void })
</div> </div>
{error && <p style={{ color: "crimson" }}>{error}</p>} {error && <p style={{ color: "crimson" }}>{error}</p>}
<button type="submit" disabled={busy || !username || !password}> <button type="submit" disabled={busy || !username || !password}>
{busy ? "Signing in…" : "Sign in"} {busy ? t("auth.signingIn") : t("auth.signIn")}
</button> </button>
</form> </form>
</main> </main>
-183
View File
@@ -1,183 +0,0 @@
import { useEffect, useState } from "react";
import {
ApiError,
createPermit,
deletePermit,
fetchPermits,
revokePermit,
updatePermit,
type Permit,
type PermitCredential,
type PermitInput,
} from "./api.js";
// Permit (subscription) admin. Create/edit/revoke/delete permits + their
// credentials (card/QR) and bound plates. A permit is mutable master data; every
// USE of it is a signed ledger event elsewhere. See wiki/entities/permit.md.
interface FormState {
holderName: string;
contact: string;
carBound: boolean; // false = unbound (maxConcurrent null)
maxConcurrent: string;
validFrom: string;
validTo: string;
credentials: PermitCredential[];
platesText: string; // comma/space separated
}
function emptyForm(): FormState {
return { holderName: "", contact: "", carBound: true, maxConcurrent: "1", validFrom: "", validTo: "", credentials: [{ kind: "rf", value: "" }], platesText: "" };
}
function formFrom(p: Permit): FormState {
return {
holderName: p.holderName ?? "",
contact: p.contact ?? "",
carBound: p.maxConcurrent != null,
maxConcurrent: p.maxConcurrent != null ? String(p.maxConcurrent) : "1",
validFrom: p.validFrom ?? "",
validTo: p.validTo ?? "",
credentials: p.credentials.length ? p.credentials : [{ kind: "rf", value: "" }],
platesText: p.plates.join(", "),
};
}
function toInput(f: FormState): PermitInput {
return {
holderName: f.holderName.trim() || null,
contact: f.contact.trim() || null,
maxConcurrent: f.carBound ? Math.max(1, Math.round(Number(f.maxConcurrent) || 1)) : null,
validFrom: f.validFrom.trim() || null,
validTo: f.validTo.trim() || null,
credentials: f.credentials.filter((c) => c.value.trim()).map((c) => ({ kind: c.kind, value: c.value.trim() })),
plates: f.platesText.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean),
};
}
export function PermitManager() {
const [permits, setPermits] = useState<Permit[] | null>(null);
const [editing, setEditing] = useState<string | "new" | null>(null);
const [form, setForm] = useState<FormState>(emptyForm);
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
function reload() {
fetchPermits()
.then((r) => setPermits(r.permits))
.catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
}
useEffect(reload, []);
function startNew() {
setForm(emptyForm());
setEditing("new");
setMsg(null);
}
function startEdit(p: Permit) {
setForm(formFrom(p));
setEditing(p.id);
setMsg(null);
}
async function save() {
setMsg(null);
try {
if (editing === "new") await createPermit(toInput(form));
else if (editing) await updatePermit(editing, toInput(form));
setEditing(null);
reload();
setMsg({ kind: "ok", text: "Permit saved." });
} catch (e) {
const problems = e instanceof ApiError ? (e as ApiError & { problems?: string[] }).problems : undefined;
setMsg({ kind: "err", text: problems?.length ? `${(e as Error).message}: ${problems.join("; ")}` : (e as Error).message });
}
}
async function doRevoke(p: Permit) {
if (!confirm(`Revoke permit for ${p.holderName ?? p.id}? It will be refused at the barrier.`)) return;
await revokePermit(p.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
reload();
}
async function doDelete(p: Permit) {
if (!confirm(`Delete permit for ${p.holderName ?? p.id}? (Past events are kept.)`)) return;
await deletePermit(p.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
reload();
}
function setCred(i: number, patch: Partial<PermitCredential>) {
setForm((f) => ({ ...f, credentials: f.credentials.map((c, j) => (j === i ? { ...c, ...patch } : c)) }));
}
if (!permits) return null;
return (
<section style={{ marginTop: "2rem" }}>
<h2>Permits</h2>
<ul style={{ listStyle: "none", padding: 0 }}>
{permits.map((p) => (
<li key={p.id} style={{ display: "flex", gap: "0.5rem", alignItems: "center", padding: "0.4rem 0", borderBottom: "1px solid #eee" }}>
<strong>{p.holderName ?? "(unnamed)"}</strong>
<span style={{ color: p.status === "active" ? "#16a34a" : "#b45309" }}>{p.status}</span>
<span style={{ color: "#666" }}>
{p.maxConcurrent == null ? "unbound" : `${p.maxConcurrent} car${p.maxConcurrent > 1 ? "s" : ""}`} ·{" "}
{p.credentials.length} cred · {p.plates.length} plate(s)
</span>
<span style={{ flex: 1 }} />
<button type="button" onClick={() => startEdit(p)}>Edit</button>
{p.status !== "revoked" && <button type="button" onClick={() => doRevoke(p)}>Revoke</button>}
<button type="button" onClick={() => doDelete(p)}>Delete</button>
</li>
))}
{permits.length === 0 && <li style={{ color: "#777" }}>No permits yet.</li>}
</ul>
{editing == null ? (
<button type="button" onClick={startNew}>+ Add permit</button>
) : (
<div style={{ border: "1px solid #ddd", padding: "1rem", marginTop: "0.5rem", maxWidth: 460 }}>
<h3 style={{ marginTop: 0 }}>{editing === "new" ? "New permit" : "Edit permit"}</h3>
<div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.4rem 0.75rem", alignItems: "center" }}>
<label>Holder name</label>
<input value={form.holderName} onChange={(e) => setForm((f) => ({ ...f, holderName: e.target.value }))} />
<label>Contact</label>
<input value={form.contact} onChange={(e) => setForm((f) => ({ ...f, contact: e.target.value }))} />
<label>Car limit</label>
<span>
<label style={{ marginRight: "0.5rem" }}>
<input type="checkbox" checked={form.carBound} onChange={(e) => setForm((f) => ({ ...f, carBound: e.target.checked }))} /> limit cars in at once
</label>
{form.carBound && (
<input value={form.maxConcurrent} onChange={(e) => setForm((f) => ({ ...f, maxConcurrent: e.target.value }))} style={{ width: 50 }} />
)}
</span>
<label>Valid from</label>
<input value={form.validFrom} onChange={(e) => setForm((f) => ({ ...f, validFrom: e.target.value }))} placeholder="ISO date (optional)" />
<label>Valid to</label>
<input value={form.validTo} onChange={(e) => setForm((f) => ({ ...f, validTo: e.target.value }))} placeholder="ISO date (optional)" />
<label>Bound plates</label>
<input value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder="comma-separated (optional)" />
</div>
<h4 style={{ marginBottom: "0.25rem" }}>Credentials (card / QR)</h4>
{form.credentials.map((c, i) => (
<div key={i} style={{ display: "flex", gap: "0.4rem", marginBottom: "0.3rem" }}>
<select value={c.kind} onChange={(e) => setCred(i, { kind: e.target.value as "rf" | "qr" })}>
<option value="rf">RF card/tag</option>
<option value="qr">QR</option>
</select>
<input value={c.value} onChange={(e) => setCred(i, { value: e.target.value })} placeholder="credential value" style={{ flex: 1 }} />
<button type="button" onClick={() => setForm((f) => ({ ...f, credentials: f.credentials.filter((_, j) => j !== i) }))}>×</button>
</div>
))}
<button type="button" onClick={() => setForm((f) => ({ ...f, credentials: [...f.credentials, { kind: "rf", value: "" }] }))}>+ credential</button>
<p style={{ color: "#777", fontSize: "0.85em", margin: "0.5rem 0 0" }}>
A permit needs at least one credential OR one bound plate.
</p>
<div style={{ marginTop: "1rem", display: "flex", gap: "0.5rem" }}>
<button type="button" onClick={save}>Save</button>
<button type="button" onClick={() => setEditing(null)}>Cancel</button>
</div>
</div>
)}
{msg && <p style={{ color: msg.kind === "ok" ? "#16a34a" : "crimson" }}>{msg.text}</p>}
</section>
);
}
+86 -19
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { import {
assignDevice, assignDevice,
editDevice,
discoverDevices, discoverDevices,
fetchBackendIps, fetchBackendIps,
fetchCatalog, fetchCatalog,
@@ -127,8 +128,12 @@ function CategorySection({
onChanged: () => Promise<void> | void; onChanged: () => Promise<void> | void;
}) { }) {
const [adding, setAdding] = useState(false); const [adding, setAdding] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [warnings, setWarnings] = useState<string[]>([]); const [warnings, setWarnings] = useState<string[]>([]);
const showForm = adding || assignments.length === 0; const editing = editingId ? assignments.find((a) => a.id === editingId) : undefined;
// Show the add form for an empty category or an explicit "+ Add", but not while
// editing an existing row (that row renders its own inline form).
const showForm = !editing && (adding || assignments.length === 0);
// Binding categories need a controller to point at first. // Binding categories need a controller to point at first.
const isBound = category !== "access"; const isBound = category !== "access";
@@ -162,15 +167,43 @@ function CategorySection({
{assignments.length > 0 && ( {assignments.length > 0 && (
<ul style={{ listStyle: "none", padding: 0, margin: "0 0 0.75rem" }}> <ul style={{ listStyle: "none", padding: 0, margin: "0 0 0.75rem" }}>
{assignments.map((a) => ( {assignments.map((a) =>
<AssignmentRow key={a.id} assignment={a} controllers={controllers} onChanged={onChanged} /> editingId === a.id ? (
))} <li key={a.id} style={{ listStyle: "none", padding: 0 }}>
<DeviceForm
category={category}
entries={entries}
discoverableIds={discoverableIds}
pushCapableIds={pushCapableIds}
controllers={controllers}
editing={a}
onSaved={async (w) => {
setWarnings(w);
await onChanged();
setEditingId(null);
}}
onCancel={() => setEditingId(null)}
/>
</li>
) : (
<AssignmentRow
key={a.id}
assignment={a}
controllers={controllers}
onChanged={onChanged}
onEdit={() => {
setAdding(false);
setEditingId(a.id);
}}
/>
),
)}
</ul> </ul>
)} )}
{blockedNoController ? ( {blockedNoController ? (
<p style={{ color: "#b45309", margin: 0 }}>Add a controller first — a {noun} points at one of its relays.</p> <p style={{ color: "#b45309", margin: 0 }}>Add a controller first — a {noun} points at one of its relays.</p>
) : showForm ? ( ) : editing ? null : showForm ? (
<DeviceForm <DeviceForm
category={category} category={category}
entries={entries} entries={entries}
@@ -197,10 +230,12 @@ function AssignmentRow({
assignment, assignment,
controllers, controllers,
onChanged, onChanged,
onEdit,
}: { }: {
assignment: Assignment; assignment: Assignment;
controllers: Assignment[]; controllers: Assignment[];
onChanged: () => Promise<void> | void; onChanged: () => Promise<void> | void;
onEdit: () => void;
}) { }) {
const [removing, setRemoving] = useState(false); const [removing, setRemoving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -237,6 +272,9 @@ function AssignmentRow({
{!assignment.enabled && <span style={{ color: "#b45309" }}>(disabled)</span>} {!assignment.enabled && <span style={{ color: "#b45309" }}>(disabled)</span>}
<span style={{ flex: 1 }} /> <span style={{ flex: 1 }} />
{error && <span style={{ color: "crimson" }}>{error}</span>} {error && <span style={{ color: "crimson" }}>{error}</span>}
<button type="button" onClick={onEdit} disabled={removing}>
Edit
</button>
<button type="button" onClick={remove} disabled={removing}> <button type="button" onClick={remove} disabled={removing}>
{removing ? "Removing…" : "Remove"} {removing ? "Removing…" : "Remove"}
</button> </button>
@@ -280,6 +318,7 @@ function DeviceForm({
discoverableIds, discoverableIds,
pushCapableIds, pushCapableIds,
controllers, controllers,
editing,
onSaved, onSaved,
onCancel, onCancel,
}: { }: {
@@ -288,21 +327,42 @@ function DeviceForm({
discoverableIds: string[]; discoverableIds: string[];
pushCapableIds: string[]; pushCapableIds: string[];
controllers: Assignment[]; controllers: Assignment[];
/** When set, the form edits this assignment in place (driver locked, config
* pre-filled) instead of adding a new device. */
editing?: Assignment;
onSaved: (warnings: string[]) => Promise<void> | void; onSaved: (warnings: string[]) => Promise<void> | void;
onCancel?: () => void; onCancel?: () => void;
}) { }) {
const [selectedId, setSelectedId] = useState<string>(""); // On edit the driver is fixed (you can't change what KIND of device a slot is —
// that's a remove + re-add); pre-select it and lock the picker.
const editCfg = editing?.config as Record<string, unknown> | undefined;
const [selectedId, setSelectedId] = useState<string>(editing?.driverId ?? "");
const selected = entries.find((e) => e.id === selectedId); const selected = entries.find((e) => e.id === selectedId);
const canDiscover = selected != null && discoverableIds.includes(selected.id); const canDiscover = !editing && selected != null && discoverableIds.includes(selected.id);
const pushesToBackend = selected != null && pushCapableIds.includes(selected.id); const pushesToBackend = selected != null && pushCapableIds.includes(selected.id);
const isController = category === "access"; const isController = category === "access";
const [config, setConfig] = useState<Record<string, string | number>>({}); // Pre-fill scalar config fields from the existing assignment when editing.
// (relays/controllerId/relay are model fields handled by their own state below.)
const [config, setConfig] = useState<Record<string, string | number>>(() => {
if (!editCfg) return {};
const out: Record<string, string | number> = {};
for (const [k, v] of Object.entries(editCfg)) {
if (typeof v === "string" || typeof v === "number") out[k] = v;
}
return out;
});
// Controllers: the relay map (which relay = entry/exit/both, + entry button terminal). // Controllers: the relay map (which relay = entry/exit/both, + entry button terminal).
const [relays, setRelays] = useState<RelaySpec[]>([{ relay: 1, direction: "both" }]); const [relays, setRelays] = useState<RelaySpec[]>(() =>
Array.isArray(editCfg?.relays) ? (editCfg!.relays as RelaySpec[]) : [{ relay: 1, direction: "both" }],
);
// Bound devices: which controller + relay this device sits at. // Bound devices: which controller + relay this device sits at.
const [controllerId, setControllerId] = useState<string>(""); const [controllerId, setControllerId] = useState<string>(
const [boundRelay, setBoundRelay] = useState<number | "">(""); typeof editCfg?.controllerId === "string" ? editCfg.controllerId : "",
);
const [boundRelay, setBoundRelay] = useState<number | "">(
typeof editCfg?.relay === "number" ? editCfg.relay : "",
);
const [tested, setTested] = useState<TestResult | null>(null); const [tested, setTested] = useState<TestResult | null>(null);
const [testing, setTesting] = useState(false); const [testing, setTesting] = useState(false);
@@ -420,12 +480,17 @@ function DeviceForm({
setSaving(true); setSaving(true);
setSaveError(null); setSaveError(null);
try { try {
const result = await assignDevice({ const result = editing
category, ? await editDevice(editing.id, {
driverId: selected.id, config: mergedConfig(),
config: mergedConfig(), ...(backendIp ? { backendIp } : {}),
...(backendIp ? { backendIp } : {}), })
}); : await assignDevice({
category,
driverId: selected.id,
config: mergedConfig(),
...(backendIp ? { backendIp } : {}),
});
await onSaved(result.warnings ?? []); await onSaved(result.warnings ?? []);
} catch (e) { } catch (e) {
setSaveError((e as Error).message); setSaveError((e as Error).message);
@@ -439,7 +504,9 @@ function DeviceForm({
{entries.length === 0 ? ( {entries.length === 0 ? (
<em>No drivers registered.</em> <em>No drivers registered.</em>
) : ( ) : (
<select value={selectedId} onChange={(e) => selectDriver(e.target.value)}> // Driver is locked when editing — changing the kind of device is a
// remove + re-add, not an in-place edit.
<select value={selectedId} onChange={(e) => selectDriver(e.target.value)} disabled={!!editing}>
<option value="" disabled> <option value="" disabled>
Choose a device… Choose a device…
</option> </option>
@@ -538,7 +605,7 @@ function DeviceForm({
{testing ? "Testing…" : "Test connection"} {testing ? "Testing…" : "Test connection"}
</button> </button>
<button type="button" onClick={save} disabled={saving}> <button type="button" onClick={save} disabled={saving}>
{saving ? "Saving…" : "Save & configure"} {saving ? "Saving…" : editing ? "Save changes" : "Save & configure"}
</button> </button>
{onCancel && ( {onCancel && (
<button type="button" onClick={onCancel} disabled={saving}> <button type="button" onClick={onCancel} disabled={saving}>
+98 -17
View File
@@ -1,25 +1,41 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { closeShift, fetchShift, openShift, type ShiftReport } from "./api.js"; import { useTranslation } from "react-i18next";
import { closeShift, fetchShift, openShift, recordCashMovement, type ShiftReport } from "./api.js";
// Manned-mode shift control. Start/End are explicit (not time-based — see // Manned-mode shift control. Start/End are explicit (not time-based — see
// wiki/concepts/shift.md). End Shift signs + prints a Z-report and shows the // wiki/concepts/shift.md). End Shift signs + prints a Z-report and shows the
// totals. Available to cashier/operator/admin (readonly has no shift). // totals + the DRAWER picture (opening float carried from the prior shift, cash
// taken/added/removed, expected drawer). Admins can load/remove drawer cash.
// Available to cashier/operator/admin (readonly has no shift).
const money = (m: number, cur: string | null) => `${(m / 100).toFixed(2)} ${cur ?? ""}`.trim(); const money = (m: number, cur: string | null) => `${(m / 100).toFixed(2)} ${cur ?? ""}`.trim();
export function ShiftControl() { export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
const { t } = useTranslation();
const [startedAt, setStartedAt] = useState<string | null>(null); const [startedAt, setStartedAt] = useState<string | null>(null);
const [drawerMinor, setDrawerMinor] = useState<number | null>(null);
const [currency, setCurrency] = useState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [report, setReport] = useState<ShiftReport | null>(null); const [report, setReport] = useState<ShiftReport | null>(null);
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
useEffect(() => { // Cash-movement form (admin only).
const [moveAmount, setMoveAmount] = useState("");
const [moveReason, setMoveReason] = useState("");
const [moveMsg, setMoveMsg] = useState<string | null>(null);
function refresh() {
fetchShift() fetchShift()
.then((s) => setStartedAt(s.open?.startedAt ?? null)) .then((s) => {
setStartedAt(s.open?.startedAt ?? null);
setDrawerMinor(s.drawerMinor);
setCurrency(s.currency);
})
.catch(() => { .catch(() => {
/* readonly / not permitted — hide control */ /* readonly / not permitted — hide control */
}); });
}, []); }
useEffect(refresh, []);
async function start() { async function start() {
setBusy(true); setBusy(true);
@@ -28,6 +44,7 @@ export function ShiftControl() {
try { try {
const { startedAt } = await openShift(); const { startedAt } = await openShift();
setStartedAt(startedAt); setStartedAt(startedAt);
refresh();
} catch (e) { } catch (e) {
setErr((e as Error).message); setErr((e as Error).message);
} finally { } finally {
@@ -41,6 +58,7 @@ export function ShiftControl() {
const z = await closeShift(); const z = await closeShift();
setReport(z); setReport(z);
setStartedAt(null); setStartedAt(null);
refresh();
} catch (e) { } catch (e) {
setErr((e as Error).message); setErr((e as Error).message);
} finally { } finally {
@@ -48,33 +66,96 @@ export function ShiftControl() {
} }
} }
async function move(sign: 1 | -1) {
setMoveMsg(null);
const major = Number(moveAmount);
if (!Number.isFinite(major) || major <= 0) {
setMoveMsg(t("shift.enterPositive"));
return;
}
try {
const r = await recordCashMovement(sign * Math.round(major * 100), moveReason.trim());
setMoveAmount("");
setMoveReason("");
setMoveMsg(t("shift.drawerNow", { amount: money(r.balanceMinor, currency) }));
refresh();
} catch (e) {
setMoveMsg((e as Error).message);
}
}
return ( return (
<section style={{ marginTop: "1.5rem", padding: "0.75rem 1rem", border: "1px solid #ddd", borderRadius: 6, maxWidth: 460 }}> <section style={{ marginTop: "1.5rem", padding: "0.75rem 1rem", border: "1px solid #ddd", borderRadius: 6, maxWidth: 460 }}>
<strong>Shift:</strong>{" "} <strong>{t("shift.label")}</strong>{" "}
{startedAt ? ( {startedAt ? (
<> <>
<span style={{ color: "#16a34a" }}>open</span> since {new Date(startedAt).toLocaleString()}{" "} <span style={{ color: "#16a34a" }}>{t("shift.open")}</span> {t("shift.since")}{" "}
{new Date(startedAt).toLocaleString()}{" "}
<button type="button" onClick={end} disabled={busy}> <button type="button" onClick={end} disabled={busy}>
{busy ? "Ending…" : "End shift"} {busy ? t("shift.ending") : t("shift.endShift")}
</button> </button>
</> </>
) : ( ) : (
<> <>
<span style={{ color: "#777" }}>not started</span>{" "} <span style={{ color: "#777" }}>{t("shift.notStarted")}</span>{" "}
<button type="button" onClick={start} disabled={busy}> <button type="button" onClick={start} disabled={busy}>
{busy ? "Starting…" : "Start shift"} {busy ? t("shift.starting") : t("shift.startShift")}
</button> </button>
</> </>
)} )}
{/* Live drawer balance (what's in the till right now / inherited). */}
{drawerMinor != null && (
<div style={{ marginTop: "0.5rem", color: "#555" }}>
{t("shift.drawer")} <strong>{money(drawerMinor, currency)}</strong>
{startedAt && <span style={{ color: "#888" }}> {t("shift.openingFloatInherited")}</span>}
</div>
)}
{err && <p style={{ color: "crimson", margin: "0.5rem 0 0" }}>{err}</p>} {err && <p style={{ color: "crimson", margin: "0.5rem 0 0" }}>{err}</p>}
{/* Admin: load / remove physical drawer cash (signed cash_movement). */}
{isAdmin && (
<div style={{ marginTop: "0.6rem", paddingTop: "0.5rem", borderTop: "1px solid #eee" }}>
<div style={{ color: "#666", fontSize: "0.85rem", marginBottom: "0.35rem" }}>
{t("shift.drawerCashAdmin")}
</div>
<div style={{ display: "flex", gap: "0.4rem", alignItems: "center", flexWrap: "wrap" }}>
<input
value={moveAmount}
onChange={(e) => setMoveAmount(e.target.value)}
placeholder={t("shift.amount")}
inputMode="decimal"
style={{ width: 90 }}
/>
<input
value={moveReason}
onChange={(e) => setMoveReason(e.target.value)}
placeholder={t("shift.reasonPlaceholder")}
style={{ flex: 1, minWidth: 140 }}
/>
<button type="button" onClick={() => move(1)}>{t("shift.load")}</button>
<button type="button" onClick={() => move(-1)}>{t("shift.remove")}</button>
</div>
{moveMsg && <div style={{ marginTop: "0.35rem", color: "#555", fontSize: "0.85rem" }}>{moveMsg}</div>}
</div>
)}
{report && ( {report && (
<div style={{ marginTop: "0.75rem", fontFamily: "ui-monospace, monospace", fontSize: "0.9em" }}> <div style={{ marginTop: "0.75rem", fontFamily: "ui-monospace, monospace", fontSize: "0.9em" }}>
<div style={{ fontWeight: 600 }}>Z-REPORT — {report.operator}</div> <div style={{ fontWeight: 600 }}>{t("shift.zReport")} — {report.operator}</div>
<div>Payments: {report.paymentCount}</div> <div>{t("shift.payments")} {report.paymentCount}</div>
<div>Cash: {money(report.cashTotalMinor, report.currency)}</div> <div>{t("shift.cash")} {money(report.cashTotalMinor, report.currency)}</div>
<div>Card: {money(report.cardTotalMinor, report.currency)}</div> <div>{t("shift.card")} {money(report.cardTotalMinor, report.currency)}</div>
<div style={{ color: report.printed ? "#16a34a" : "#b45309" }}> <div style={{ marginTop: "0.4rem", color: "#666" }}>{t("shift.drawerSection")}</div>
{report.printed ? "Printed to booth receipt." : "Recorded (no printer to print to)."} <div>{t("shift.openingFloat")} {money(report.openingFloatMinor, report.currency)}</div>
<div>{t("shift.cashTaken")} {money(report.cashTotalMinor, report.currency)}</div>
<div>{t("shift.cashAdded")} {money(report.cashAddedMinor, report.currency)}</div>
<div>{t("shift.cashRemoved")} {money(report.cashRemovedMinor, report.currency)}</div>
<div style={{ fontWeight: 600 }}>
{t("shift.expectedDrawer")} {money(report.expectedDrawerMinor, report.currency)}
</div>
<div style={{ color: report.printed ? "#16a34a" : "#b45309", marginTop: "0.3rem" }}>
{report.printed ? t("shift.printedToReceipt") : t("shift.recordedNoPrinter")}
</div> </div>
</div> </div>
)} )}
+79 -19
View File
@@ -1,14 +1,30 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { fetchOccupancy, fetchSiteConfig, setCapacity, type Occupancy } from "./api.js"; import { useTranslation } from "react-i18next";
import { fetchOccupancy, fetchSiteConfig, saveSiteConfig, type Occupancy, type SiteConfig } from "./api.js";
// Live occupancy + capacity. Occupancy is shown to everyone (it's a fold over the // Live occupancy + capacity + park metadata. Occupancy is shown to everyone (it's a
// signed ledger); the capacity field is admin-editable. The FULL gate (refuse // fold over the signed ledger); capacity and the metadata fields are admin-editable.
// transient entry at capacity) is enforced server-side in the entry flow. // The FULL gate (refuse transient entry at capacity) is enforced server-side in the
// See wiki/concepts/capacity-occupancy.md. // entry flow. Metadata (name, NIUS, address, contact) feeds the ticket header.
// See wiki/concepts/capacity-occupancy.md and wiki/concepts/site-metadata.md.
// The optional text fields, in display order. `labelKey`/`phKey` are i18n keys
// (resolved at render); only `address` is multiline.
const META_FIELDS: ReadonlyArray<{ key: keyof SiteConfig; labelKey: string; phKey?: string; multiline?: boolean }> = [
{ key: "parkName", labelKey: "site.fieldParkName", phKey: "site.fieldParkNamePh" },
{ key: "operatorName", labelKey: "site.fieldOperator", phKey: "site.fieldOperatorPh" },
{ key: "nius", labelKey: "site.fieldNius", phKey: "site.fieldNiusPh" },
{ key: "address", labelKey: "site.fieldAddress", multiline: true },
{ key: "phone", labelKey: "site.fieldPhone" },
{ key: "email", labelKey: "site.fieldEmail" },
];
export function SiteSettings({ canEdit }: { canEdit: boolean }) { export function SiteSettings({ canEdit }: { canEdit: boolean }) {
const { t } = useTranslation();
const [occ, setOcc] = useState<Occupancy | null>(null); const [occ, setOcc] = useState<Occupancy | null>(null);
const [capInput, setCapInput] = useState(""); const [capInput, setCapInput] = useState("");
const [meta, setMeta] = useState<Record<string, string>>({});
const [exitVoucherDefault, setExitVoucherDefault] = useState(false);
const [msg, setMsg] = useState<string | null>(null); const [msg, setMsg] = useState<string | null>(null);
function reload() { function reload() {
@@ -17,18 +33,29 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
useEffect(() => { useEffect(() => {
reload(); reload();
fetchSiteConfig() fetchSiteConfig()
.then((c) => setCapInput(c.capacity == null ? "" : String(c.capacity))) .then((c) => {
setCapInput(c.capacity == null ? "" : String(c.capacity));
setExitVoucherDefault(c.exitVoucherDefault);
const m: Record<string, string> = {};
for (const { key } of META_FIELDS) m[key] = c[key] == null ? "" : String(c[key]);
setMeta(m);
})
.catch(() => {}); .catch(() => {});
}, []); }, []);
async function save() { async function save() {
setMsg(null); setMsg(null);
const raw = capInput.trim(); const raw = capInput.trim();
const capacity = raw === "" ? null : Math.round(Number(raw)); const patch: Partial<SiteConfig> = {
capacity: raw === "" ? null : Math.round(Number(raw)),
exitVoucherDefault,
};
// Send each metadata field; "" → null is applied server-side.
for (const { key } of META_FIELDS) (patch as Record<string, string | null>)[key] = meta[key] ?? "";
try { try {
await setCapacity(capacity); await saveSiteConfig(patch);
reload(); reload();
setMsg("Capacity saved."); setMsg(t("site.saved"));
} catch (e) { } catch (e) {
setMsg((e as Error).message); setMsg((e as Error).message);
} }
@@ -36,28 +63,61 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
return ( return (
<section style={{ marginTop: "1.5rem", padding: "0.75rem 1rem", border: "1px solid #ddd", borderRadius: 6, maxWidth: 460 }}> <section style={{ marginTop: "1.5rem", padding: "0.75rem 1rem", border: "1px solid #ddd", borderRadius: 6, maxWidth: 460 }}>
<strong>Occupancy:</strong>{" "} <strong>{t("site.occupancy")}</strong>{" "}
{occ == null ? ( {occ == null ? (
"…" "…"
) : ( ) : (
<> <>
<span style={{ fontWeight: 600 }}>{occ.count}</span> <span style={{ fontWeight: 600 }}>{occ.count}</span>
{occ.capacity != null ? ` / ${occ.capacity}` : " (no capacity set)"} {occ.capacity != null ? ` / ${occ.capacity}` : ` ${t("site.noCapacitySet")}`}
{occ.capacity != null && ( {occ.capacity != null && (
<span style={{ color: "#666" }}> · {occ.free} free</span> <span style={{ color: "#666" }}> · {occ.free} {t("site.free")}</span>
)} )}
{occ.full && <span style={{ color: "crimson", marginLeft: "0.5rem", fontWeight: 600 }}>FULL</span>}{" "} {occ.full && <span style={{ color: "crimson", marginLeft: "0.5rem", fontWeight: 600 }}>{t("site.full")}</span>}{" "}
<button type="button" onClick={reload} style={{ marginLeft: "0.5rem" }}>↻</button> <button type="button" onClick={reload} style={{ marginLeft: "0.5rem" }}>↻</button>
</> </>
)} )}
{canEdit && ( {canEdit && (
<div style={{ marginTop: "0.6rem" }}> <div style={{ marginTop: "0.6rem", display: "grid", gap: "0.5rem" }}>
<label> <label>
Capacity (blank = no limit):{" "} {t("site.capacityLabel")}{" "}
<input value={capInput} onChange={(e) => setCapInput(e.target.value)} style={{ width: 80 }} placeholder="e.g. 120" /> <input value={capInput} onChange={(e) => setCapInput(e.target.value)} style={{ width: 80 }} placeholder={t("site.capacityPlaceholder")} />
</label>{" "} </label>
<button type="button" onClick={save}>Save</button> <label style={{ display: "flex", alignItems: "center", gap: "0.4rem" }}>
{msg && <span style={{ marginLeft: "0.5rem", color: "#555" }}>{msg}</span>} <input
type="checkbox"
checked={exitVoucherDefault}
onChange={(e) => setExitVoucherDefault(e.target.checked)}
/>
{t("site.printExitDefault")}
<span style={{ color: "#888", fontSize: "0.8rem" }}>{t("site.printExitHint")}</span>
</label>
<div style={{ borderTop: "1px solid #eee", paddingTop: "0.5rem", color: "#666", fontSize: "0.85rem" }}>
{t("site.parkDetails")}
</div>
{META_FIELDS.map(({ key, labelKey, phKey, multiline }) => (
<label key={key} style={{ display: "flex", flexDirection: "column", fontSize: "0.85rem" }}>
{t(labelKey)}
{multiline ? (
<textarea
value={meta[key] ?? ""}
onChange={(e) => setMeta((m) => ({ ...m, [key]: e.target.value }))}
rows={2}
placeholder={phKey ? t(phKey) : undefined}
/>
) : (
<input
value={meta[key] ?? ""}
onChange={(e) => setMeta((m) => ({ ...m, [key]: e.target.value }))}
placeholder={phKey ? t(phKey) : undefined}
/>
)}
</label>
))}
<div>
<button type="button" onClick={save}>{t("site.save")}</button>
{msg && <span style={{ marginLeft: "0.5rem", color: "#555" }}>{msg}</span>}
</div>
</div> </div>
)} )}
</section> </section>
+434
View File
@@ -0,0 +1,434 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ApiError,
armCapture,
cancelCapture,
createSubscription,
deleteSubscription,
fetchReaders,
fetchSiteConfig,
fetchSubscriptions,
pollCapture,
printSubscription,
revokeSubscription,
updateSubscription,
type ReaderInfo,
type Subscription,
type SubscriptionCredential,
type SubscriptionInput,
} from "./api.js";
// Subscription admin. Create/edit/revoke/delete subscriptions + their credentials
// (card/QR) and bound plates, and the recurring monthly price (e.g. 10,000 ALL). A
// subscription is mutable master data; every USE of it is a signed ledger event
// elsewhere. See wiki/entities/subscription.md.
const DEFAULT_CURRENCY = "ALL";
interface FormState {
holderName: string;
contact: string;
priceMajor: string; // major units as typed (e.g. "10000"); "" = no price
currency: string;
carBound: boolean; // false = unbound (maxConcurrent null)
maxConcurrent: string;
validFrom: string;
months: string; // months paid for; "" = none (use explicit validTo / open-ended)
validTo: string;
credentials: SubscriptionCredential[];
platesText: string; // comma/space separated
}
/** Today (UTC date, yyyy-mm-dd) for a sensible default validFrom on new subs. */
function todayISODate(): string {
return new Date().toISOString().slice(0, 10);
}
function emptyForm(defaultPriceMajor = "", currency = DEFAULT_CURRENCY): FormState {
return {
holderName: "",
contact: "",
priceMajor: defaultPriceMajor,
currency,
carBound: true,
maxConcurrent: "1",
validFrom: todayISODate(),
months: "1",
validTo: "",
credentials: [{ kind: "qr", value: "" }],
platesText: "",
};
}
function formFrom(s: Subscription): FormState {
return {
holderName: s.holderName ?? "",
contact: s.contact ?? "",
priceMajor: s.priceMinor != null ? String(s.priceMinor / 100) : "",
currency: s.currency ?? DEFAULT_CURRENCY,
carBound: s.maxConcurrent != null,
maxConcurrent: s.maxConcurrent != null ? String(s.maxConcurrent) : "1",
validFrom: s.validFrom ?? "",
months: "", // on edit, default to leaving the window as-is (explicit validTo below)
validTo: s.validTo ?? "",
credentials: s.credentials.length ? s.credentials : [{ kind: "qr", value: "" }],
platesText: s.plates.join(", "),
};
}
/** Add whole months to a yyyy-mm-dd (clamps day overflow), → yyyy-mm-dd. Mirrors the
* server's addMonths so the form can preview the coverage end. */
function addMonthsDate(date: string, months: number): string | null {
const d = new Date(`${date}T00:00:00Z`);
if (Number.isNaN(d.getTime())) return null;
const day = d.getUTCDate();
d.setUTCMonth(d.getUTCMonth() + months);
if (d.getUTCDate() < day) d.setUTCDate(0);
return d.toISOString().slice(0, 10);
}
const STATUS_KEY: Record<Subscription["status"], string> = {
active: "subs.statusActive",
suspended: "subs.statusSuspended",
revoked: "subs.statusRevoked",
};
function toInput(f: FormState): SubscriptionInput {
const major = Number(f.priceMajor);
const priceSet = f.priceMajor.trim() !== "" && Number.isFinite(major) && major >= 0;
const monthsNum = f.months.trim() === "" ? null : Math.max(1, Math.round(Number(f.months) || 0));
return {
holderName: f.holderName.trim() || null,
contact: f.contact.trim() || null,
priceMinor: priceSet ? Math.round(major * 100) : null,
period: "monthly",
currency: priceSet ? f.currency.trim() || DEFAULT_CURRENCY : null,
maxConcurrent: f.carBound ? Math.max(1, Math.round(Number(f.maxConcurrent) || 1)) : null,
validFrom: f.validFrom.trim() || null,
// months (with validFrom) drives validTo server-side; else send the explicit end.
months: monthsNum && f.validFrom.trim() ? monthsNum : null,
validTo: f.validTo.trim() || null,
// A QR credential with a blank value is sent as { kind:'qr' } (no value) so the
// server auto-generates the code. RF (and pre-existing QR) keep their value.
credentials: f.credentials
.filter((c) => c.kind === "qr" || c.value.trim())
.map((c) => (c.value.trim() ? { kind: c.kind, value: c.value.trim() } : { kind: c.kind })),
plates: f.platesText.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean),
};
}
function priceLabel(s: Subscription, t: (k: string) => string): string {
if (s.priceMinor == null) return t("subs.noPrice");
return `${(s.priceMinor / 100).toLocaleString()} ${s.currency ?? ""} / ${t("subs.perMonth")}`.trim();
}
export function SubscriptionManager() {
const { t } = useTranslation();
const [subs, setSubs] = useState<Subscription[] | null>(null);
const [defaultPriceMajor, setDefaultPriceMajor] = useState("");
const [editing, setEditing] = useState<string | "new" | null>(null);
const [form, setForm] = useState<FormState>(() => emptyForm());
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
// Credential capture ("Read card"): which credential index is being captured, the
// reader picker list, and a live status line. null = no capture in progress.
const [capture, setCapture] = useState<{ credIndex: number; phase: "pick" | "waiting"; status?: string } | null>(null);
const [readers, setReaders] = useState<ReaderInfo[]>([]);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
function reload() {
fetchSubscriptions()
.then((r) => setSubs(r.subscriptions))
.catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
}
useEffect(() => {
reload();
// Pull the site default monthly price to pre-fill new subscriptions.
fetchSiteConfig()
.then((c) => {
if (c.subscriptionMonthlyPriceMinor != null) setDefaultPriceMajor(String(c.subscriptionMonthlyPriceMinor / 100));
})
.catch(() => {
/* non-fatal — the form just won't pre-fill */
});
}, []);
function startNew() {
setForm(emptyForm(defaultPriceMajor));
setEditing("new");
setMsg(null);
}
function startEdit(s: Subscription) {
setForm(formFrom(s));
setEditing(s.id);
setMsg(null);
}
async function save() {
setMsg(null);
try {
if (editing === "new") {
const created = await createSubscription(toInput(form));
setEditing(null);
reload();
// Reflect the auto-print outcome: printed OK, or saved-but-print-failed (the
// operator can use "Print code" to retry).
if (created.printed) {
setMsg({ kind: "ok", text: t("subs.savedPrinted") });
} else if (created.printError) {
setMsg({ kind: "err", text: t("subs.savedPrintFailed", { error: created.printError }) });
} else {
setMsg({ kind: "ok", text: t("subs.saved") });
}
return;
}
if (editing) await updateSubscription(editing, toInput(form));
setEditing(null);
reload();
setMsg({ kind: "ok", text: t("subs.saved") });
} catch (e) {
const problems = e instanceof ApiError ? (e as ApiError & { problems?: string[] }).problems : undefined;
setMsg({ kind: "err", text: problems?.length ? `${(e as Error).message}: ${problems.join("; ")}` : (e as Error).message });
}
}
async function doPrint(s: Subscription) {
setMsg(null);
try {
const r = await printSubscription(s.id);
setMsg({ kind: "ok", text: t("subs.printedOn", { printer: r.printedBy }) });
} catch (e) {
setMsg({ kind: "err", text: (e as Error).message });
}
}
async function doRevoke(s: Subscription) {
if (!confirm(t("subs.confirmRevoke", { name: s.holderName ?? s.id }))) return;
await revokeSubscription(s.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
reload();
}
async function doDelete(s: Subscription) {
if (!confirm(t("subs.confirmDelete", { name: s.holderName ?? s.id }))) return;
await deleteSubscription(s.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
reload();
}
function setCred(i: number, patch: Partial<SubscriptionCredential>) {
setForm((f) => ({ ...f, credentials: f.credentials.map((c, j) => (j === i ? { ...c, ...patch } : c)) }));
}
function clearPoll() {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
}
// Stop a capture in progress (cancel on the server + clear local state).
function stopCapture() {
clearPoll();
void cancelCapture().catch(() => {});
setCapture(null);
}
// "Read card" on credential i → load readers + show the picker.
async function startCapture(i: number) {
setMsg(null);
try {
const r = await fetchReaders();
setReaders(r.readers);
setCapture({ credIndex: i, phase: "pick" });
} catch (e) {
setMsg({ kind: "err", text: (e as Error).message });
}
}
// Operator picked a reader → arm it and poll until captured / expired.
async function pickReader(deviceId: string) {
const cap = capture;
if (!cap) return;
try {
await armCapture(deviceId);
setCapture({ ...cap, phase: "waiting", status: t("subs.captureWaiting") });
clearPoll();
pollRef.current = setInterval(async () => {
try {
const st = await pollCapture();
if (st.status === "captured") {
clearPoll();
setCred(cap.credIndex, { value: st.value });
void cancelCapture().catch(() => {}); // clear the server-side result
setCapture(null);
setMsg({ kind: "ok", text: t("subs.captured", { value: st.value }) });
} else if (st.status === "expired" || st.status === "idle") {
clearPoll();
setCapture({ ...cap, phase: "waiting", status: t("subs.captureTimeout") });
}
} catch {
/* transient poll error — keep polling */
}
}, 700);
} catch (e) {
setMsg({ kind: "err", text: (e as Error).message });
setCapture(null);
}
}
// Stop polling if the form closes or the component unmounts.
useEffect(() => clearPoll, []);
// Live coverage preview: when months + validFrom are set, show the end date and
// (if priced) the N×monthly total the operator should collect.
const monthsN = form.months.trim() === "" ? 0 : Math.max(0, Math.round(Number(form.months) || 0));
const coverageEnd = monthsN >= 1 && form.validFrom.trim() ? addMonthsDate(form.validFrom.trim(), monthsN) : null;
const priceMajorN = form.priceMajor.trim() === "" ? null : Number(form.priceMajor);
const totalDue =
coverageEnd && priceMajorN != null && Number.isFinite(priceMajorN)
? `${(priceMajorN * monthsN).toLocaleString()} ${form.currency.trim() || DEFAULT_CURRENCY}`
: null;
const coverageHint = coverageEnd
? t("subs.coverageHint", { end: coverageEnd }) + (totalDue ? ` · ${t("subs.totalDue", { total: totalDue })}` : "")
: null;
if (!subs) return null;
return (
<section style={{ marginTop: "2rem" }}>
<h2>{t("subs.title")}</h2>
<ul style={{ listStyle: "none", padding: 0 }}>
{subs.map((s) => (
<li key={s.id} style={{ display: "flex", gap: "0.5rem", alignItems: "center", padding: "0.4rem 0", borderBottom: "1px solid #eee" }}>
<strong>{s.holderName ?? t("subs.unnamed")}</strong>
<span style={{ color: s.status === "active" ? "#16a34a" : "#b45309" }}>{t(STATUS_KEY[s.status])}</span>
<span style={{ color: "#0a7", fontVariantNumeric: "tabular-nums" }}>{priceLabel(s, t)}</span>
<span style={{ color: "#666" }}>
{s.maxConcurrent == null ? t("subs.unbound") : t("subs.car", { count: s.maxConcurrent })} ·{" "}
{s.credentials.length} {t("subs.cred")} · {t("subs.plates", { count: s.plates.length })}
</span>
<span style={{ flex: 1 }} />
{/* Print code — only when the subscription has a QR credential to encode. */}
{s.credentials.some((c) => c.kind === "qr") && (
<button type="button" onClick={() => doPrint(s)}>{t("subs.printCode")}</button>
)}
<button type="button" onClick={() => startEdit(s)}>{t("subs.edit")}</button>
{s.status !== "revoked" && <button type="button" onClick={() => doRevoke(s)}>{t("subs.revoke")}</button>}
<button type="button" onClick={() => doDelete(s)}>{t("subs.delete")}</button>
</li>
))}
{subs.length === 0 && <li style={{ color: "#777" }}>{t("subs.noneYet")}</li>}
</ul>
{editing == null ? (
<button type="button" onClick={startNew}>{t("subs.add")}</button>
) : (
<div style={{ border: "1px solid #ddd", padding: "1rem", marginTop: "0.5rem", maxWidth: 460 }}>
<h3 style={{ marginTop: 0 }}>{editing === "new" ? t("subs.new") : t("subs.editTitle")}</h3>
<div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.4rem 0.75rem", alignItems: "center" }}>
<label>{t("subs.holderName")}</label>
<input value={form.holderName} onChange={(e) => setForm((f) => ({ ...f, holderName: e.target.value }))} />
<label>{t("subs.contact")}</label>
<input value={form.contact} onChange={(e) => setForm((f) => ({ ...f, contact: e.target.value }))} />
<label>{t("subs.monthlyPrice")}</label>
<span style={{ display: "flex", gap: "0.4rem", alignItems: "center" }}>
<input
value={form.priceMajor}
onChange={(e) => setForm((f) => ({ ...f, priceMajor: e.target.value }))}
inputMode="decimal"
placeholder={t("subs.pricePlaceholder")}
style={{ width: 110 }}
/>
<input value={form.currency} onChange={(e) => setForm((f) => ({ ...f, currency: e.target.value }))} style={{ width: 60 }} />
<span style={{ color: "#888" }}>/ {t("subs.perMonth")}</span>
</span>
<label>{t("subs.carLimit")}</label>
<span>
<label style={{ marginRight: "0.5rem" }}>
<input type="checkbox" checked={form.carBound} onChange={(e) => setForm((f) => ({ ...f, carBound: e.target.checked }))} /> {t("subs.limitCarsInAtOnce")}
</label>
{form.carBound && (
<input value={form.maxConcurrent} onChange={(e) => setForm((f) => ({ ...f, maxConcurrent: e.target.value }))} style={{ width: 50 }} />
)}
</span>
<label>{t("subs.validFrom")}</label>
<input type="date" value={form.validFrom} onChange={(e) => setForm((f) => ({ ...f, validFrom: e.target.value }))} />
<label>{t("subs.months")}</label>
<span style={{ display: "flex", gap: "0.4rem", alignItems: "center", flexWrap: "wrap" }}>
<input
value={form.months}
onChange={(e) => setForm((f) => ({ ...f, months: e.target.value }))}
inputMode="numeric"
placeholder="1"
style={{ width: 50 }}
/>
<span style={{ color: "#888" }}>{t("subs.monthsHint")}</span>
{/* Live preview of the coverage end + the N×price total. */}
{coverageHint && <span style={{ color: "#0a7" }}>{coverageHint}</span>}
</span>
<label>{t("subs.validToOverride")}</label>
<input type="date" value={form.validTo} onChange={(e) => setForm((f) => ({ ...f, validTo: e.target.value }))} />
<label>{t("subs.boundPlates")}</label>
<input value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder={t("subs.commaSeparatedOptional")} />
</div>
<h4 style={{ marginBottom: "0.25rem" }}>{t("subs.credentials")}</h4>
{form.credentials.map((c, i) => (
<div key={i} style={{ display: "flex", gap: "0.4rem", marginBottom: "0.3rem" }}>
{/* Operator chooses the credential type: QR (auto-generated) or RFID
(read off a card via "Read card"). */}
<select value={c.kind} onChange={(e) => setCred(i, { kind: e.target.value as "rf" | "qr" })}>
<option value="qr">{t("subs.qr")}</option>
<option value="rf">{t("subs.rfCardTag")}</option>
</select>
{c.kind === "qr" ? (
// QR codes are server-generated. Blank → "will be generated"; an
// existing code is shown read-only (it can be printed; never typed).
c.value.trim() ? (
<input value={c.value} readOnly style={{ flex: 1, fontFamily: "ui-monospace, monospace", background: "#f6f6f6" }} />
) : (
<span style={{ flex: 1, color: "#888", fontStyle: "italic", alignSelf: "center" }}>{t("subs.qrAutoGen")}</span>
)
) : (
// RFID: the value is read off a physical card (or typed). "Read card"
// arms a chosen reader and fills the captured value.
<input value={c.value} onChange={(e) => setCred(i, { value: e.target.value })} placeholder={t("subs.rfPlaceholder")} style={{ flex: 1, fontFamily: "ui-monospace, monospace" }} />
)}
{c.kind === "rf" && (
<button type="button" onClick={() => startCapture(i)} disabled={capture != null}>{t("subs.readCard")}</button>
)}
<button type="button" onClick={() => setForm((f) => ({ ...f, credentials: f.credentials.filter((_, j) => j !== i) }))}>×</button>
</div>
))}
<button type="button" onClick={() => setForm((f) => ({ ...f, credentials: [...f.credentials, { kind: "qr", value: "" }] }))}>{t("subs.addCredential")}</button>
{/* Capture panel: pick a reader, present the card; the captured value fills
the credential. The OTHER reader keeps serving the live flow. */}
{capture && (
<div style={{ marginTop: "0.5rem", padding: "0.6rem 0.75rem", border: "1px solid #0a7", borderRadius: 6, background: "#f0fbf6" }}>
{capture.phase === "pick" ? (
<>
<div style={{ marginBottom: "0.35rem" }}>{t("subs.captureChooseReader")}</div>
<div style={{ display: "flex", gap: "0.4rem", flexWrap: "wrap" }}>
{readers.length === 0 && <span style={{ color: "#a00" }}>{t("subs.captureNoReaders")}</span>}
{readers.map((r) => (
<button key={r.id} type="button" onClick={() => pickReader(r.id)}>
{t(`devices.role.${r.direction}`)} ({r.driverId})
</button>
))}
<button type="button" onClick={stopCapture}>{t("subs.cancel")}</button>
</div>
</>
) : (
<div style={{ display: "flex", gap: "0.6rem", alignItems: "center" }}>
<span>{capture.status ?? t("subs.captureWaiting")}</span>
<button type="button" onClick={stopCapture}>{t("subs.cancel")}</button>
</div>
)}
</div>
)}
<p style={{ color: "#777", fontSize: "0.85em", margin: "0.5rem 0 0" }}>
{t("subs.needCredentialOrPlate")}
</p>
<div style={{ marginTop: "1rem", display: "flex", gap: "0.5rem" }}>
<button type="button" onClick={save}>{t("subs.save")}</button>
<button type="button" onClick={() => setEditing(null)}>{t("subs.cancel")}</button>
</div>
</div>
)}
{msg && <p style={{ color: msg.kind === "ok" ? "#16a34a" : "crimson" }}>{msg.text}</p>}
</section>
);
}
+356 -80
View File
@@ -1,9 +1,12 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { import {
ApiError, ApiError,
fetchTariff, fetchTariff,
isTariffV2,
publishTariffVersion, publishTariffVersion,
type TariffBlock, type TariffBlock,
type TariffCard,
type TariffStructure, type TariffStructure,
type TariffState, type TariffState,
} from "./api.js"; } from "./api.js";
@@ -14,32 +17,106 @@ import {
// usability and converted to integer minor units on submit. See wiki/concepts/tariff.md. // usability and converted to integer minor units on submit. See wiki/concepts/tariff.md.
// Editable form mirror of TariffStructure, but money in major-unit strings. // Editable form mirror of TariffStructure, but money in major-unit strings.
// Blocks are edited as a DURATION in hours ("this band lasts N hours") — the
// owner thinks "first 2 hours, then next 3 hours", not in cumulative minutes.
// The LAST block is always open-ended ("thereafter"): its hours field is unused
// and it has no bound. On submit, per-block hours accumulate into the engine's
// cumulative `uptoMin` (minutes), and the last block emits uptoMin: null.
interface BlockForm { interface BlockForm {
uptoMin: string; // "" = open-ended (last block) hours: string; // duration of THIS band, in hours (ignored for the last block)
price: string; // major units, e.g. "2.00" 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 { interface FormState {
currency: string; currency: string;
gracePeriodEntryMin: string; gracePeriodEntryMin: string;
incrementMin: string; incrementMin: string;
dailyCap: string; // "" = no cap
lostTicket: string; lostTicket: string;
gracePeriodExitMin: 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 toMinor = (major: string): number => Math.round(parseFloat(major || "0") * 100);
const toMajor = (minor: number): string => (minor / 100).toFixed(2); 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 { function emptyForm(): FormState {
return { return {
currency: "EUR", currency: "EUR",
gracePeriodEntryMin: "15", gracePeriodEntryMin: "15",
incrementMin: "60", incrementMin: "60",
dailyCap: "",
lostTicket: "20.00", lostTicket: "20.00",
gracePeriodExitMin: "15", gracePeriodExitMin: "15",
blocks: [{ uptoMin: "60", price: "2.00" }, { uptoMin: "", price: "1.00" }], base: emptyLadder(),
tiers: [],
};
}
// 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) };
const hours = (b.uptoMin - prev) / 60;
prev = b.uptoMin;
return { hours: String(hours), price: toMajor(b.priceMinorPerIncrement) };
});
}
// 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),
}; };
} }
@@ -47,37 +124,85 @@ function formFromActive(s: TariffState): FormState {
const v = s.active; const v = s.active;
if (!v) return emptyForm(); if (!v) return emptyForm();
const st = v.structure; const st = v.structure;
return { const common = {
currency: v.currency, currency: v.currency,
gracePeriodEntryMin: String(st.gracePeriodEntryMin), gracePeriodEntryMin: String(st.gracePeriodEntryMin),
incrementMin: String(st.incrementMin), incrementMin: String(st.incrementMin),
dailyCap: st.dailyCapMinor == null ? "" : toMajor(st.dailyCapMinor),
lostTicket: toMajor(st.lostTicketMinor), lostTicket: toMajor(st.lostTicketMinor),
gracePeriodExitMin: String(st.gracePeriodExitMin), gracePeriodExitMin: String(st.gracePeriodExitMin),
blocks: st.blocks.map((b) => ({
uptoMin: b.uptoMin == null ? "" : String(b.uptoMin),
price: toMajor(b.priceMinorPerIncrement),
})),
}; };
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 { function toStructure(f: FormState): TariffStructure {
const blocks: TariffBlock[] = f.blocks.map((b) => ({ const common = {
uptoMin: b.uptoMin.trim() === "" ? null : Math.round(Number(b.uptoMin)),
priceMinorPerIncrement: toMinor(b.price),
}));
return {
gracePeriodEntryMin: Math.round(Number(f.gracePeriodEntryMin)), gracePeriodEntryMin: Math.round(Number(f.gracePeriodEntryMin)),
incrementMin: Math.round(Number(f.incrementMin)), incrementMin: Math.round(Number(f.incrementMin)),
blocks,
dailyCapMinor: f.dailyCap.trim() === "" ? null : toMinor(f.dailyCap),
lostTicketMinor: toMinor(f.lostTicket), lostTicketMinor: toMinor(f.lostTicket),
gracePeriodExitMin: Math.round(Number(f.gracePeriodExitMin)), 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),
}; };
} }
export function TariffComposer() { export function TariffComposer() {
const { t } = useTranslation();
const [state, setState] = useState<TariffState | null>(null); const [state, setState] = useState<TariffState | null>(null);
const [form, setForm] = useState<FormState>(emptyForm); const [form, setForm] = useState<FormState>(emptyForm);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -95,14 +220,48 @@ export function TariffComposer() {
function set<K extends keyof FormState>(key: K, value: FormState[K]) { function set<K extends keyof FormState>(key: K, value: FormState[K]) {
setForm((f) => ({ ...f, [key]: value })); 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)) })); // --- 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) => {
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)) };
});
} }
function addBlock() { function setBlock(target: "base" | number, i: number, patch: Partial<BlockForm>) {
setForm((f) => ({ ...f, blocks: [...f.blocks, { uptoMin: "", price: "0.00" }] })); updatePricing(target, (p) => ({ ...p, blocks: p.blocks.map((b, j) => (j === i ? { ...b, ...patch } : b)) }));
} }
function removeBlock(i: number) { // Insert a bounded band just BEFORE the open-ended tail, so the last block stays open-ended.
setForm((f) => ({ ...f, blocks: f.blocks.filter((_, j) => j !== i) })); 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() { async function publish() {
@@ -112,7 +271,7 @@ export function TariffComposer() {
await publishTariffVersion({ currency: form.currency.trim().toUpperCase(), structure: toStructure(form) }); await publishTariffVersion({ currency: form.currency.trim().toUpperCase(), structure: toStructure(form) });
const fresh = await fetchTariff(); const fresh = await fetchTariff();
setState(fresh); setState(fresh);
setMsg({ kind: "ok", text: "New tariff version published — it's now the active rate card." }); setMsg({ kind: "ok", text: t("tariff.publishedOk") });
} catch (e) { } catch (e) {
const text = const text =
e instanceof ApiError && (e as ApiError & { problems?: string[] }).problems e instanceof ApiError && (e as ApiError & { problems?: string[] }).problems
@@ -126,77 +285,115 @@ export function TariffComposer() {
return ( return (
<section style={{ marginTop: "2rem" }}> <section style={{ marginTop: "2rem" }}>
<h2>Tariff</h2> <h2>{t("tariff.title")}</h2>
{!state?.active ? ( {!state?.active ? (
<p style={{ color: "#b45309" }}> <p style={{ color: "#b45309" }}>{t("tariff.noRateCard")}</p>
No rate card published yet — the pay station can't charge until you publish one.
</p>
) : ( ) : (
<p style={{ color: "#555" }}> <p style={{ color: "#555" }}>
Active since {new Date(state.active.effectiveFrom).toLocaleString()} ·{" "} {t("tariff.activeSince", {
{state.versions.length} version(s) in history. Publishing creates a new version; past date: new Date(state.active.effectiveFrom).toLocaleString(),
sessions keep their original pricing. count: state.versions.length,
})}
</p> </p>
)} )}
<div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.4rem 0.75rem", alignItems: "center", maxWidth: 460 }}> <div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.4rem 0.75rem", alignItems: "center", maxWidth: 460 }}>
<label>Currency</label> <label>{t("tariff.currency")}</label>
<input value={form.currency} onChange={(e) => set("currency", e.target.value)} maxLength={3} style={{ width: 80 }} /> <input value={form.currency} onChange={(e) => set("currency", e.target.value)} maxLength={3} style={{ width: 80 }} />
<label>Free entry grace (min)</label> <label>{t("tariff.freeEntryGrace")}</label>
<input value={form.gracePeriodEntryMin} onChange={(e) => set("gracePeriodEntryMin", e.target.value)} /> <input value={form.gracePeriodEntryMin} onChange={(e) => set("gracePeriodEntryMin", e.target.value)} />
<label>Billing increment (min)</label> <label>{t("tariff.billingIncrement")}</label>
<input value={form.incrementMin} onChange={(e) => set("incrementMin", e.target.value)} /> <input value={form.incrementMin} onChange={(e) => set("incrementMin", e.target.value)} />
<label>Daily cap (blank = none)</label> <label>{t("tariff.lostTicketFee")}</label>
<input value={form.dailyCap} onChange={(e) => set("dailyCap", e.target.value)} placeholder="e.g. 12.00" />
<label>Lost-ticket fee</label>
<input value={form.lostTicket} onChange={(e) => set("lostTicket", e.target.value)} /> <input value={form.lostTicket} onChange={(e) => set("lostTicket", e.target.value)} />
<label>Exit walk-back grace (min)</label> <label>{t("tariff.exitGrace")}</label>
<input value={form.gracePeriodExitMin} onChange={(e) => set("gracePeriodExitMin", e.target.value)} /> <input value={form.gracePeriodExitMin} onChange={(e) => set("gracePeriodExitMin", e.target.value)} />
</div> </div>
<h3 style={{ marginBottom: "0.25rem" }}>Rate blocks</h3> {/* The DEFAULT card — always-active rate. Front-and-centre; a site that never
<p style={{ color: "#777", margin: "0 0 0.5rem", fontSize: "0.9em" }}> wants tiers just edits this and publishes a bare V1 structure. */}
Consumed in order as time accrues. "Up to (min)" is the block's upper bound; leave the last <h3 style={{ marginBottom: "0.25rem" }}>{t("tariff.defaultCard")}</h3>
block's bound blank for "thereafter". Price is per billing increment. <p style={{ color: "#777", margin: "0 0 0.5rem", fontSize: "0.9em" }}>{t("tariff.defaultCardHint")}</p>
</p> <PricingEditor
<table style={{ borderCollapse: "collapse" }}> t={t}
<thead> pricing={form.base}
<tr style={{ textAlign: "left", color: "#555" }}> onMode={(mode) => updatePricing("base", (p) => ({ ...p, mode }))}
<th style={{ padding: "0 0.5rem" }}>Up to (min)</th> onFlat={(flat) => updatePricing("base", (p) => ({ ...p, flat }))}
<th style={{ padding: "0 0.5rem" }}>Price / increment</th> onCap={(dailyCap) => updatePricing("base", (p) => ({ ...p, dailyCap }))}
<th /> onBlock={(i, patch) => setBlock("base", i, patch)}
</tr> onAddBlock={() => addBlock("base")}
</thead> onRemoveBlock={(i) => removeBlock("base", i)}
<tbody> />
{form.blocks.map((b, i) => (
<tr key={i}> {/* Advanced: time & seasonal/category TIERS (opt-in). Empty ⇒ V1 is published. */}
<td style={{ padding: "0.15rem 0.5rem" }}> <details style={{ marginTop: "1.25rem" }} open={form.tiers.length > 0}>
<input <summary style={{ cursor: "pointer", fontWeight: 600 }}>{t("tariff.tiersAdvanced")}</summary>
value={b.uptoMin} <p style={{ color: "#777", margin: "0.4rem 0", fontSize: "0.9em" }}>{t("tariff.tiersHint")}</p>
onChange={(e) => setBlock(i, { uptoMin: e.target.value })} {form.tiers.map((tr, i) => (
placeholder={i === form.blocks.length - 1 ? "thereafter" : "e.g. 60"} <fieldset key={i} style={{ border: "1px solid #ddd", borderRadius: 6, padding: "0.6rem 0.8rem", marginBottom: "0.75rem" }}>
style={{ width: 110 }} <legend style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
/> <input
</td> value={tr.name}
<td style={{ padding: "0.15rem 0.5rem" }}> onChange={(e) => setTier(i, { name: e.target.value })}
<input value={b.price} onChange={(e) => setBlock(i, { price: e.target.value })} style={{ width: 90 }} /> placeholder={t("tariff.tierName")}
</td> style={{ width: 140 }}
<td> />
<button type="button" onClick={() => removeBlock(i)} disabled={form.blocks.length <= 1}> <button type="button" onClick={() => removeTier(i)}>
Remove {t("tariff.remove")}
</button> </button>
</td> </legend>
</tr> <div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.35rem 0.75rem", alignItems: "center", maxWidth: 520 }}>
))} <label>{t("tariff.tierPriority")}</label>
</tbody> <input value={tr.priority} onChange={(e) => setTier(i, { priority: e.target.value })} style={{ width: 70 }} />
</table> <label>{t("tariff.tierCategory")}</label>
<button type="button" onClick={addBlock} style={{ marginTop: "0.4rem" }}> <input value={tr.category} onChange={(e) => setTier(i, { category: e.target.value })} placeholder={t("tariff.tierCategoryPh")} style={{ width: 140 }} />
+ Add block <label>{t("tariff.tierDays")}</label>
</button> <span style={{ display: "flex", gap: "0.3rem", flexWrap: "wrap" }}>
{[1, 2, 3, 4, 5, 6, 0].map((d) => (
<label key={d} style={{ display: "inline-flex", alignItems: "center", gap: "0.15rem", fontSize: "0.85em" }}>
<input type="checkbox" checked={tr.dow.includes(d)} onChange={() => toggleDow(i, d)} />
{t(`tariff.dow${d}`)}
</label>
))}
</span>
<label>{t("tariff.tierHours")}</label>
<span style={{ display: "inline-flex", gap: "0.3rem", alignItems: "center" }}>
<input value={tr.fromHour} onChange={(e) => setTier(i, { fromHour: e.target.value })} placeholder="22:00" style={{ width: 70 }} />
<span>–</span>
<input value={tr.toHour} onChange={(e) => setTier(i, { toHour: e.target.value })} placeholder="06:00" style={{ width: 70 }} />
{tr.fromHour && tr.toHour && tr.toHour <= tr.fromHour && (
<span style={{ color: "#777", fontSize: "0.8em" }}>{t("tariff.tierOvernight")}</span>
)}
</span>
<label>{t("tariff.tierDates")}</label>
<span style={{ display: "inline-flex", gap: "0.3rem", alignItems: "center" }}>
<input type="date" value={tr.dateFrom} onChange={(e) => setTier(i, { dateFrom: e.target.value })} />
<span>–</span>
<input type="date" value={tr.dateTo} onChange={(e) => setTier(i, { dateTo: e.target.value })} />
</span>
</div>
<div style={{ marginTop: "0.5rem" }}>
<PricingEditor
t={t}
pricing={tr.pricing}
onMode={(mode) => updatePricing(i, (p) => ({ ...p, mode }))}
onFlat={(flat) => updatePricing(i, (p) => ({ ...p, flat }))}
onCap={(dailyCap) => updatePricing(i, (p) => ({ ...p, dailyCap }))}
onBlock={(bi, patch) => setBlock(i, bi, patch)}
onAddBlock={() => addBlock(i)}
onRemoveBlock={(bi) => removeBlock(i, bi)}
/>
</div>
</fieldset>
))}
<button type="button" onClick={addTier}>
{t("tariff.addTier")}
</button>
</details>
<div style={{ marginTop: "1rem" }}> <div style={{ marginTop: "1rem" }}>
<button type="button" onClick={publish} disabled={saving}> <button type="button" onClick={publish} disabled={saving}>
{saving ? "Publishing…" : "Publish new version"} {saving ? t("tariff.publishing") : t("tariff.publishNewVersion")}
</button> </button>
</div> </div>
{msg && ( {msg && (
@@ -205,3 +402,82 @@ export function TariffComposer() {
</section> </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>
);
}
+334 -22
View File
@@ -45,10 +45,13 @@ export class ApiError extends Error {
// --- Auth ----------------------------------------------------------------- // --- Auth -----------------------------------------------------------------
export type Role = "admin" | "operator" | "cashier" | "readonly"; export type Role = "admin" | "operator" | "cashier" | "readonly";
export type Lang = "sq" | "en";
export interface SessionUser { export interface SessionUser {
id: string; id: string;
username: string; username: string;
role: Role; role: Role;
/** Preferred UI language (loaded from the server on login). */
language: Lang;
} }
export function login(username: string, password: string): Promise<SessionUser> { export function login(username: string, password: string): Promise<SessionUser> {
@@ -62,6 +65,11 @@ export function logout(): Promise<{ ok: boolean }> {
return apiFetch("/api/auth/logout", { method: "POST" }); return apiFetch("/api/auth/logout", { method: "POST" });
} }
/** Persist the current user's UI language preference (restored on next login). */
export function setLanguagePref(language: Lang): Promise<{ language: Lang }> {
return apiFetch("/api/auth/language", { method: "PUT", body: JSON.stringify({ language }) });
}
/** Returns the current user, or null if not authenticated. */ /** Returns the current user, or null if not authenticated. */
export async function fetchMe(): Promise<SessionUser | null> { export async function fetchMe(): Promise<SessionUser | null> {
try { try {
@@ -186,6 +194,15 @@ export function assignDevice(body: AssignBody): Promise<AssignResult> {
return apiFetch("/api/setup/assign", { method: "POST", body: JSON.stringify(body) }); return apiFetch("/api/setup/assign", { method: "POST", body: JSON.stringify(body) });
} }
/** Re-configure an existing device in place, keeping its id (and so its push
* URL). Category/driver are fixed at create time, so only config changes. */
export function editDevice(
id: string,
body: Omit<AssignBody, "category" | "driverId">,
): Promise<AssignResult> {
return apiFetch(`/api/setup/assign/${id}`, { method: "PATCH", body: JSON.stringify(body) });
}
/** A persisted device assignment (one per instance; machine-only secrets stripped). */ /** A persisted device assignment (one per instance; machine-only secrets stripped). */
export interface Assignment { export interface Assignment {
id: string; id: string;
@@ -223,7 +240,10 @@ export interface TariffBlock {
uptoMin: number | null; uptoMin: number | null;
priceMinorPerIncrement: number; 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; gracePeriodEntryMin: number;
incrementMin: number; incrementMin: number;
blocks: TariffBlock[]; blocks: TariffBlock[];
@@ -232,6 +252,39 @@ export interface TariffStructure {
gracePeriodExitMin: number; gracePeriodExitMin: number;
overstay: "reprice"; 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 { export interface TariffVersion {
id: string; id: string;
tariffId: string; tariffId: string;
@@ -260,48 +313,114 @@ export function publishTariffVersion(body: {
return apiFetch("/api/tariff/versions", { method: "POST", body: JSON.stringify(body) }); return apiFetch("/api/tariff/versions", { method: "POST", body: JSON.stringify(body) });
} }
// --- Permits -------------------------------------------------------------- // --- Subscriptions --------------------------------------------------------
export interface PermitCredential { export interface SubscriptionCredential {
kind: "rf" | "qr"; kind: "rf" | "qr";
value: string; value: string;
} }
export interface Permit { export interface Subscription {
id: string; id: string;
holderName: string | null; holderName: string | null;
contact: string | null; contact: string | null;
/** Recurring price in minor units (e.g. 1000000 = 10,000.00). null = not set. */
priceMinor: number | null;
period: "monthly";
currency: string | null;
maxConcurrent: number | null; maxConcurrent: number | null;
validFrom: string | null; validFrom: string | null;
validTo: string | null; validTo: string | null;
status: "active" | "suspended" | "revoked"; status: "active" | "suspended" | "revoked";
credentials: PermitCredential[]; credentials: SubscriptionCredential[];
plates: string[]; plates: string[];
} }
export type PermitInput = Omit<Permit, "id" | "status"> & { /** A credential as SENT to the server: a QR value may be omitted/blank → the server
status?: Permit["status"]; * auto-generates an unguessable code. RF must carry the card id. */
export interface SubscriptionCredentialInput {
kind: "rf" | "qr";
value?: string;
}
export type SubscriptionInput = {
holderName: string | null;
contact: string | null;
priceMinor: number | null;
period: "monthly";
currency: string | null;
maxConcurrent: number | null;
validFrom: string | null;
validTo: string | null;
/** Months paid for: when set (with validFrom), validTo = validFrom + months. */
months?: number | null;
status?: Subscription["status"];
credentials: SubscriptionCredentialInput[];
plates: string[];
}; };
export function fetchPermits(): Promise<{ permits: Permit[] }> { /** The create response = the saved subscription + the auto-print outcome. */
return apiFetch("/api/permits"); export type SubscriptionCreated = Subscription & {
printed: boolean;
printedBy?: string;
printError?: string;
};
export function fetchSubscriptions(): Promise<{ subscriptions: Subscription[] }> {
return apiFetch("/api/subscriptions");
} }
export function createPermit(body: PermitInput): Promise<Permit> { export function createSubscription(body: SubscriptionInput): Promise<SubscriptionCreated> {
return apiFetch("/api/permits", { method: "POST", body: JSON.stringify(body) }); return apiFetch("/api/subscriptions", { method: "POST", body: JSON.stringify(body) });
} }
export function updatePermit(id: string, body: PermitInput): Promise<Permit> { /** Re-print a subscription's QR card (failed auto-print / lost card). */
return apiFetch(`/api/permits/${id}`, { method: "PUT", body: JSON.stringify(body) }); export function printSubscription(id: string): Promise<{ ok: boolean; printedBy: string }> {
return apiFetch(`/api/subscriptions/${id}/print`, { method: "POST" });
} }
export function revokePermit(id: string): Promise<Permit> {
return apiFetch(`/api/permits/${id}/revoke`, { method: "POST" }); // --- Credential capture ("enroll a card" on a chosen reader) ---------------
export interface ReaderInfo {
id: string;
driverId: string;
direction: "entry" | "exit" | "both";
} }
export function deletePermit(id: string): Promise<void> { export type CaptureState =
return apiFetch(`/api/permits/${id}`, { method: "DELETE" }); | { status: "idle" }
| { status: "armed"; deviceId: string; armedAt: number; expiresAt: number }
| { status: "captured"; deviceId: string; value: string; capturedAt: number }
| { status: "expired"; deviceId: string };
export function fetchReaders(): Promise<{ readers: ReaderInfo[] }> {
return apiFetch("/api/subscriptions/readers");
}
export function armCapture(deviceId: string): Promise<{ expiresAt: number }> {
return apiFetch("/api/subscriptions/capture/arm", { method: "POST", body: JSON.stringify({ deviceId }) });
}
export function pollCapture(): Promise<CaptureState> {
return apiFetch("/api/subscriptions/capture");
}
export function cancelCapture(): Promise<{ ok: boolean }> {
return apiFetch("/api/subscriptions/capture/cancel", { method: "POST" });
}
export function updateSubscription(id: string, body: SubscriptionInput): Promise<Subscription> {
return apiFetch(`/api/subscriptions/${id}`, { method: "PUT", body: JSON.stringify(body) });
}
export function revokeSubscription(id: string): Promise<Subscription> {
return apiFetch(`/api/subscriptions/${id}/revoke`, { method: "POST" });
}
export function deleteSubscription(id: string): Promise<void> {
return apiFetch(`/api/subscriptions/${id}`, { method: "DELETE" });
} }
// --- Shifts --------------------------------------------------------------- // --- Shifts ---------------------------------------------------------------
export interface ShiftStatus { export interface ShiftStatus {
/** The requesting (logged-in) operator. */
operator: string; operator: string;
open: { startedAt: string } | null; /** The SINGLE site-wide open shift (startedAt + whose), or null if none open. */
open: { startedAt: string; operator: string | null } | null;
/** True iff the open shift belongs to the requesting operator (can close it). */
isMine: boolean;
/** Live physical drawer balance (cash payments + cash movements). */
drawerMinor: number;
currency: string | null;
} }
export interface ShiftReport { export interface ShiftReport {
operator: string; operator: string;
@@ -311,19 +430,35 @@ export interface ShiftReport {
cardTotalMinor: number; cardTotalMinor: number;
currency: string | null; currency: string | null;
paymentCount: number; paymentCount: number;
// Drawer (carries across shifts).
openingFloatMinor: number;
cashAddedMinor: number;
cashRemovedMinor: number;
expectedDrawerMinor: number;
printed: boolean; printed: boolean;
} }
export function fetchShift(): Promise<ShiftStatus> { export function fetchShift(): Promise<ShiftStatus> {
return apiFetch("/api/shift/current"); return apiFetch("/api/shift/current");
} }
export function openShift(): Promise<{ startedAt: string }> { export function openShift(): Promise<{ startedAt: string; openingFloatMinor: number }> {
return apiFetch("/api/shift/open", { method: "POST" }); return apiFetch("/api/shift/open", { method: "POST" });
} }
export function closeShift(): Promise<ShiftReport> { export function closeShift(): Promise<ShiftReport> {
return apiFetch("/api/shift/close", { method: "POST" }); return apiFetch("/api/shift/close", { method: "POST" });
} }
/** Admin loads/removes physical drawer cash. amountMinor signed: + load, − remove. */
export function recordCashMovement(
amountMinor: number,
reason: string,
): Promise<{ amountMinor: number; balanceMinor: number }> {
return apiFetch("/api/cash-movement", {
method: "POST",
body: JSON.stringify({ amountMinor, reason }),
});
}
// --- Site config / occupancy ---------------------------------------------- // --- Site config / occupancy ----------------------------------------------
export interface Occupancy { export interface Occupancy {
@@ -333,12 +468,189 @@ export interface Occupancy {
full: boolean; full: boolean;
} }
/** Capacity + optional park metadata (all nullable). Mirrors site_config. */
export interface SiteConfig {
capacity: number | null;
/** Default for the booth "print exit ticket" checkbox (booth-geography knob). */
exitVoucherDefault: boolean;
/** Site default monthly subscription price (minor units); pre-fills the form. */
subscriptionMonthlyPriceMinor: number | null;
parkName: string | null;
operatorName: string | null;
/** NIUS — Albanian tax/identification number. */
nius: string | null;
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> { export function fetchOccupancy(): Promise<Occupancy> {
return apiFetch("/api/occupancy"); return apiFetch("/api/occupancy");
} }
export function fetchSiteConfig(): Promise<{ capacity: number | null }> {
// --- Device status (the booth footer) -------------------------------------
/** Live status of one configured device — mirrors the server's DeviceStatusEvent.
* Every enabled device is polled (printers via rich readStatus, the rest via
* healthCheck) and flattened to one traffic-light. Pushed over the WS; the REST
* snapshot below is the initial load / fallback. */
export interface DeviceStatus {
deviceId: string;
driverId: string;
category: "access" | "reader" | "camera" | "printer";
/** Role/direction token for the footer label (NOT the vendor) — the client
* localises it next to the category, e.g. "Lexuesi hyrje", "Printer kabina". */
roleKind: "entry" | "exit" | "both" | "mixed" | "lane" | "booth" | null;
state: "ready" | "degraded" | "offline";
detail?: string;
checkedAt: string;
}
export function fetchDeviceStatus(): Promise<{ devices: DeviceStatus[] }> {
return apiFetch("/api/devices/status");
}
// --- Ledger events (the signed audit trail; read-only) --------------------
/** A persisted ledger row. Re-exported from shared so UI code has one source of
* truth for the event shape (the same type the WS pushes). */
export type { LedgerEvent } from "@parking/shared";
/** Recent ledger events, newest first (default 100, max 1000). Used for the
* booth feed's initial load; live updates then arrive over the WS. `since` (ISO)
* scopes to events at/after that instant — the booth passes the current shift's
* start so the feed shows ONLY this shift's activity. */
export function fetchEvents(
limit = 100,
since?: string,
): Promise<{ events: import("@parking/shared").LedgerEvent[] }> {
const qs = new URLSearchParams({ limit: String(limit) });
if (since) qs.set("since", since);
return apiFetch(`/api/events?${qs.toString()}`);
}
// --- Booth: session lookup, payment, exit ---------------------------------
/** One-read session view for the booth pay/exit modal (mirrors server SessionLookup). */
export interface SessionLookup {
identity: string;
found: boolean;
open: boolean;
enteredAt: string | null;
exitedAt: string | null;
paidAt: string | null;
amountMinor: number | null;
currency: string | null;
withinGrace: boolean;
graceExpiresAt: string | null;
/** A subscription occurrence (prepaid — no pay flow; barrier-open assist only). */
subscription: boolean;
subscriptionId: string | null;
subscriptionHolder: string | null;
}
/** Look up a ticket/session for the booth modal (entry/exit, paid, amount owed). */
export function lookupSession(identity: string): Promise<SessionLookup> {
return apiFetch(`/api/session/${encodeURIComponent(identity)}`);
}
/** One row in the booth Active Sessions list (mirrors server ActiveSession). */
export interface ActiveSession {
identity: string;
source: string | null;
enteredAt: string;
exitedAt: string | null;
open: boolean;
paidAt: string | null;
amountMinor: number | null;
currency: string | null;
withinGrace: boolean;
graceExpiresAt: string | null;
/** A subscription occurrence (prepaid — no pay flow; barrier-open assist only). */
subscription: boolean;
subscriptionId: string | null;
subscriptionHolder: string | null;
}
/** Active sessions: still-inside OR exited-but-within-grace (barrier unconfirmed). */
export function fetchActiveSessions(): Promise<{ sessions: ActiveSession[] }> {
return apiFetch("/api/sessions/active");
}
/** Human-intervention barrier re-open for a paid active session (damaged ticket /
* phantom re-close). Signs an audited anomaly; never a 2nd exit. */
export function reopenBarrier(identity: string): Promise<{ ok: true; opened: boolean; reason?: string }> {
return apiFetch("/api/barrier/reopen", { method: "POST", body: JSON.stringify({ identity }) });
}
/** Take payment for a session → signed payment event. `overrideMinor` sets an
* operator amount (lost ticket / dispute). */
export function paySession(
identity: string,
tender: "cash" | "card",
overrideMinor?: number,
): Promise<{ amountMinor: number; currency: string }> {
return apiFetch("/api/pay", {
method: "POST",
body: JSON.stringify({ identity, tender, ...(overrideMinor != null ? { overrideMinor } : {}) }),
});
}
/** Booth-driven exit result. `opened:false` = exit recorded but barrier didn't
* open (payment stands; operator opens manually). */
export type BoothExitResult = { ok: true; opened: boolean; reason?: string };
/** Validate + open the barrier for a session from the booth (when near the exit). */
export function boothExit(identity: string): Promise<BoothExitResult> {
return apiFetch("/api/exit", { method: "POST", body: JSON.stringify({ identity }) });
}
/** Print an exit voucher (paid ticket id reprinted as a barcode) + payment detail,
* for self-exit at a distant exit. Requires the session to be paid. */
export function printVoucher(identity: string): Promise<{ ok: boolean; printedBy: string }> {
return apiFetch("/api/voucher", { method: "POST", body: JSON.stringify({ identity }) });
}
/** Print a standalone PAYMENT RECEIPT (entry/paid/duration/amount, no barcode).
* Auto-printed after a payment when no voucher is issued; also the "reprint"
* action. Requires the session to be paid. */
export function printReceipt(identity: string): Promise<{ ok: boolean; printedBy: string }> {
return apiFetch("/api/receipt", { method: "POST", body: JSON.stringify({ identity }) });
}
// --- Snapshots (entry/exit evidence images) -------------------------------
export interface SnapshotMeta {
id: string;
direction: "entry" | "exit" | null;
deviceId: string;
identity: string;
contentType: string;
capturedAt: string;
}
/** Snapshot metadata for a session identity (newest first). Image bytes are at
* `/api/snapshots/:id` — use that URL directly as an <img src>. */
export function fetchSnapshots(identity: string): Promise<{ snapshots: SnapshotMeta[] }> {
return apiFetch(`/api/snapshots/by-identity/${encodeURIComponent(identity)}`);
}
/** URL for a snapshot's image bytes (cookie-authed; usable as <img src>). */
export function snapshotImageUrl(id: string): string {
return `/api/snapshots/${encodeURIComponent(id)}`;
}
export function fetchSiteConfig(): Promise<SiteConfig> {
return apiFetch("/api/site-config"); return apiFetch("/api/site-config");
} }
export function setCapacity(capacity: number | null): Promise<{ capacity: number | null }> { /** PUT a partial config — only the fields supplied are changed. */
return apiFetch("/api/site-config", { method: "PUT", body: JSON.stringify({ capacity }) }); export function saveSiteConfig(patch: Partial<SiteConfig>): Promise<SiteConfig> {
return apiFetch("/api/site-config", { method: "PUT", body: JSON.stringify(patch) });
}
export function setCapacity(capacity: number | null): Promise<SiteConfig> {
return saveSiteConfig({ capacity });
} }
+196
View File
@@ -0,0 +1,196 @@
@import "tailwindcss";
/* 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.
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 {
/* ============================================================
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 — 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) */
/* ============================================================
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;
/* ---------- 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,
body,
#root {
height: 100%;
}
body {
margin: 0;
background: var(--color-term-bg);
color: var(--color-term-text);
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.4;
-webkit-font-smoothing: antialiased;
/* Crisp text and no rubber-banding on the fixed appliance display. */
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;
scrollbar-color: var(--color-term-border) transparent;
}
/* A visible keyboard-focus ring in the amber accent (keyboard-first UI). */
:focus-visible {
outline: 1px solid var(--color-term-amber);
outline-offset: 1px;
}
+30
View File
@@ -0,0 +1,30 @@
// Small formatting helpers for the booth. Money is integer MINOR units (never a
// float — matches the tariff/ledger model); duration is whole minutes.
/** Format integer minor units + ISO-4217 currency as a major-unit string. */
export function formatMoney(amountMinor: number, currency: string): string {
const major = amountMinor / 100;
try {
return new Intl.NumberFormat(undefined, { style: "currency", currency }).format(major);
} catch {
// Unknown/garbled currency code — fall back to a plain number + the code.
return `${major.toFixed(2)} ${currency}`;
}
}
/** Human duration between two ISO times, e.g. "2h 14m" / "47m" / "0m". */
export function formatDuration(fromIso: string, toIso: string): string {
const ms = Date.parse(toIso) - Date.parse(fromIso);
if (!Number.isFinite(ms) || ms < 0) return "—";
const mins = Math.floor(ms / 60_000);
const h = Math.floor(mins / 60);
const m = mins % 60;
return h > 0 ? `${h}h ${m}m` : `${m}m`;
}
/** Local time-of-day HH:MM:SS from an ISO string. */
export function formatTime(iso: string | null): string {
if (!iso) return "—";
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? "—" : d.toTimeString().slice(0, 8);
}
+316
View File
@@ -0,0 +1,316 @@
// English (en). Mirrors the key structure of sq.ts (the default/fallback). Any key
// missing here falls back to Albanian. See wiki/concepts/i18n.md.
import type { Catalog } from "./sq.js";
export const en: Catalog = {
common: {
loading: "Loading…",
logout: "Log out",
cancel: "Cancel",
close: "Close",
save: "Save",
none: "—",
},
auth: {
title: "Parking System",
username: "Username",
password: "Password",
signIn: "Sign in",
signingIn: "Signing in…",
},
nav: {
booth: "Booth",
shift: "Shift",
setup: "Setup",
tariff: "Tariff",
subscriptions: "Subscriptions",
site: "Site",
},
status: {
live: "LIVE",
connecting: "CONNECTING",
offline: "OFFLINE",
},
devices: {
footerTitle: "Devices",
none: "No devices configured.",
catAccess: "Barrier",
catReader: "Reader",
catCamera: "Camera",
catPrinter: "Printer",
// Role/direction suffixes for the chip label (e.g. "Reader entry").
role: {
entry: "entry",
exit: "exit",
both: "entry/exit",
mixed: "entry/exit",
lane: "at lane",
booth: "at booth",
},
state: {
ready: "ready",
degraded: "degraded",
offline: "offline",
},
allOk: "all ready",
issuesCount: "{{count}} with issues",
issuesTitle: "Device issues",
clickForIssues: "Click for details",
checkedAt: "checked {{time}}",
},
booth: {
processTicket: "Process ticket",
scanPlaceholder: "Scan or type ticket number…",
open: "Open",
occupancy: "Occupancy",
occUnavailable: "occupancy unavailable",
inside: "inside",
of: "of",
uncapped: "uncapped",
free: "free",
lotFull: "● lot full",
liveFeed: "Live feed",
events: "events",
noEventsYet: "No events yet — entries and exits will stream here.",
activeSessions: "Active sessions",
insideCount: "inside",
noActiveSessions: "No active sessions.",
inAt: "in",
openPayExit: "Open pay / exit",
openBarrier: "Open barrier",
openBarrierTitle: "Human-intervention barrier open (audited)",
barrierOpened: "barrier opened",
openManually: "open manually",
badgeExiting: "exiting",
badgePaid: "paid",
badgeUnpaid: "unpaid",
badgeSubscription: "subscription",
evtEntry: "ENTRY",
evtExit: "EXIT",
evtPay: "PAY",
evtVoid: "VOID",
evtOpenCmd: "OPEN→",
evtOpenObserved: "OPEN✓",
evtShiftOpen: "SHIFT+",
evtShiftZ: "SHIFT Z",
evtCashMovement: "CASH",
evtAnomaly: "ANOMALY",
},
tariff: {
title: "Tariff",
noRateCard: "No rate card published yet — the pay station can't charge until you publish one.",
activeSince: "Active since {{date}} · {{count}} version(s) in history. Publishing creates a new version; past sessions keep their original pricing.",
currency: "Currency",
freeEntryGrace: "Free entry grace (min)",
billingIncrement: "Billing increment (min)",
dailyCap: "Daily cap (blank = none)",
dailyCapPh: "e.g. 12.00",
lostTicketFee: "Lost-ticket fee",
exitGrace: "Exit walk-back grace (min)",
rateBlocks: "Rate blocks",
rateBlocksHint: "Each band lasts a number of hours and bills at its own price; bands are consumed in order (the first hours, then the next hours). The last band is \"thereafter\" (open-ended) — its price applies once the ladder is exhausted. Price is per billing increment.",
bandDuration: "Band duration",
hoursUnit: "hours",
egHours: "e.g. 2",
pricePerIncrement: "Price / increment",
thereafter: "thereafter (open-ended)",
remove: "Remove",
addBlock: "+ Add block",
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",
unnamed: "(unnamed)",
unbound: "unbound",
car_one: "{{count}} car",
car_other: "{{count}} cars",
cred: "cred",
plates: "{{count}} plate(s)",
noPrice: "no price",
perMonth: "month",
monthlyPrice: "Monthly price",
pricePlaceholder: "e.g. 10000",
edit: "Edit",
revoke: "Revoke",
delete: "Delete",
noneYet: "No subscriptions yet.",
add: "+ Add subscription",
new: "New subscription",
editTitle: "Edit subscription",
holderName: "Holder name",
contact: "Contact",
carLimit: "Car limit",
limitCarsInAtOnce: "limit cars in at once",
validFrom: "Valid from",
validTo: "Valid to",
months: "Months",
monthsHint: "months paid",
coverageHint: "until {{end}}",
totalDue: "total {{total}}",
validToOverride: "Valid to (manual)",
isoDateOptional: "ISO date (optional)",
boundPlates: "Bound plates",
commaSeparatedOptional: "comma-separated (optional)",
credentials: "Credentials",
credentialsCardQr: "Credentials (card / QR)",
rfCardTag: "RF card/tag",
rfCardTagSoon: "RF card/tag (soon)",
rfPlaceholder: "card number (or read the card)",
readCard: "Read card",
captureChooseReader: "Choose a reader, then present the card:",
captureNoReaders: "No readers configured.",
captureWaiting: "Present the card to the reader…",
captureTimeout: "Timed out with no card read. Try again.",
captured: "Card read: {{value}}",
qr: "QR",
qrAutoGen: "QR code is auto-generated on save",
credentialValue: "credential value",
addCredential: "+ credential",
needCredentialOrPlate: "A subscription needs at least one credential OR one bound plate.",
save: "Save",
cancel: "Cancel",
saved: "Subscription saved.",
savedPrinted: "Subscription saved — QR code printed.",
savedPrintFailed: "Subscription saved, but printing failed ({{error}}). Use \"Print code\".",
printCode: "Print code",
printedOn: "Code printed on {{printer}}.",
confirmRevoke: "Revoke subscription for {{name}}? It will be refused at the barrier.",
confirmDelete: "Delete subscription for {{name}}? (Past events are kept.)",
statusActive: "active",
statusSuspended: "suspended",
statusRevoked: "revoked",
},
site: {
occupancy: "Occupancy:",
noCapacitySet: "(no capacity set)",
free: "free",
full: "FULL",
capacityLabel: "Capacity (blank = no limit):",
capacityPlaceholder: "e.g. 120",
printExitDefault: "Print exit ticket by default",
printExitHint: "(booth far from exit → customer self-exits with a voucher)",
parkDetails: "Park details (optional — shown on tickets/receipts)",
save: "Save",
saved: "Saved.",
fieldParkName: "Park name",
fieldParkNamePh: "e.g. Acme Parking",
fieldOperator: "Operator (legal name)",
fieldOperatorPh: "operating company",
fieldNius: "NIUS",
fieldNiusPh: "e.g. L01234567A",
fieldAddress: "Address",
fieldPhone: "Phone",
fieldEmail: "Email",
},
shift: {
label: "Shift:",
open: "open",
notStarted: "not started",
since: "since",
startShift: "Start shift",
starting: "Starting…",
endShift: "End shift",
ending: "Ending…",
drawer: "Drawer:",
openingFloatInherited: "(opening float inherited from the prior shift)",
drawerCashAdmin: "Drawer cash (admin) — load or remove the float",
amount: "amount",
reasonPlaceholder: "reason (e.g. opening float)",
load: "Load +",
remove: "Remove −",
enterPositive: "Enter a positive amount.",
drawerNow: "Drawer now {{amount}}.",
zReport: "Z-REPORT",
payments: "Payments:",
cash: "Cash:",
card: "Card:",
drawerSection: "— Drawer —",
openingFloat: "Opening float:",
cashTaken: "Cash taken:",
cashAdded: "Cash added:",
cashRemoved: "Cash removed:",
expectedDrawer: "Expected drawer:",
printedToReceipt: "Printed to booth receipt.",
recordedNoPrinter: "Recorded (no printer to print to).",
// Header shift control + the booth shift gate.
headerNoShift: "No shift",
headerOpen: "Open shift",
headerClose: "Close shift",
headerHeldBy: "Shift open — {{operator}}",
headerHeldByShort: "Shift: {{operator}}",
gateTitle: "Open a shift to process tickets",
gateBody:
"No shift is open. Open your shift so payments and exits are recorded against it.",
gateOtherTitle: "The open shift belongs to another operator",
gateOtherBody:
"{{operator}} has an open shift. Only one shift may be open at a time — they must close theirs before you can open yours.",
openNow: "Open shift now",
opening: "Opening…",
},
pay: {
ticket: "Ticket",
entry: "Entry",
now: "Now",
duration: "Duration",
statusLabel: "Status",
paid: "PAID",
unpaid: "UNPAID",
total: "Total",
noTariff: "no tariff",
tender: "Tender",
cash: "Cash",
card: "Card",
printExitVoucher: "Print exit ticket",
selfExitHint: "(customer self-exits at the exit)",
payAndOpen: "Pay + open barrier",
payAndVoucher: "Pay + print voucher",
openBarrier: "Open barrier",
printVoucher: "Print voucher",
takingPayment: "taking payment…",
printingVoucher: "printing voucher…",
opening: "opening…",
noSessionFound: "No session found for this ticket.",
alreadyClosed: "This session is already closed (exited {{time}}).",
lookingUp: "looking up…",
paidBarrierOpened: "Paid — barrier opened. Car may exit.",
paidExitRecorded: "Paid and exit recorded, but the barrier did not open: {{reason}}.",
subscription: "SUBSCRIPTION",
plan: "Plan",
prepaid: "PREPAID",
subAssistHint: "Prepaid subscription. Open the barrier to assist the exit (faulty reader / missing card). No payment.",
subBarrierOpened: "Barrier opened for the subscriber (intervention recorded).",
voucherPrinted: "Exit voucher printed on {{printer}}. Customer self-exits at the exit.",
// payment receipt (transparency slip)
receiptPrintFailed: "(receipt didn't print — use \"Reprint receipt\".)",
receiptReprinted: "Receipt reprinted on {{printer}}.",
reprintReceipt: "Reprint receipt",
reprinting: "printing…",
noSnapshots: "no snapshots",
loadingSnapshots: "loading snapshots…",
},
};
+33
View File
@@ -0,0 +1,33 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import { sq } from "./sq.js";
import { en } from "./en.js";
// i18next setup for the operator UI. Albanian (sq) is the DEFAULT and the fallback;
// English (en) is the second language. The active language is the LOGGED-IN USER's
// stored preference (users.language), applied via setLanguage() after auth resolves
// — not localStorage, not the browser. Printed tickets are NOT governed by this
// (always Albanian, customer-facing). See wiki/concepts/i18n.md.
export type Lang = "sq" | "en";
// Single flat namespace; keys are dot-paths (e.g. "booth.processTicket"). Nested
// objects in the catalogs are walked by i18next's keySeparator.
void i18n.use(initReactI18next).init({
resources: {
sq: { translation: sq },
en: { translation: en },
},
lng: "sq",
fallbackLng: "sq",
interpolation: { escapeValue: false }, // React already escapes
returnNull: false,
});
/** Apply a language (e.g. after login resolves the user's preference). No-op if
* already active. */
export function setLanguage(lang: Lang): void {
if (i18n.language !== lang) void i18n.changeLanguage(lang);
}
export default i18n;
+325
View File
@@ -0,0 +1,325 @@
// Albanian (sq) — the DEFAULT and fallback language. Customer/operator-facing copy.
// Keys are dot-namespaced by area (common, nav, booth, …). When adding a string,
// add it here first (the fallback), then mirror the key in en.ts.
// See wiki/concepts/i18n.md.
export const sq = {
common: {
loading: "Duke u ngarkuar…",
logout: "Dil",
cancel: "Anulo",
close: "Mbyll",
save: "Ruaj",
none: "—",
},
auth: {
title: "Sistemi i Parkimit",
username: "Përdoruesi",
password: "Fjalëkalimi",
signIn: "Hyr",
signingIn: "Duke hyrë…",
},
nav: {
booth: "Kabina",
shift: "Turni",
setup: "Konfigurimi",
tariff: "Tarifa",
subscriptions: "Abonimet",
site: "Vendi",
},
status: {
live: "LIVE",
connecting: "DUKE U LIDHUR",
offline: "JASHTË LINJE",
},
devices: {
footerTitle: "Pajisjet",
none: "Asnjë pajisje e konfiguruar.",
catAccess: "Barriera",
catReader: "Lexuesi",
catCamera: "Kamera",
catPrinter: "Printer",
// Role/direction suffixes for the chip label (e.g. "Lexuesi hyrje").
role: {
entry: "hyrje",
exit: "dalje",
both: "hyrje/dalje",
mixed: "hyrje/dalje",
lane: "në korsi",
booth: "në kabinë",
},
state: {
ready: "gati",
degraded: "i dëmtuar",
offline: "jashtë linje",
},
allOk: "të gjitha gati",
issuesCount: "{{count}} me probleme",
issuesTitle: "Problemet e pajisjeve",
clickForIssues: "Kliko për detajet",
checkedAt: "kontrolluar {{time}}",
},
booth: {
processTicket: "Proceso biletën",
scanPlaceholder: "Skano ose shkruaj numrin e biletës…",
open: "Hap",
occupancy: "Prania",
occUnavailable: "zënia e padisponueshme",
inside: "brenda",
of: "nga",
uncapped: "pa kufi",
free: "Vende të lira",
lotFull: "● parkimi plot",
liveFeed: "Aktiviteti live",
events: "Evente",
noEventsYet: "Asnjë event ende — hyrjet dhe daljet do të shfaqen këtu.",
activeSessions: "Sesionet aktive",
insideCount: "brenda",
noActiveSessions: "Asnjë sesion aktiv.",
inAt: "në",
openPayExit: "Hap pagesën / daljen",
openBarrier: "Hap barrierën",
openBarrierTitle: "Hap barrierën manualisht",
barrierOpened: "barriera u hap",
openManually: "hape me dorë",
// session row badges
badgeExiting: "duke dalë",
badgePaid: "paguar",
badgeUnpaid: "papaguar",
badgeSubscription: "abonim",
// event types (live feed labels)
evtEntry: "HYRJE",
evtExit: "DALJE",
evtPay: "PAGESË",
evtVoid: "ANULIM",
evtOpenCmd: "HAP→",
evtOpenObserved: "HAP✓",
evtShiftOpen: "TURN+",
evtShiftZ: "TURN Z",
evtCashMovement: "ARKË",
evtAnomaly: "ANOMALI",
},
tariff: {
title: "Tarifa",
noRateCard: "Asnjë kartë tarifore e publikuar — arka nuk mund të faturojë derisa të publikoni një.",
activeSince: "Aktive që nga {{date}} · {{count}} version(e) në histori. Publikimi krijon një version të ri; sesionet e kaluara ruajnë çmimin origjinal.",
currency: "Monedha",
freeEntryGrace: "Periudha pa pagesë në hyrje (min)",
billingIncrement: "Intervali i faturimit (min)",
dailyCap: "Kufiri ditor (bosh = pa kufi)",
dailyCapPh: "p.sh. 12.00",
lostTicketFee: "Tarifa për biletë të humbur",
exitGrace: "Periudha e kthimit në dalje (min)",
rateBlocks: "Blloqet tarifore",
rateBlocksHint: "Çdo brez zgjat një numër orësh dhe faturohet me çmimin e tij; brezat konsumohen me radhë (orët e para, pastaj orët në vijim). Brezi i fundit është \"më pas\" (i hapur) — çmimi i tij zbatohet pas mbarimit të shkallës. Çmimi është për interval faturimi.",
bandDuration: "Kohëzgjatja e brezit",
hoursUnit: "orë",
egHours: "p.sh. 2",
pricePerIncrement: "Çmimi / interval",
thereafter: "më pas (i hapur)",
remove: "Hiq",
addBlock: "+ Shto bllok",
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",
unnamed: "(pa emër)",
unbound: "pa kufizim",
car_one: "{{count}} makinë",
car_other: "{{count}} makina",
cred: "kredencial",
plates: "{{count}} targë(a)",
noPrice: "pa çmim",
perMonth: "muaj",
monthlyPrice: "Çmimi mujor",
pricePlaceholder: "p.sh. 10000",
edit: "Ndrysho",
revoke: "Anulo",
delete: "Fshij",
noneYet: "Asnjë abonim ende.",
add: "+ Shto abonim",
new: "Abonim i ri",
editTitle: "Ndrysho abonimin",
holderName: "Emri i mbajtësit",
contact: "Kontakti",
carLimit: "Kufiri i makinave",
limitCarsInAtOnce: "kufizo makinat brenda njëkohësisht",
validFrom: "Vlen nga",
validTo: "Vlen deri",
months: "Muaj",
monthsHint: "muaj të paguar",
coverageHint: "deri më {{end}}",
totalDue: "gjithsej {{total}}",
validToOverride: "Vlen deri (manual)",
isoDateOptional: "Datë ISO (opsionale)",
boundPlates: "Targat e lidhura",
commaSeparatedOptional: "të ndara me presje (opsionale)",
credentials: "Kredencialet",
credentialsCardQr: "Kredencialet (kartë / QR)",
rfCardTag: "Kartë/etiketë RF",
rfCardTagSoon: "Kartë/etiketë RF (së shpejti)",
rfPlaceholder: "numri i kartës (ose lexo kartën)",
readCard: "Lexo kartën",
captureChooseReader: "Zgjidh lexuesin, pastaj afro kartën:",
captureNoReaders: "Asnjë lexues i konfiguruar.",
captureWaiting: "Afro kartën te lexuesi…",
captureTimeout: "Skadoi pa lexuar kartë. Provo sërish.",
captured: "Karta u lexua: {{value}}",
qr: "QR",
qrAutoGen: "kodi QR gjenerohet automatikisht në ruajtje",
credentialValue: "vlera e kredencialit",
addCredential: "+ kredencial",
needCredentialOrPlate: "Një abonim kërkon të paktën një kredencial OSE një targë të lidhur.",
save: "Ruaj",
cancel: "Anulo",
saved: "Abonimi u ruajt.",
savedPrinted: "Abonimi u ruajt — kodi QR u printua.",
savedPrintFailed: "Abonimi u ruajt, por printimi dështoi ({{error}}). Përdor \"Printo kodin\".",
printCode: "Printo kodin",
printedOn: "Kodi u printua te {{printer}}.",
confirmRevoke: "Të anulohet abonimi për {{name}}? Do të refuzohet te barriera.",
confirmDelete: "Të fshihet abonimi për {{name}}? (t e kaluara ruhen.)",
statusActive: "aktiv",
statusSuspended: "pezulluar",
statusRevoked: "anuluar",
},
site: {
occupancy: "Prania:",
noCapacitySet: "(pa kapacitet të caktuar)",
free: "lirë",
full: "PLOT",
capacityLabel: "Kapaciteti (bosh = pa kufi):",
capacityPlaceholder: "p.sh. 120",
printExitDefault: "Printo biletën e daljes si parazgjedhje",
printExitHint: "(kabina larg daljes → klienti del vetë me biletë)",
parkDetails: "Të dhënat e parkimit (opsionale — shfaqen në bileta/fatura)",
save: "Ruaj",
saved: "U ruajt.",
fieldParkName: "Emri i parkimit",
fieldParkNamePh: "p.sh. Acme Parking",
fieldOperator: "Operatori (emri ligjor)",
fieldOperatorPh: "kompania operuese",
fieldNius: "NIUS",
fieldNiusPh: "p.sh. L01234567A",
fieldAddress: "Adresa",
fieldPhone: "Telefoni",
fieldEmail: "Email",
},
shift: {
label: "Turni:",
open: "hapur",
notStarted: "i panisur",
since: "që nga",
startShift: "Fillo turnin",
starting: "Duke filluar…",
endShift: "Mbyll turnin",
ending: "Duke mbyllur…",
drawer: "Arka:",
openingFloatInherited: "(bilanci fillestar i trashëguar nga turni i mëparshëm)",
drawerCashAdmin: "Para në arkë (admin) — shto ose hiq bilancin",
amount: "shuma",
reasonPlaceholder: "arsyeja (p.sh. bilanci fillestar)",
load: "Shto +",
remove: "Hiq −",
enterPositive: "Shkruaj një shumë pozitive.",
drawerNow: "Arka tani {{amount}}.",
zReport: "RAPORT Z",
payments: "Pagesa:",
cash: "Para:",
card: "Kartë:",
drawerSection: "— Arka —",
openingFloat: "Bilanci fillestar:",
cashTaken: "Para të marra:",
cashAdded: "Para të shtuara:",
cashRemoved: "Para të hequra:",
expectedDrawer: "Arka e pritshme:",
printedToReceipt: "Printuar te printeri i kabinës.",
recordedNoPrinter: "Regjistruar (pa printer për të printuar).",
// Header shift control + the booth shift gate.
headerNoShift: "Asnjë turn",
headerOpen: "Hap turnin",
headerClose: "Mbyll turnin",
headerHeldBy: "Turn i hapur nga {{operator}}",
headerHeldByShort: "Turni: {{operator}}",
gateTitle: "Hap një turn për të proceduar biletat",
gateBody:
"Asnjë turn nuk është i hapur. Hap turnin tënd që pagesat dhe daljet të regjistrohen te ky turn.",
gateOtherTitle: "Turni i hapur i përket një operatori tjetër",
gateOtherBody:
"{{operator}} ka një turn të hapur. Vetëm një turn mund të jetë i hapur njëkohësisht — ai duhet të mbyllë turnin para se ti të hapësh tëndin.",
openNow: "Hap turnin tani",
opening: "Duke hapur…",
},
pay: {
ticket: "Bileta",
entry: "Hyrja",
now: "Tani",
duration: "Kohëzgjatja",
statusLabel: "Statusi",
paid: "PAGUAR",
unpaid: "PAPAGUAR",
total: "Totali",
noTariff: "pa tarifë",
tender: "Mënyra",
cash: "Para",
card: "Kartë",
printExitVoucher: "Printo biletë dalje",
selfExitHint: "(klienti del vetë te dalja)",
payAndOpen: "Paguaj + hap barrierën",
payAndVoucher: "Paguaj + printo biletën",
openBarrier: "Hap barrierën",
printVoucher: "Printo biletën",
takingPayment: "Duke marrë pagesën…",
printingVoucher: "Duke printuar biletën…",
opening: "Duke hapur…",
noSessionFound: "Nuk u gjet asnjë sesion për këtë biletë.",
alreadyClosed: "Ky sesion është mbyllur tashmë (doli {{time}}).",
lookingUp: "Duke kërkuar…",
paidBarrierOpened: "Paguar — barriera u hap. Automjeti mund të dalë.",
paidExitRecorded: "Paguar dhe dalja u regjistrua, por barriera nuk u hap: {{reason}}.",
subscription: "ABONIM",
plan: "Plani",
prepaid: "I PARAPAGUAR",
subAssistHint: "Abonim i parapaguar. Hap barrierën për të ndihmuar daljen (lexues me defekt / kartë e munguar). S'ka pagesë.",
subBarrierOpened: "Barriera u hap për abonentin (ndërhyrje e regjistruar).",
voucherPrinted: "Bileta e daljes u printua në {{printer}}. Klienti del vetë te dalja.",
// payment receipt (transparency slip)
receiptPrintFailed: "(fatura nuk u printua — provoni \"Riprinto faturën\".)",
receiptReprinted: "Fatura u riprintua në {{printer}}.",
reprintReceipt: "Riprinto faturën",
reprinting: "duke printuar…",
// snapshots
noSnapshots: "asnjë foto",
loadingSnapshots: "duke ngarkuar fotot…",
},
};
// The catalog SHAPE (keys + nesting), with string-typed values — so en.ts must
// supply every key but may differ in value. (Not `typeof sq` with `as const`, which
// would pin en.ts to the Albanian literals.)
type Stringify<T> = { [K in keyof T]: T[K] extends object ? Stringify<T[K]> : string };
export type Catalog = Stringify<typeof sq>;
+58
View File
@@ -0,0 +1,58 @@
import { create } from "zustand";
import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js";
// CLIENT state for the live booth feed — deliberately small. Server data (the
// authoritative event list, occupancy totals) is owned by TanStack Query; this
// store holds only what Query shouldn't: the WS connection status, the latest
// pushed occupancy snapshot, and a rolling in-memory tail of recent events for the
// live ticker. Anything durable is re-fetched via Query. See lib/query.ts.
/** Connection state of the booth WebSocket, for a status indicator in the UI. */
export type WsStatus = "connecting" | "open" | "closed";
/** Cap the in-memory live feed so a long-running booth session can't grow it
* unbounded — the full history is always available via the /api/events query. */
const MAX_FEED = 200;
interface LiveState {
status: WsStatus;
/** Most recent occupancy pushed by the server (rides on every ledger event). */
occupancy: Occupancy | null;
/** Newest-first tail of recently pushed ledger events (for the live ticker). */
feed: LedgerEvent[];
/** Live device status keyed by device id (for the footer): set from the WS
* hello snapshot, then upserted per device on each device-status push. */
devices: Record<string, DeviceStatus>;
setStatus: (s: WsStatus) => void;
setOccupancy: (o: Occupancy) => void;
pushEvent: (e: LedgerEvent) => void;
/** Replace the whole device-status set (WS hello / reconnect snapshot). */
setDevices: (list: DeviceStatus[]) => void;
/** Upsert one device's status (a device-status push). */
upsertDevice: (d: DeviceStatus) => void;
reset: () => void;
}
/** Index a device-status list by device id. */
function byId(list: DeviceStatus[]): Record<string, DeviceStatus> {
const m: Record<string, DeviceStatus> = {};
for (const d of list) m[d.deviceId] = d;
return m;
}
export const useLiveStore = create<LiveState>((set) => ({
status: "connecting",
occupancy: null,
feed: [],
devices: {},
setStatus: (status) => set({ status }),
setOccupancy: (occupancy) => set({ occupancy }),
pushEvent: (e) =>
set((s) => ({
// Newest first; de-dupe by id (a reconnect can replay) and cap the length.
feed: s.feed.some((x) => x.id === e.id) ? s.feed : [e, ...s.feed].slice(0, MAX_FEED),
})),
setDevices: (list) => set({ devices: byId(list) }),
upsertDevice: (d) => set((s) => ({ devices: { ...s.devices, [d.deviceId]: d } })),
reset: () => set({ status: "connecting", occupancy: null, feed: [], devices: {} }),
}));
+30
View File
@@ -0,0 +1,30 @@
import { QueryClient } from "@tanstack/react-query";
// Single QueryClient for the app. TanStack Query owns SERVER state (fetch, cache,
// refetch, loading/error) — wrapping the existing thin api.ts fetchers. Client/UI
// state (live feed, WS status) lives in Zustand, not here. The WS layer invalidates
// these caches on live events so Query stays the source of truth for server data.
//
// Defaults tuned for a single-appliance booth: no window-focus refetch (it's a
// kiosk, not a tab someone switches to), and a short staleTime since the WS is the
// real freshness mechanism — queries are the fallback/initial load.
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
staleTime: 5_000,
retry: 1,
},
},
});
/** Stable query keys — referenced by both the screens and the WS invalidator. */
export const qk = {
me: ["me"] as const,
occupancy: ["occupancy"] as const,
events: ["events"] as const,
activeSessions: ["active-sessions"] as const,
siteConfig: ["site-config"] as const,
shift: ["shift"] as const,
deviceStatus: ["device-status"] as const,
} as const;
+110
View File
@@ -0,0 +1,110 @@
import { useEffect, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query";
import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js";
import { qk } from "./query.js";
import { useLiveStore } from "./live-store.js";
// Booth WebSocket client. Opens ONE socket to /api/ws and turns server pushes into
// (a) live-store updates for the ticker/occupancy and (b) Query cache invalidations
// so TanStack Query remains the source of truth for durable server data. The browser
// attaches the auth cookie automatically; the backend gates by cookie + Origin
// (see routes/ws.ts). Auto-reconnects with capped backoff so a booth left running
// recovers from a server restart without a manual refresh.
/** Server → client message shapes (mirror routes/ws.ts OutMsg). */
type WsMessage =
| { kind: "hello"; occupancy: Occupancy; devices: DeviceStatus[] }
| { kind: "ledger"; event: LedgerEvent; occupancy: Occupancy }
| { kind: "printer-status"; event: unknown }
| { kind: "device-status"; event: DeviceStatus };
/** Build the ws:// or wss:// URL for the same origin the SPA is served from. */
function wsUrl(): string {
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${proto}//${window.location.host}/api/ws`;
}
export function useLiveFeed(): void {
const qc = useQueryClient();
const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice } = useLiveStore();
// Hold the socket + reconnect timer across renders; guard against StrictMode
// double-invoke and unmount.
const sockRef = useRef<WebSocket | null>(null);
const retryRef = useRef(0);
const closedRef = useRef(false);
useEffect(() => {
closedRef.current = false;
const connect = () => {
if (closedRef.current) return;
setStatus(retryRef.current === 0 ? "connecting" : "connecting");
const sock = new WebSocket(wsUrl());
sockRef.current = sock;
sock.onopen = () => {
retryRef.current = 0;
setStatus("open");
};
sock.onmessage = (ev) => {
let msg: WsMessage;
try {
msg = JSON.parse(ev.data as string) as WsMessage;
} catch {
return; // ignore malformed frames
}
if (msg.kind === "hello") {
setOccupancy(msg.occupancy);
// Initial device-status snapshot for the footer.
if (Array.isArray(msg.devices)) setDevices(msg.devices);
} else if (msg.kind === "device-status") {
upsertDevice(msg.event);
} else if (msg.kind === "ledger") {
setOccupancy(msg.occupancy);
pushEvent(msg.event);
// Keep Query authoritative: the durable event list, occupancy totals,
// and active-sessions list refetch on the next read instead of trusting
// the pushed copy alone.
void qc.invalidateQueries({ queryKey: qk.events });
void qc.invalidateQueries({ queryKey: qk.occupancy });
void qc.invalidateQueries({ queryKey: qk.activeSessions });
// A shift open/close (or a drawer movement) changes the header control
// state and the per-shift log window — refresh the shift status too.
if (
msg.event.type === "shift_open" ||
msg.event.type === "shift_z_report" ||
msg.event.type === "cash_movement"
) {
void qc.invalidateQueries({ queryKey: qk.shift });
}
} else if (msg.kind === "printer-status") {
void qc.invalidateQueries({ queryKey: ["printers"] });
}
};
const scheduleReconnect = () => {
if (closedRef.current) return;
setStatus("closed");
// Capped exponential backoff: 0.5s, 1s, 2s, … up to 10s.
const delay = Math.min(500 * 2 ** retryRef.current, 10_000);
retryRef.current += 1;
window.setTimeout(connect, delay);
};
sock.onclose = scheduleReconnect;
// onerror fires before onclose; let onclose own the reconnect to avoid double.
sock.onerror = () => sock.close();
};
connect();
return () => {
closedRef.current = true;
sockRef.current?.close();
sockRef.current = null;
};
// qc / store setters are stable; run once on mount.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}
+42
View File
@@ -0,0 +1,42 @@
import { useQuery } from "@tanstack/react-query";
import { fetchShift, type ShiftStatus } from "../api.js";
import { qk } from "./query.js";
// Shared shift status for the whole app — the header control, the booth screen's
// per-shift log scope, and the pay/exit modal's gate all read this one Query so
// they never disagree about whether a shift is open and whose it is. A shift is a
// SITE-WIDE single-open accountability period (at most one open at a time). The WS
// invalidates qk.shift on shift_open/shift_z_report/cash_movement, so this stays
// live without polling. See wiki/concepts/shift.md.
export interface ShiftState {
/** Raw status from the server (null while loading / on error). */
status: ShiftStatus | undefined;
/** Is ANY shift open site-wide? */
isOpen: boolean;
/** Is the open shift the logged-in operator's (so they may close it / operate)? */
isMine: boolean;
/** A shift is open but belongs to someone else — this operator is blocked. */
blockedByOther: boolean;
/** ISO start of the open shift, for scoping the per-shift log. */
startedAt: string | null;
/** Whoever holds the open shift (for "held by X" messaging). */
heldBy: string | null;
isLoading: boolean;
}
export function useShift(): ShiftState {
const q = useQuery({ queryKey: qk.shift, queryFn: fetchShift });
const s = q.data;
const isOpen = s?.open != null;
const isMine = s?.isMine ?? false;
return {
status: s,
isOpen,
isMine,
blockedByOther: isOpen && !isMine,
startedAt: s?.open?.startedAt ?? null,
heldBy: s?.open?.operator ?? null,
isLoading: q.isLoading,
};
}
+2
View File
@@ -1,5 +1,7 @@
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import "./index.css";
import "./lib/i18n/index.js"; // initialize i18next before the app renders
import { App } from "./App.js"; import { App } from "./App.js";
const rootEl = document.getElementById("root"); const rootEl = document.getElementById("root");
+272
View File
@@ -0,0 +1,272 @@
import {
createRootRouteWithContext,
createRoute,
createRouter,
Link,
Outlet,
redirect,
} from "@tanstack/react-router";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import type { Lang, SessionUser } from "./api.js";
import { closeShift, logout, openShift, setLanguagePref } from "./api.js";
import { qk, queryClient } from "./lib/query.js";
import { setLanguage } from "./lib/i18n/index.js";
import { useLiveFeed } from "./lib/use-live-feed.js";
import { useShift } from "./lib/use-shift.js";
import { DeviceFooter } from "./ui/DeviceFooter.js";
import { StatusDot } from "./ui/StatusDot.js";
import { BoothScreen } from "./BoothScreen.js";
import { SetupWizard } from "./SetupWizard.js";
import { TariffComposer } from "./TariffComposer.js";
import { SubscriptionManager } from "./SubscriptionManager.js";
import { ShiftControl } from "./ShiftControl.js";
import { SiteSettings } from "./SiteSettings.js";
// Code-based TanStack Router (no file-based codegen — the app is small enough that
// an explicit tree is clearer). The router context carries the signed-in user and
// a setter so route guards can redirect by role. The root renders the terminal
// chrome (nav + user + live status) and opens the booth WebSocket once, app-wide.
export interface RouterContext {
user: SessionUser | null;
setUser: (u: SessionUser | null) => void;
}
const rootRoute = createRootRouteWithContext<RouterContext>()({
component: RootLayout,
});
function NavLink({ to, label }: { to: string; label: string }) {
return (
<Link
to={to}
className="px-2 py-1 text-[11px] uppercase tracking-wider text-term-muted rounded-term hover:text-term-text [&.active]:text-term-amber [&.active]:bg-term-panel-2"
>
{label}
</Link>
);
}
/** SQ/EN toggle. Persists the choice to the user's profile (restored on next login)
* and applies it immediately. Updates the router-context user so App re-syncs. */
function LanguageToggle({
user,
setUser,
}: {
user: SessionUser;
setUser: (u: SessionUser | null) => void;
}) {
async function pick(lang: Lang) {
if (lang === user.language) return;
setLanguage(lang); // instant UI
setUser({ ...user, language: lang });
try {
await setLanguagePref(lang); // persist
} catch {
/* non-fatal — the choice still applies this session */
}
}
return (
<div className="flex items-center gap-0.5 text-[10px] uppercase tracking-wider">
{(["sq", "en"] as const).map((l) => (
<button
key={l}
type="button"
onClick={() => pick(l)}
className={`rounded-term px-1.5 py-0.5 ${
user.language === l ? "bg-term-panel-2 text-term-amber" : "text-term-muted hover:text-term-text"
}`}
>
{l}
</button>
))}
</div>
);
}
/**
* Header shift control — the site-wide single-open shift expressed as one button:
* - no shift open → "Open shift" (enabled; opens this operator's shift)
* - my shift open → "Close shift" (enabled; signs + prints the Z-report)
* - another's shift open → disabled, labelled with who holds it (you can neither
* open yours nor close theirs until they hand over).
* On open/close it invalidates the shift status, the per-shift log, and occupancy.
*/
function ShiftButton() {
const { t } = useTranslation();
const qc = useQueryClient();
const { isOpen, isMine, blockedByOther, heldBy } = useShift();
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
async function act(kind: "open" | "close") {
setBusy(true);
setErr(null);
try {
if (kind === "open") await openShift();
else await closeShift();
// The shift boundary moves: refresh status, the per-shift log window, drawer.
void qc.invalidateQueries({ queryKey: qk.shift });
void qc.invalidateQueries({ queryKey: qk.events });
void qc.invalidateQueries({ queryKey: qk.occupancy });
} catch (e) {
setErr((e as Error).message);
} finally {
setBusy(false);
}
}
// Disabled when another operator holds the shift (can't open or close).
const label = blockedByOther
? t("shift.headerHeldByShort", { operator: heldBy ?? "?" })
: isMine
? t("shift.headerClose")
: t("shift.headerOpen");
const tone = blockedByOther
? "border-term-border text-term-muted opacity-60 cursor-not-allowed"
: isMine
? "border-term-red text-term-red hover:bg-term-red/10"
: "border-term-green text-term-green hover:bg-term-green/10";
return (
<div className="flex items-center gap-1">
<button
type="button"
disabled={busy || blockedByOther}
title={blockedByOther ? t("shift.headerHeldBy", { operator: heldBy ?? "?" }) : undefined}
onClick={() => act(isMine ? "close" : "open")}
className={`rounded-term border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wider ${tone}`}
>
{busy ? t("shift.opening") : label}
</button>
{!isOpen && (
<span className="text-[10px] uppercase tracking-wider text-term-amber">{t("shift.headerNoShift")}</span>
)}
{err && <span className="text-[10px] text-term-red">{err}</span>}
</div>
);
}
function RootLayout() {
const { user, setUser } = rootRoute.useRouteContext();
const { t } = useTranslation();
// One app-wide WebSocket for the live feed (booth + any live widget).
useLiveFeed();
const isAdmin = user?.role === "admin";
return (
<div className="flex h-screen flex-col bg-term-bg text-term-text">
<header className="flex items-center gap-4 border-b border-term-border bg-term-panel px-4 py-2">
<span className="text-sm font-bold uppercase tracking-widest text-term-amber">▮ Parking</span>
<nav className="flex items-center gap-1">
<NavLink to="/booth" label={t("nav.booth")} />
<NavLink to="/shift" label={t("nav.shift")} />
{isAdmin && <NavLink to="/setup" label={t("nav.setup")} />}
{isAdmin && <NavLink to="/tariff" label={t("nav.tariff")} />}
{isAdmin && <NavLink to="/subscriptions" label={t("nav.subscriptions")} />}
{isAdmin && <NavLink to="/site" label={t("nav.site")} />}
</nav>
<div className="ml-auto flex items-center gap-3">
{user && <ShiftButton />}
{user && <LanguageToggle user={user} setUser={setUser} />}
<StatusDot />
<span className="text-[11px] text-term-muted">
{user?.username} · {user?.role}
</span>
<button
type="button"
className="rounded-term border border-term-border px-2 py-0.5 text-[11px] uppercase tracking-wider text-term-muted hover:text-term-text"
onClick={async () => {
await logout();
setUser(null);
}}
>
{t("common.logout")}
</button>
</div>
</header>
<main className="min-h-0 flex-1 overflow-auto p-3">
<Outlet />
</main>
{/* Fixed device-status footer — relays, readers, cameras, printers. */}
{user && <DeviceFooter />}
</div>
);
}
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/",
beforeLoad: () => {
throw redirect({ to: "/booth" });
},
});
const boothRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/booth",
component: BoothScreen,
});
const shiftRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/shift",
component: function ShiftRoute() {
const { user } = rootRoute.useRouteContext();
return <ShiftControl isAdmin={user?.role === "admin"} />;
},
});
/** Guard: admin-only routes redirect non-admins back to the booth. */
function adminOnly(ctx: RouterContext) {
if (ctx.user?.role !== "admin") throw redirect({ to: "/booth" });
}
const setupRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/setup",
beforeLoad: ({ context }) => adminOnly(context),
component: () => <SetupWizard />,
});
const tariffRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/tariff",
beforeLoad: ({ context }) => adminOnly(context),
component: () => <TariffComposer />,
});
const subscriptionsRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/subscriptions",
beforeLoad: ({ context }) => adminOnly(context),
component: () => <SubscriptionManager />,
});
const siteRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/site",
beforeLoad: ({ context }) => adminOnly(context),
component: () => <SiteSettings canEdit={true} />,
});
const routeTree = rootRoute.addChildren([
indexRoute,
boothRoute,
shiftRoute,
setupRoute,
tariffRoute,
subscriptionsRoute,
siteRoute,
]);
export const router = createRouter({
routeTree,
context: { user: null, setUser: () => {} },
defaultPreload: "intent",
});
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
+195
View File
@@ -0,0 +1,195 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { fetchDeviceStatus, type DeviceStatus } from "../api.js";
import { qk } from "../lib/query.js";
import { useLiveStore } from "../lib/live-store.js";
// Fixed device-status footer for the booth chrome. One compact chip per configured
// device — relays, readers, cameras, printers — labelled by ROLE, never vendor
// (e.g. "Lexuesi hyrje", "Printer kabina", "Kamera dalje"), with a traffic-light
// dot. Fault detail does NOT pollute the footer: clicking opens a small panel that
// lists the degraded/offline devices and their issues. Status is fed by the
// DeviceMonitor over the WS (snapshot on connect + per-device pushes, held in the
// live store); a REST snapshot seeds it / fills in if the WS is briefly down.
// See wiki/concepts/device-status-monitoring.md, booth-console.md.
const DOT: Record<DeviceStatus["state"], string> = {
ready: "bg-term-green",
degraded: "bg-term-amber",
offline: "bg-term-red",
};
const TEXT: Record<DeviceStatus["state"], string> = {
ready: "text-term-text",
degraded: "text-term-amber",
offline: "text-term-red",
};
/** i18n key for a device category. */
const CATEGORY_KEY: Record<DeviceStatus["category"], string> = {
access: "devices.catAccess",
reader: "devices.catReader",
camera: "devices.catCamera",
printer: "devices.catPrinter",
};
/** i18n key for the role/direction token (null = no suffix). */
function roleKey(roleKind: DeviceStatus["roleKind"]): string | null {
return roleKind ? `devices.role.${roleKind}` : null;
}
/** Stable display order: access (barrier) first, then readers, cameras, printers. */
const ORDER: Record<DeviceStatus["category"], number> = {
access: 0,
reader: 1,
camera: 2,
printer: 3,
};
/** "Lexuesi hyrje" — category word + localised role/direction (when known). */
function useLabel() {
const { t } = useTranslation();
return (d: DeviceStatus) => {
const cat = t(CATEGORY_KEY[d.category]);
const rk = roleKey(d.roleKind);
return rk ? `${cat} ${t(rk)}` : cat;
};
}
function sortDevices(list: DeviceStatus[]): DeviceStatus[] {
return [...list].sort(
(a, b) => ORDER[a.category] - ORDER[b.category] || (a.roleKind ?? "").localeCompare(b.roleKind ?? ""),
);
}
export function DeviceFooter() {
const { t } = useTranslation();
const label = useLabel();
// Seed/fallback from REST; the WS keeps the live store authoritative thereafter.
const seed = useQuery({ queryKey: qk.deviceStatus, queryFn: fetchDeviceStatus });
const live = useLiveStore((s) => s.devices);
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLElement>(null);
// Close the issues panel on an outside click or Escape.
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
// Prefer the live store (WS); fall back to the REST snapshot before the first push.
const fromLive = Object.values(live);
const devices = sortDevices(fromLive.length > 0 ? fromLive : seed.data?.devices ?? []);
const problems = devices.filter((d) => d.state !== "ready");
return (
<footer
ref={rootRef}
className="relative flex shrink-0 items-center gap-2 overflow-visible border-t border-term-border bg-term-panel px-3 py-1.5 text-[11px]"
>
<span className="shrink-0 font-semibold uppercase tracking-wider text-term-muted">
{t("devices.footerTitle")}
</span>
<div className="flex items-center gap-1.5 overflow-x-auto">
{devices.length === 0 ? (
<span className="text-term-muted">{t("devices.none")}</span>
) : (
devices.map((d) => {
const isProblem = d.state !== "ready";
return (
<button
key={d.deviceId}
type="button"
// Only a problem chip is interactive (opens the issues panel).
onClick={isProblem ? () => setOpen((v) => !v) : undefined}
aria-disabled={!isProblem}
title={isProblem ? t("devices.clickForIssues") : undefined}
className={`flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-term border border-term-border bg-term-panel-2 px-2 py-0.5 ${
isProblem ? "cursor-pointer hover:border-term-amber" : "cursor-default"
}`}
>
<span
className={`inline-block h-2 w-2 shrink-0 rounded-full ${DOT[d.state]} ${
d.state === "offline" ? "animate-pulse" : ""
}`}
/>
<span className={TEXT[d.state]}>{label(d)}</span>
</button>
);
})
)}
</div>
{/* Right-aligned roll-up; clicking opens the issues panel when any exist. */}
<button
type="button"
disabled={problems.length === 0}
onClick={() => setOpen((v) => !v)}
className="ml-auto shrink-0 tabular-nums disabled:cursor-default"
>
{problems.length === 0 ? (
devices.length > 0 ? (
<span className="text-term-green">{t("devices.allOk")}</span>
) : null
) : (
<span className="text-term-amber hover:underline">
{t("devices.issuesCount", { count: problems.length })}
</span>
)}
</button>
{/* Issues panel — anchored above the footer, lists only problem devices. */}
{open && problems.length > 0 && (
<div className="absolute bottom-full right-2 z-50 mb-1 w-[360px] max-w-[95vw] rounded-term border border-term-border bg-term-panel shadow-2xl">
<div className="flex items-center justify-between border-b border-term-border bg-term-panel-2 px-3 py-1.5">
<span className="text-[11px] font-semibold uppercase tracking-wider text-term-amber">
{t("devices.issuesTitle")}
</span>
<button
type="button"
onClick={() => setOpen(false)}
className="text-term-muted hover:text-term-text"
aria-label={t("common.close")}
>
✕
</button>
</div>
<ul className="max-h-[40vh] overflow-y-auto p-1.5">
{problems.map((d) => (
<li
key={d.deviceId}
className="flex items-start gap-2 border-b border-term-border/40 px-1.5 py-1.5 last:border-b-0"
>
<span className={`mt-1 inline-block h-2 w-2 shrink-0 rounded-full ${DOT[d.state]}`} />
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<span className={`text-[12px] font-semibold ${TEXT[d.state]}`}>{label(d)}</span>
<span className="shrink-0 text-[10px] uppercase tracking-wider text-term-muted">
{t(`devices.state.${d.state}`)}
</span>
</div>
{d.detail && <div className="mt-0.5 break-words text-[11px] text-term-muted">{d.detail}</div>}
<div className="mt-0.5 text-[10px] tabular-nums text-term-muted/70">
{t("devices.checkedAt", { time: new Date(d.checkedAt).toLocaleTimeString() })}
</div>
</div>
</li>
))}
</ul>
</div>
)}
</footer>
);
}
+33
View File
@@ -0,0 +1,33 @@
import type { ReactNode } from "react";
// Terminal panel: a bordered, titled box — the basic building block of the dense
// booth layout. Title bar in amber, square corners, subtle layered surfaces.
export function Panel({
title,
right,
children,
className = "",
}: {
title?: string;
/** Optional right-aligned content in the title bar (e.g. a status dot). */
right?: ReactNode;
children: ReactNode;
className?: string;
}) {
return (
<section
className={`flex flex-col border border-term-border bg-term-panel rounded-term overflow-hidden ${className}`}
>
{title && (
<header className="flex items-center justify-between px-3 py-1.5 bg-term-panel-2 border-b border-term-border">
<h2 className="m-0 text-[11px] font-semibold uppercase tracking-wider text-term-amber">
{title}
</h2>
{right}
</header>
)}
<div className="flex-1 min-h-0 p-3">{children}</div>
</section>
);
}
+62
View File
@@ -0,0 +1,62 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { fetchSnapshots, snapshotImageUrl } from "../api.js";
// Entry/exit evidence images for a session. Lets the operator verify the car at the
// booth against the ticket. Thumbnails load from /api/snapshots/:id (cookie-authed,
// served with a long immutable cache); clicking one enlarges it. Read-only.
export function SnapshotStrip({ identity }: { identity: string }) {
const { t } = useTranslation();
const { data, isLoading } = useQuery({
queryKey: ["snapshots", identity],
queryFn: () => fetchSnapshots(identity),
enabled: !!identity,
});
const [zoom, setZoom] = useState<string | null>(null);
const shots = data?.snapshots ?? [];
if (isLoading) return <div className="text-[11px] text-term-muted">{t("pay.loadingSnapshots")}</div>;
if (shots.length === 0) return <div className="text-[11px] text-term-muted">{t("pay.noSnapshots")}</div>;
return (
<>
<div className="flex gap-2">
{shots.map((s) => (
<button
key={s.id}
type="button"
onClick={() => setZoom(s.id)}
className="group flex flex-col items-center gap-1 rounded-term border border-term-border bg-term-panel-2 p-1 hover:border-term-amber"
title={`${s.direction ?? "snapshot"} · ${new Date(s.capturedAt).toLocaleString()}`}
>
<img
src={snapshotImageUrl(s.id)}
alt={s.direction ?? "snapshot"}
className="h-20 w-28 object-cover"
loading="lazy"
/>
<span
className={`text-[9px] uppercase tracking-wider ${
s.direction === "entry" ? "text-term-green" : s.direction === "exit" ? "text-term-red" : "text-term-muted"
}`}
>
{s.direction ?? "—"}
</span>
</button>
))}
</div>
{zoom && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-6"
onClick={() => setZoom(null)}
>
<img src={snapshotImageUrl(zoom)} alt="snapshot" className="max-h-full max-w-full object-contain" />
</div>
)}
</>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { useTranslation } from "react-i18next";
import { useLiveStore, type WsStatus } from "../lib/live-store.js";
// Small live-connection indicator for the booth chrome: a coloured dot + label
// reflecting the WebSocket status. Green = live, amber = connecting, red = down.
const COLOR: Record<WsStatus, string> = {
open: "bg-term-green",
connecting: "bg-term-amber",
closed: "bg-term-red",
};
const LABEL_KEY: Record<WsStatus, string> = {
open: "status.live",
connecting: "status.connecting",
closed: "status.offline",
};
export function StatusDot() {
const { t } = useTranslation();
const status = useLiveStore((s) => s.status);
return (
<span className="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-term-muted">
<span
className={`inline-block h-2 w-2 rounded-full ${COLOR[status]} ${status === "open" ? "" : "animate-pulse"}`}
/>
{t(LABEL_KEY[status])}
</span>
);
}
+9 -2
View File
@@ -1,18 +1,25 @@
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
// Operator SPA. Built by Vite and served by Fastify in production // Operator SPA. Built by Vite and served by Fastify in production
// (see wiki/entities/react-vite-spa.md). The dev proxy points the API at the // (see wiki/entities/react-vite-spa.md). The dev proxy points the API at the
// local Fastify server. // local Fastify server.
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react(), tailwindcss()],
server: { server: {
port: 5173, port: 5173,
proxy: { proxy: {
// Use 127.0.0.1 (not "localhost") so the proxy never tries IPv6 ::1 // Use 127.0.0.1 (not "localhost") so the proxy never tries IPv6 ::1
// first and stall — the backend binds IPv4. Avoids slow/hung requests, // first and stall — the backend binds IPv4. Avoids slow/hung requests,
// notably under WSL2 mirrored networking. // notably under WSL2 mirrored networking.
"/api": "http://127.0.0.1:3000", "/api": {
target: "http://127.0.0.1:3000",
// The live booth feed (/api/ws) is a WebSocket — without `ws: true` the
// proxy would not forward the upgrade. The backend's Origin allowlist must
// include the dev origin (http://localhost:5173) via WS_ALLOWED_ORIGINS.
ws: true,
},
"/health": "http://127.0.0.1:3000", "/health": "http://127.0.0.1:3000",
}, },
}, },
@@ -0,0 +1,6 @@
ALTER TABLE `site_config` ADD `park_name` text;--> statement-breakpoint
ALTER TABLE `site_config` ADD `operator_name` text;--> statement-breakpoint
ALTER TABLE `site_config` ADD `nius` text;--> statement-breakpoint
ALTER TABLE `site_config` ADD `address` text;--> statement-breakpoint
ALTER TABLE `site_config` ADD `phone` text;--> statement-breakpoint
ALTER TABLE `site_config` ADD `email` text;
@@ -0,0 +1 @@
ALTER TABLE `site_config` ADD `exit_voucher_default` integer DEFAULT false NOT NULL;
@@ -0,0 +1 @@
ALTER TABLE `users` ADD `language` text DEFAULT 'sq' NOT NULL;
@@ -0,0 +1,25 @@
-- Rename permit → subscription (master data only). The signed ledger keeps its
-- immutable `permitId` payload — NOT touched here. Data-preserving ALTER RENAMEs
-- (SQLite 3.25+) rather than drop/recreate, so existing subscriptions survive.
-- Adds per-subscription pricing (price_minor + period + currency) and a site-wide
-- default monthly price. See wiki/entities/subscription.md.
ALTER TABLE `permits` RENAME TO `subscriptions`;
--> statement-breakpoint
ALTER TABLE `permit_credentials` RENAME TO `subscription_credentials`;
--> statement-breakpoint
ALTER TABLE `subscription_credentials` RENAME COLUMN `permit_id` TO `subscription_id`;
--> statement-breakpoint
ALTER TABLE `permit_plates` RENAME TO `subscription_plates`;
--> statement-breakpoint
ALTER TABLE `subscription_plates` RENAME COLUMN `permit_id` TO `subscription_id`;
--> statement-breakpoint
ALTER TABLE `subscriptions` ADD `price_minor` integer;
--> statement-breakpoint
ALTER TABLE `subscriptions` ADD `period` text DEFAULT 'monthly' NOT NULL;
--> statement-breakpoint
ALTER TABLE `subscriptions` ADD `currency` text;
--> statement-breakpoint
ALTER TABLE `sessions` RENAME COLUMN `permit_id` TO `subscription_id`;
--> statement-breakpoint
ALTER TABLE `site_config` ADD `subscription_monthly_price_minor` integer;
@@ -0,0 +1 @@
ALTER TABLE `site_config` ADD `timezone` text;
@@ -0,0 +1 @@
ALTER TABLE `site_config` ADD `default_vehicle_category` text;
+797
View File
@@ -0,0 +1,797 @@
{
"version": "6",
"dialect": "sqlite",
"id": "2cfc13fa-43fc-4f89-8438-7b9bcaf7ea3b",
"prevId": "a6d81d46-c4a4-4ee7-8565-ec012bbe0252",
"tables": {
"blocklist": {
"name": "blocklist",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"active": {
"name": "active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"added_by": {
"name": "added_by",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"added_at": {
"name": "added_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"device_events": {
"name": "device_events",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"device_id": {
"name": "device_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"detail": {
"name": "detail",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"occurred_at": {
"name": "occurred_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"devices": {
"name": "devices",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"driver_id": {
"name": "driver_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"ledger_events": {
"name": "ledger_events",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"index": {
"name": "index",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"direction": {
"name": "direction",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"identity": {
"name": "identity",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"payload": {
"name": "payload",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"occurred_at": {
"name": "occurred_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"prev_hash": {
"name": "prev_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"signature": {
"name": "signature",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"key_id": {
"name": "key_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"ledger_events_index_unique": {
"name": "ledger_events_index_unique",
"columns": [
"index"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"permit_credentials": {
"name": "permit_credentials",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"permit_id": {
"name": "permit_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"permit_plates": {
"name": "permit_plates",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"permit_id": {
"name": "permit_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"plate": {
"name": "plate",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"permits": {
"name": "permits",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"holder_name": {
"name": "holder_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"contact": {
"name": "contact",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"max_concurrent": {
"name": "max_concurrent",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 1
},
"valid_from": {
"name": "valid_from",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"valid_to": {
"name": "valid_to",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"identity": {
"name": "identity",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"permit_id": {
"name": "permit_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"entered_at": {
"name": "entered_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"exited_at": {
"name": "exited_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"state": {
"name": "state",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'open'"
},
"last_event_index": {
"name": "last_event_index",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"setup_state": {
"name": "setup_state",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"site_config": {
"name": "site_config",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"capacity": {
"name": "capacity",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"park_name": {
"name": "park_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"operator_name": {
"name": "operator_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"nius": {
"name": "nius",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"address": {
"name": "address",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"phone": {
"name": "phone",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"snapshots": {
"name": "snapshots",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"direction": {
"name": "direction",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"device_id": {
"name": "device_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"identity": {
"name": "identity",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content_type": {
"name": "content_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"bytes": {
"name": "bytes",
"type": "blob",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"captured_at": {
"name": "captured_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tariff_versions": {
"name": "tariff_versions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"tariff_id": {
"name": "tariff_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"effective_from": {
"name": "effective_from",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"currency": {
"name": "currency",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"structure": {
"name": "structure",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_by": {
"name": "created_by",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tariffs": {
"name": "tariffs",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'site'"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+805
View File
@@ -0,0 +1,805 @@
{
"version": "6",
"dialect": "sqlite",
"id": "dbee8e05-0b49-4af7-962c-9aab53b36eb7",
"prevId": "2cfc13fa-43fc-4f89-8438-7b9bcaf7ea3b",
"tables": {
"blocklist": {
"name": "blocklist",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"active": {
"name": "active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"added_by": {
"name": "added_by",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"added_at": {
"name": "added_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"device_events": {
"name": "device_events",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"device_id": {
"name": "device_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"detail": {
"name": "detail",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"occurred_at": {
"name": "occurred_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"devices": {
"name": "devices",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"driver_id": {
"name": "driver_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"ledger_events": {
"name": "ledger_events",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"index": {
"name": "index",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"direction": {
"name": "direction",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"identity": {
"name": "identity",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"payload": {
"name": "payload",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"occurred_at": {
"name": "occurred_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"prev_hash": {
"name": "prev_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"signature": {
"name": "signature",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"key_id": {
"name": "key_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"ledger_events_index_unique": {
"name": "ledger_events_index_unique",
"columns": [
"index"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"permit_credentials": {
"name": "permit_credentials",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"permit_id": {
"name": "permit_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"permit_plates": {
"name": "permit_plates",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"permit_id": {
"name": "permit_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"plate": {
"name": "plate",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"permits": {
"name": "permits",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"holder_name": {
"name": "holder_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"contact": {
"name": "contact",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"max_concurrent": {
"name": "max_concurrent",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 1
},
"valid_from": {
"name": "valid_from",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"valid_to": {
"name": "valid_to",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"identity": {
"name": "identity",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"permit_id": {
"name": "permit_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"entered_at": {
"name": "entered_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"exited_at": {
"name": "exited_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"state": {
"name": "state",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'open'"
},
"last_event_index": {
"name": "last_event_index",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"setup_state": {
"name": "setup_state",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"site_config": {
"name": "site_config",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"capacity": {
"name": "capacity",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"park_name": {
"name": "park_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"operator_name": {
"name": "operator_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"nius": {
"name": "nius",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"address": {
"name": "address",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"phone": {
"name": "phone",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"exit_voucher_default": {
"name": "exit_voucher_default",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"snapshots": {
"name": "snapshots",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"direction": {
"name": "direction",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"device_id": {
"name": "device_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"identity": {
"name": "identity",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content_type": {
"name": "content_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"bytes": {
"name": "bytes",
"type": "blob",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"captured_at": {
"name": "captured_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tariff_versions": {
"name": "tariff_versions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"tariff_id": {
"name": "tariff_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"effective_from": {
"name": "effective_from",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"currency": {
"name": "currency",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"structure": {
"name": "structure",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_by": {
"name": "created_by",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tariffs": {
"name": "tariffs",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'site'"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+813
View File
@@ -0,0 +1,813 @@
{
"version": "6",
"dialect": "sqlite",
"id": "620eba1b-2c7e-4bd4-8b3e-c779a69e87b9",
"prevId": "dbee8e05-0b49-4af7-962c-9aab53b36eb7",
"tables": {
"blocklist": {
"name": "blocklist",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"active": {
"name": "active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"added_by": {
"name": "added_by",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"added_at": {
"name": "added_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"device_events": {
"name": "device_events",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"device_id": {
"name": "device_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"detail": {
"name": "detail",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"occurred_at": {
"name": "occurred_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"devices": {
"name": "devices",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"driver_id": {
"name": "driver_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"ledger_events": {
"name": "ledger_events",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"index": {
"name": "index",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"direction": {
"name": "direction",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"identity": {
"name": "identity",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"payload": {
"name": "payload",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"occurred_at": {
"name": "occurred_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"prev_hash": {
"name": "prev_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"signature": {
"name": "signature",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"key_id": {
"name": "key_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"ledger_events_index_unique": {
"name": "ledger_events_index_unique",
"columns": [
"index"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"permit_credentials": {
"name": "permit_credentials",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"permit_id": {
"name": "permit_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"permit_plates": {
"name": "permit_plates",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"permit_id": {
"name": "permit_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"plate": {
"name": "plate",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"permits": {
"name": "permits",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"holder_name": {
"name": "holder_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"contact": {
"name": "contact",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"max_concurrent": {
"name": "max_concurrent",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 1
},
"valid_from": {
"name": "valid_from",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"valid_to": {
"name": "valid_to",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"identity": {
"name": "identity",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"permit_id": {
"name": "permit_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"entered_at": {
"name": "entered_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"exited_at": {
"name": "exited_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"state": {
"name": "state",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'open'"
},
"last_event_index": {
"name": "last_event_index",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"setup_state": {
"name": "setup_state",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"site_config": {
"name": "site_config",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"capacity": {
"name": "capacity",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"park_name": {
"name": "park_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"operator_name": {
"name": "operator_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"nius": {
"name": "nius",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"address": {
"name": "address",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"phone": {
"name": "phone",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"exit_voucher_default": {
"name": "exit_voucher_default",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"snapshots": {
"name": "snapshots",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"direction": {
"name": "direction",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"device_id": {
"name": "device_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"identity": {
"name": "identity",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content_type": {
"name": "content_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"bytes": {
"name": "bytes",
"type": "blob",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"captured_at": {
"name": "captured_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tariff_versions": {
"name": "tariff_versions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"tariff_id": {
"name": "tariff_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"effective_from": {
"name": "effective_from",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"currency": {
"name": "currency",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"structure": {
"name": "structure",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_by": {
"name": "created_by",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tariffs": {
"name": "tariffs",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'site'"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"language": {
"name": "language",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'sq'"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+42
View File
@@ -8,6 +8,48 @@
"when": 1781632874398, "when": 1781632874398,
"tag": "0000_baseline", "tag": "0000_baseline",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1781682176094,
"tag": "0001_neat_slipstream",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1781713560438,
"tag": "0002_panoramic_tiger_shark",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1781774228086,
"tag": "0003_early_hawkeye",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"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
} }
] ]
} }
+1 -1
View File
@@ -5,7 +5,7 @@ import * as schema from "./schema.js";
export * from "./schema.js"; export * from "./schema.js";
// Re-export the query helpers consumers need, so they don't depend on // Re-export the query helpers consumers need, so they don't depend on
// drizzle-orm directly (it's an implementation detail of this package). // drizzle-orm directly (it's an implementation detail of this package).
export { eq, and, desc, sql } from "drizzle-orm"; export { eq, and, desc, gte, sql } from "drizzle-orm";
/** /**
* Open the local SQLite database in WAL mode. WAL allows many concurrent readers * Open the local SQLite database in WAL mode. WAL allows many concurrent readers
+83 -21
View File
@@ -9,7 +9,7 @@ import { blob, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
// tariffs and occupancy are PROJECTIONS over it. See append-only-event-chain.md. // tariffs and occupancy are PROJECTIONS over it. See append-only-event-chain.md.
// • `device_events` — UNSIGNED operational telemetry (relay/printer/camera/reader/input). // • `device_events` — UNSIGNED operational telemetry (relay/printer/camera/reader/input).
// High-volume, prunable, never reconciled. See wiki/concepts/device-events.md. // High-volume, prunable, never reconciled. See wiki/concepts/device-events.md.
// - Business master data (tariffs/permits/blocklist) IS mutable, but its USE is fixed in a // - Business master data (tariffs/subscriptions/blocklist) IS mutable, but its USE is fixed in a
// signed ledger event, so the audit trail stays append-only. Tariffs are versioned: // signed ledger event, so the audit trail stays append-only. Tariffs are versioned:
// editing publishes a new immutable tariff_version. See wiki/concepts/tariff.md. // editing publishes a new immutable tariff_version. See wiki/concepts/tariff.md.
// - `users` holds bcrypt hashes + a role; auth is fully local (offline-first). // - `users` holds bcrypt hashes + a role; auth is fully local (offline-first).
@@ -22,6 +22,12 @@ export const users = sqliteTable("users", {
role: text("role", { role: text("role", {
enum: ["admin", "operator", "cashier", "readonly"], enum: ["admin", "operator", "cashier", "readonly"],
}).notNull(), }).notNull(),
// Preferred UI language for this user (operator-facing). Loaded on login and
// restored from any booth. Albanian is the default. Printed tickets are NOT
// governed by this — they're always Albanian (customer-facing). See i18n.md.
language: text("language", { enum: ["sq", "en"] })
.notNull()
.default("sq"),
createdAt: text("created_at") createdAt: text("created_at")
.notNull() .notNull()
.default(sql`(current_timestamp)`), .default(sql`(current_timestamp)`),
@@ -87,7 +93,7 @@ export const snapshots = sqliteTable("snapshots", {
direction: text("direction", { enum: ["entry", "exit"] }).notNull(), direction: text("direction", { enum: ["entry", "exit"] }).notNull(),
// The camera `devices` instance that captured it (raw provenance). // The camera `devices` instance that captured it (raw provenance).
deviceId: text("device_id"), deviceId: text("device_id"),
// The session/credential ref (ticket id, plate, permit) — links to the ledger event. // The session/credential ref (ticket id, plate, subscription) — links to the ledger event.
identity: text("identity"), identity: text("identity"),
contentType: text("content_type").notNull(), contentType: text("content_type").notNull(),
bytes: blob("bytes").notNull().$type<Buffer>(), bytes: blob("bytes").notNull().$type<Buffer>(),
@@ -131,9 +137,53 @@ export const setupState = sqliteTable("setup_state", {
// Single-row site settings (admin-configurable). The home for site-wide knobs; // Single-row site settings (admin-configurable). The home for site-wide knobs;
// `capacity` is the nominal space count the FULL gate refuses transient entry at // `capacity` is the nominal space count the FULL gate refuses transient entry at
// (null = no cap). See wiki/concepts/capacity-occupancy.md. // (null = no cap). See wiki/concepts/capacity-occupancy.md.
// Park identity/metadata (all optional) lives here too — display name, the legal
// operator, the NIUS tax id, address and contact. These feed the ticket/receipt
// header (park name + NIUS are commonly required on an Albanian parking receipt)
// and admin display. All nullable: the lot runs fine with none set.
// See wiki/concepts/site-metadata.md.
export const siteConfig = sqliteTable("site_config", { export const siteConfig = sqliteTable("site_config", {
id: integer("id").primaryKey(), // always 1 id: integer("id").primaryKey(), // always 1
capacity: integer("capacity"), // null = no capacity limit capacity: integer("capacity"), // null = no capacity limit
/** Park display name shown on the ticket header / UI (e.g. "Acme Parking"). */
parkName: text("park_name"),
/** Legal entity operating the lot, for receipts (may differ from parkName). */
operatorName: text("operator_name"),
/** NIUS — Albanian tax/identification number, printed on the receipt when set. */
nius: text("nius"),
/** Free-text postal address (multi-line allowed). */
address: text("address"),
/** Contact phone — also used for the ticket "lost ticket? call …" footer. */
phone: text("phone"),
/** Contact email. */
email: text("email"),
/** Default for the booth pay modal's "print exit ticket" checkbox. Site-wide
* because it's booth GEOGRAPHY: when the booth is far from the exit, the
* customer pays at the booth and self-exits later by scanning a printed exit
* voucher (= the ticket id reprinted, now paid). When near the exit, the booth
* opens the barrier directly. The operator may still override per transaction.
* Stored 0/1 (SQLite has no bool). See wiki/concepts/booth-exit-flow.md. */
exitVoucherDefault: integer("exit_voucher_default", { mode: "boolean" })
.notNull()
.default(false),
/** Default monthly subscription price in minor units (e.g. 1000000 = 10,000.00).
* A starting value the subscription form pre-fills; each subscription stores its
* 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") updatedAt: text("updated_at")
.notNull() .notNull()
.default(sql`(current_timestamp)`), .default(sql`(current_timestamp)`),
@@ -171,16 +221,28 @@ export const tariffVersions = sqliteTable("tariff_versions", {
.default(sql`(current_timestamp)`), .default(sql`(current_timestamp)`),
}); });
// --- Permits (subscriptions) --------------------------------------------- // --- Subscriptions --------------------------------------------------------
// Mutable master data; every USE produces a signed vehicle_entry/exit ledger event. // A subscriber: a known holder who parks on a recurring plan (e.g. 10,000 ALL /
// Two optional, independent bindings: car-count (maxConcurrent, default 1, null = // month) instead of paying per stay. Mutable master data; every USE still produces a
// unbound) and plate (plates rows, default none = any car). Identity = card/QR OR a // signed vehicle_entry/exit ledger event. Two optional, independent bindings:
// matching plate. Credentials and cars are child rows. See wiki/entities/permit.md. // car-count (maxConcurrent, default 1, null = unbound) and plate (plates rows,
export const permits = sqliteTable("permits", { // default none = any car). Identity = card/QR OR a matching plate (LPR/ANPR future).
// Pricing: priceMinor + period + currency record the plan; collecting the fee into
// the ledger/shift is deferred. See wiki/entities/subscription.md.
// NB: signed ledger events still carry `permitId` in their payload — immutable
// history, intentionally NOT renamed. These tables are the mutable master data,
// renamed permit→subscription in migration 0004.
export const subscriptions = sqliteTable("subscriptions", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
holderName: text("holder_name"), holderName: text("holder_name"),
contact: text("contact"), contact: text("contact"),
// Car-count binding: how many of the permit's cars may be inside at once. // Recurring price for the plan, in minor units (e.g. 1000000 = 10,000.00 ALL).
// null = no price set (comp/legacy). The `period` says what it recurs over.
priceMinor: integer("price_minor"),
period: text("period", { enum: ["monthly"] }).notNull().default("monthly"),
// ISO-4217 currency of priceMinor (e.g. "ALL"). null when no price set.
currency: text("currency"),
// Car-count binding: how many of the subscription's cars may be inside at once.
// null = unbound. Default 1. // null = unbound. Default 1.
maxConcurrent: integer("max_concurrent").default(1), maxConcurrent: integer("max_concurrent").default(1),
validFrom: text("valid_from"), validFrom: text("valid_from"),
@@ -193,19 +255,19 @@ export const permits = sqliteTable("permits", {
.default(sql`(current_timestamp)`), .default(sql`(current_timestamp)`),
}); });
// A permit's credentials (RF tag/chip/card, or QR). Either opens the lane. // A subscription's credentials (RF tag/chip/card, or QR). Either opens the barrier.
export const permitCredentials = sqliteTable("permit_credentials", { export const subscriptionCredentials = sqliteTable("subscription_credentials", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
permitId: text("permit_id").notNull(), subscriptionId: text("subscription_id").notNull(),
kind: text("kind", { enum: ["rf", "qr"] }).notNull(), kind: text("kind", { enum: ["rf", "qr"] }).notNull(),
value: text("value").notNull(), value: text("value").notNull(),
}); });
// Plate binding (optional). When a permit has plate rows, a matching plate read is // Plate binding (optional). When a subscription has plate rows, a matching plate read
// itself an accepted identity (card/QR OR plate). Empty = not plate-bound (any car). // is itself an accepted identity (card/QR OR plate). Empty = not plate-bound (any car).
export const permitPlates = sqliteTable("permit_plates", { export const subscriptionPlates = sqliteTable("subscription_plates", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
permitId: text("permit_id").notNull(), subscriptionId: text("subscription_id").notNull(),
plate: text("plate").notNull(), plate: text("plate").notNull(),
}); });
@@ -234,8 +296,8 @@ export const sessions = sqliteTable("sessions", {
// Identity that opened the session, and how it was read. // Identity that opened the session, and how it was read.
identity: text("identity"), identity: text("identity"),
source: text("source"), source: text("source"),
// null while transient; set when matched to a permit. // null while transient; set when matched to a subscription.
permitId: text("permit_id"), subscriptionId: text("subscription_id"),
enteredAt: text("entered_at").notNull(), enteredAt: text("entered_at").notNull(),
// null until exit; presence = CLOSED. // null until exit; presence = CLOSED.
exitedAt: text("exited_at"), exitedAt: text("exited_at"),
@@ -256,8 +318,8 @@ export type SetupStateRow = typeof setupState.$inferSelect;
export type SiteConfigRow = typeof siteConfig.$inferSelect; export type SiteConfigRow = typeof siteConfig.$inferSelect;
export type TariffRow = typeof tariffs.$inferSelect; export type TariffRow = typeof tariffs.$inferSelect;
export type TariffVersionRow = typeof tariffVersions.$inferSelect; export type TariffVersionRow = typeof tariffVersions.$inferSelect;
export type PermitRow = typeof permits.$inferSelect; export type SubscriptionRow = typeof subscriptions.$inferSelect;
export type PermitCredentialRow = typeof permitCredentials.$inferSelect; export type SubscriptionCredentialRow = typeof subscriptionCredentials.$inferSelect;
export type PermitPlateRow = typeof permitPlates.$inferSelect; export type SubscriptionPlateRow = typeof subscriptionPlates.$inferSelect;
export type BlocklistRow = typeof blocklist.$inferSelect; export type BlocklistRow = typeof blocklist.$inferSelect;
export type SessionRow = typeof sessions.$inferSelect; export type SessionRow = typeof sessions.$inferSelect;
+3
View File
@@ -5,6 +5,7 @@ import { registry } from "../registry.js";
import { dingtianDriver } from "./access-dingtian.js"; import { dingtianDriver } from "./access-dingtian.js";
import { stubAccessDriver } from "./access-stub.js"; import { stubAccessDriver } from "./access-stub.js";
import { dahuaDriver, hikvisionDriver } from "./camera.js"; import { dahuaDriver, hikvisionDriver } from "./camera.js";
import { cashinoDriver } from "./printer-cashino.js";
import { rongtaDriver } from "./printer-rongta.js"; import { rongtaDriver } from "./printer-rongta.js";
import { geeQrReaderDriver, tcpipReaderDriver, wiegandReaderDriver } from "./reader.js"; import { geeQrReaderDriver, tcpipReaderDriver, wiegandReaderDriver } from "./reader.js";
@@ -22,6 +23,7 @@ export function registerBuiltinDrivers(): void {
registry.register(hikvisionDriver); registry.register(hikvisionDriver);
registry.register(dahuaDriver); registry.register(dahuaDriver);
registry.register(rongtaDriver); registry.register(rongtaDriver);
registry.register(cashinoDriver);
} }
export { export {
@@ -33,4 +35,5 @@ export {
hikvisionDriver, hikvisionDriver,
dahuaDriver, dahuaDriver,
rongtaDriver, rongtaDriver,
cashinoDriver,
}; };
@@ -0,0 +1,157 @@
import type {
DeviceHealth,
PrinterDevice,
PrintReport,
ReceiptData,
SubscriptionCardData,
TicketData,
} from "../interfaces.js";
import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
import { hostField, portField, stubLog } from "./common.js";
import {
probe,
renderReceipt,
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}`);
}
async printReceipt(data: ReceiptData): Promise<void> {
await sendRaw(this.#host, this.#port, renderReceipt(data), this.#timeout);
stubLog(
this.driverId,
`printed ${data.voucher ? "voucher" : "receipt"} ${data.ticketId}`,
);
}
}
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,432 @@
import { Socket } from "node:net";
import type {
PrintReport,
ReceiptData,
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,
Ë: 0xd3, // CP852 0xD3 = U+00CB Ë (0xEB is ű — wrong; fixed after a misprint)
ç: 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[] = [];
// Intl.NumberFormat separates the amount from the currency with a NO-BREAK
// SPACE (U+00A0) or NARROW NO-BREAK SPACE (U+202F); neither is in CP852, so
// they'd print as "?". Normalise to a plain space (e.g. "1000 Lekë").
const normalised = text.replace(/[  ]/g, " ");
for (const ch of normalised) {
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}`,
// --- payment receipt ---
/** Receipt title. */
receipt: "FATURË PAGESE",
/** Voucher-mode title (the same slip self-exits). */
voucherTitle: "BILETË DALJE",
/** "Entry:" — entry time row. */
entry: (v: string) => `Hyrja: ${v}`,
/** "Paid:" — payment time row. */
paid: (v: string) => `Pagesa: ${v}`,
/** "Duration:" — time parked. */
duration: (v: string) => `Kohëzgjatja: ${v}`,
/** "Tender:" — cash/card. */
tender: (v: string) => `Mënyra: ${v}`,
tenderCash: "Para në dorë",
tenderCard: "Kartë",
/** "Paid:" amount label (precedes the large total). */
amountLabel: "PAGUAR",
/** Walk-back grace emphasis (voucher mode) — two short lines that each fit the
* 80mm width, so neither wraps mid-word. */
graceLines: (min: number): readonly string[] => [
`Dilni brenda ${min} min.`,
"Skanoni këtë biletë në dalje.",
],
/** Thank-you footer. */
thanks: "Faleminderit!",
} as const;
/** Format integer minor units + ISO-4217 currency as a major-unit string for the
* printed receipt. Mirrors the booth UI's formatMoney (no float money model). */
function money(amountMinor: number, currency: string): string {
const major = amountMinor / 100;
try {
return new Intl.NumberFormat("sq-AL", {
style: "currency",
currency,
}).format(major);
} catch {
return `${major.toFixed(2)} ${currency}`;
}
}
/** Human duration between two ISO times, e.g. "2h 14m" / "47m". Whole minutes,
* mirroring the booth UI's formatDuration. */
function duration(fromIso: string, toIso: string): string {
const ms = Date.parse(toIso) - Date.parse(fromIso);
if (!Number.isFinite(ms) || ms < 0) return "—";
const mins = Math.floor(ms / 60_000);
const h = Math.floor(mins / 60);
const m = mins % 60;
return h > 0 ? `${h}h ${m}m` : `${m}m`;
}
/** Local date+time "YYYY-MM-DD HH:MM" for a receipt row. The host clock is the
* site's local time (the appliance runs in the site's zone). */
function stamp(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
/** 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);
}
/** Build the ESC/POS byte stream for a PAYMENT RECEIPT. Header → title → the
* transparency figures (entry / paid / duration / amount / tender). In voucher
* mode it ALSO prints the scannable ticket-id barcode and emphasises the
* walk-back grace, so the one slip both proves payment and self-exits at a
* distant exit reader. Standalone (voucher=false) is detail-only. Albanian. */
export function renderReceipt(data: ReceiptData): Buffer {
const parts: Buffer[] = [
INIT,
SELECT_CP852,
renderHeader(data.header),
line(),
ALIGN_CENTER,
BOLD_ON,
DOUBLE_ON,
line(data.voucher ? STR.voucherTitle : STR.receipt),
DOUBLE_OFF,
BOLD_OFF,
line(),
ALIGN_LEFT,
// The transparency figures.
line(STR.entry(stamp(data.enteredAt))),
line(STR.paid(stamp(data.paidAt))),
line(STR.duration(duration(data.enteredAt, data.paidAt))),
line(
STR.tender(data.tender === "card" ? STR.tenderCard : STR.tenderCash),
),
line(),
// The amount, large and centred.
ALIGN_CENTER,
line(STR.amountLabel),
BOLD_ON,
DOUBLE_ON,
line(money(data.amountMinor, data.currency)),
DOUBLE_OFF,
BOLD_OFF,
line(),
];
if (data.voucher) {
// The same ticket id, scannable at the exit reader, + the grace emphasis.
parts.push(
code128(data.ticketId),
line(),
line(data.ticketId),
line(),
);
if (data.graceExitMin != null && data.graceExitMin > 0) {
parts.push(
BOLD_ON,
...STR.graceLines(data.graceExitMin).map((l) => line(l)),
BOLD_OFF,
);
}
}
parts.push(line(), line(STR.thanks), 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";
+99 -109
View File
@@ -1,4 +1,3 @@
import { Socket } from "node:net";
import { request as httpRequest } from "node:http"; import { request as httpRequest } from "node:http";
import type { import type {
Device, Device,
@@ -7,15 +6,27 @@ import type {
PrinterDevice, PrinterDevice,
PrinterStatus, PrinterStatus,
PrintReport, PrintReport,
SubscriptionCardData,
TicketData, TicketData,
} from "../interfaces.js"; } from "../interfaces.js";
import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js"; import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
import { hostField, portField, stubLog } from "./common.js"; import { hostField, portField, stubLog } from "./common.js";
import {
probe,
renderReceipt,
renderReport,
renderSubscriptionCard,
renderTicket,
sendRaw,
} from "./printer-escpos.js";
// Rongta 80mm network thermal printer driver. Rongta RP-series printers (and the // 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 // 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 // on port 9100 — the JetDirect/RAW convention. The ESC/POS rendering + transport
// socket; like the other field devices it lives on the isolated device VLAN. // 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. // 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 // ROLES + FAILOVER: a lane has more than one printer. Each instance declares a
@@ -26,90 +37,22 @@ import { hostField, portField, stubLog } from "./common.js";
// itself is role-agnostic; the role/rank live in config and the caller (server) // 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. // 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
/** Encode a printable line as bytes (CP437/ASCII subset) + a line feed. */
function line(text = ""): Buffer {
return Buffer.concat([Buffer.from(text, "ascii"), Buffer.from([LF])]);
}
/** 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,
ALIGN_CENTER,
BOLD_ON,
line(report.title),
BOLD_OFF,
ALIGN_LEFT,
line(),
...report.lines.map((l) => line(l)),
FEED_AND_CUT,
]);
}
/** Build the full ESC/POS byte stream for an entry ticket. */
function renderTicket(data: TicketData): Buffer {
return Buffer.concat([
INIT,
ALIGN_CENTER,
BOLD_ON,
DOUBLE_ON,
line("PARKING"),
DOUBLE_OFF,
BOLD_OFF,
line(),
BOLD_ON,
line(data.ticketId),
BOLD_OFF,
ALIGN_LEFT,
line(),
line(`Issued: ${data.issuedAt}`),
FEED_AND_CUT,
]);
}
/** 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 ------------------------- // --- 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 // 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 // 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 // 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 // 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. // 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). */ /** 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>>; type StatusFlags = Partial<Record<StatusFlag, boolean>>;
/** Label text on the status page (NBSP/space-normalised, lowercased) → our key. */ /** Label text on the status page (NBSP/space-normalised, lowercased) → our key. */
@@ -122,10 +65,20 @@ const STATUS_FIELDS: Record<string, StatusFlag> = {
}; };
/** GET the status page over HTTP and return the raw HTML. */ /** 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) => { return new Promise((resolve, reject) => {
const req = httpRequest( 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) => { (res) => {
let data = ""; let data = "";
res.on("data", (c) => (data += c)); res.on("data", (c) => (data += c));
@@ -154,8 +107,15 @@ function parseStatusPage(html: string): StatusFlags {
let m: RegExpExecArray | null; let m: RegExpExecArray | null;
while ((m = rowRe.exec(html))) { while ((m = rowRe.exec(html))) {
if (m[1] === undefined || m[2] === undefined) continue; if (m[1] === undefined || m[2] === undefined) continue;
const label = m[1].replace(/&nbsp;/gi, " ").replace(/\s+/g, " ").trim().toLowerCase(); const label = m[1]
const value = m[2].replace(/&nbsp;/gi, " ").trim().toLowerCase(); .replace(/&nbsp;/gi, " ")
.replace(/\s+/g, " ")
.trim()
.toLowerCase();
const value = m[2]
.replace(/&nbsp;/gi, " ")
.trim()
.toLowerCase();
const key = STATUS_FIELDS[label]; const key = STATUS_FIELDS[label];
if (key && (value === "yes" || value === "no")) { if (key && (value === "yes" || value === "no")) {
out[key] = value === "yes"; out[key] = value === "yes";
@@ -164,24 +124,6 @@ function parseStatusPage(html: string): StatusFlags {
return out; 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 { class RongtaPrinter implements PrinterDevice, MonitorableDevice {
readonly driverId = "rongta"; readonly driverId = "rongta";
readonly #host: string; readonly #host: string;
@@ -220,7 +162,28 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
async printReport(report: PrintReport): Promise<void> { async printReport(report: PrintReport): Promise<void> {
await sendRaw(this.#host, this.#port, renderReport(report), this.#timeout); 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,
);
stubLog(this.driverId, `printed subscription card ${data.code}`);
}
async printReceipt(data: import("../interfaces.js").ReceiptData): Promise<void> {
await sendRaw(this.#host, this.#port, renderReceipt(data), this.#timeout);
stubLog(
this.driverId,
`printed ${data.voucher ? "voucher" : "receipt"} ${data.ticketId}`,
);
} }
/** /**
@@ -244,7 +207,13 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
} }
const flags = parseStatusPage(html); 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); const missing = expected.filter((k) => flags[k] === undefined);
if (missing.length > 0) { if (missing.length > 0) {
return { return {
@@ -265,7 +234,8 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
return { return {
status: faults.length > 0 ? "degraded" : "ready", status: faults.length > 0 ? "degraded" : "ready",
...flags, ...flags,
detail: faults.length > 0 ? faults.map((f) => labels[f]).join(", ") : undefined, detail:
faults.length > 0 ? faults.map((f) => labels[f]).join(", ") : undefined,
checkedAt, checkedAt,
}; };
} }
@@ -281,7 +251,10 @@ const roleField: ConfigField = {
required: true, required: true,
default: "entry-dispenser", default: "entry-dispenser",
options: [ 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)" }, { 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.", help: "Entry tickets print on the entry dispenser, falling back to the booth printer if it is offline.",
@@ -301,15 +274,32 @@ export const rongtaDriver: PrinterDriver = {
category: "printer", category: "printer",
label: "Rongta 80mm thermal printer", label: "Rongta 80mm thermal printer",
description: 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"], transports: ["tcp-ip"],
configFields: [ configFields: [
hostField, 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, roleField,
rankField, 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), create: (c) => new RongtaPrinter(c),
}; };
+1
View File
@@ -18,6 +18,7 @@ export {
hikvisionDriver, hikvisionDriver,
dahuaDriver, dahuaDriver,
rongtaDriver, rongtaDriver,
cashinoDriver,
} from "./drivers/index.js"; } from "./drivers/index.js";
export { isPrinter, type PrinterRole } from "./drivers/printer-rongta.js"; export { isPrinter, type PrinterRole } from "./drivers/printer-rongta.js";
export { export {
+58
View File
@@ -190,9 +190,61 @@ export interface Snapshot {
} }
// --- Printers (ticket dispenser / booth printer) ------------------------- // --- Printers (ticket dispenser / booth printer) -------------------------
/** Optional park identity printed at the top of a ticket/receipt. All fields
* optional — the driver prints only what's set. Sourced from site_config; an
* Albanian parking receipt commonly must show the park name + NIUS. */
export interface TicketHeader {
readonly parkName?: string | null;
readonly operatorName?: string | null;
/** NIUS — Albanian tax/identification number. */
readonly nius?: string | null;
readonly address?: string | null;
readonly phone?: string | null;
}
export interface TicketData { export interface TicketData {
readonly ticketId: string; readonly ticketId: string;
readonly issuedAt: string; // ISO-8601 readonly issuedAt: string; // ISO-8601
/** Park identity for the header. Absent → driver prints the generic "PARKING". */
readonly header?: TicketHeader;
}
/** A PAYMENT RECEIPT handed to the customer after a completed payment — the
* transparency record: when they entered, when they paid, how long they stayed,
* and how much they paid. Printed in two modes (see `voucher`):
* - voucher mode: ALSO carries the scannable ticket-id barcode + the walk-back
* grace window, so the same slip both proves payment AND self-exits at a
* distant exit reader (replaces the old barcode-only voucher);
* - standalone mode: detail-only (no barcode), printed at payment when the booth
* is at the exit and no voucher is issued.
* Money is integer MINOR units + an ISO-4217 currency (never a float) — the
* driver formats it. See wiki/concepts/booth-exit-flow.md, tariff.md. */
export interface ReceiptData {
readonly ticketId: string;
readonly enteredAt: string; // ISO-8601
readonly paidAt: string; // ISO-8601
readonly amountMinor: number;
readonly currency: string; // ISO-4217 (e.g. "ALL")
readonly tender: "cash" | "card";
/** Voucher mode: print the scannable barcode + emphasise the walk-back grace. */
readonly voucher: boolean;
/** Minutes the customer has to reach the exit after paying (voucher mode only). */
readonly graceExitMin?: number | null;
readonly header?: TicketHeader;
}
/** A subscription card: the customer's keepsake, printed at the booth on creation
* (and re-printable). The driver renders the `code` as a SCANNABLE QR (the
* subscriber scans it every entry/exit) plus the code as text + the holder/validity.
* See wiki/entities/subscription.md. */
export interface SubscriptionCardData {
/** The credential value to encode in the QR (e.g. "SUB-…"). */
readonly code: string;
readonly holderName?: string | null;
/** Coverage window, for the printed card (human-readable already, or ISO). */
readonly validFrom?: string | null;
readonly validTo?: string | null;
readonly header?: TicketHeader;
} }
export interface PrinterDevice extends Device { export interface PrinterDevice extends Device {
@@ -201,6 +253,12 @@ export interface PrinterDevice extends Device {
* printed as-is; the driver adds a header/cut. Kept generic so the business * printed as-is; the driver adds a header/cut. Kept generic so the business
* layer composes the content. See wiki/concepts/shift.md. */ * layer composes the content. See wiki/concepts/shift.md. */
printReport(report: PrintReport): Promise<void>; printReport(report: PrintReport): Promise<void>;
/** Print a subscription card: a scannable QR of the code + holder/validity. */
printSubscriptionCard(data: SubscriptionCardData): Promise<void>;
/** Print a payment receipt (transparency: entry/paid/duration/amount). In
* voucher mode it also carries the ticket-id barcode + grace window so it
* doubles as the self-exit voucher. See ReceiptData. */
printReceipt(data: ReceiptData): Promise<void>;
} }
export interface PrintReport { export interface PrintReport {
+4 -2
View File
@@ -15,9 +15,11 @@
"build": "tsc -b", "build": "tsc -b",
"dev": "tsc -b --watch", "dev": "tsc -b --watch",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"lint": "tsc --noEmit" "lint": "tsc --noEmit",
"test": "vitest run"
}, },
"devDependencies": { "devDependencies": {
"typescript": "6.0.3" "typescript": "6.0.3",
"vitest": "^4.1.9"
} }
} }
+454 -30
View File
@@ -52,6 +52,10 @@ export type LedgerEventType =
// with a takings summary (shift_z_report). See wiki/concepts/shift.md. // with a takings summary (shift_z_report). See wiki/concepts/shift.md.
| "shift_open" | "shift_open"
| "shift_z_report" | "shift_z_report"
// Admin loads/removes physical drawer cash (the float). Signed payload:
// { amountMinor (signed: + load, − removal), reason, currency, operator }.
// Folds into the drawer balance carried across shifts. See wiki/concepts/shift.md.
| "cash_movement"
| "anomaly"; | "anomaly";
/** How money was tendered (for payment events + the shift Z-report). */ /** How money was tendered (for payment events + the shift Z-report). */
@@ -81,6 +85,9 @@ export interface LedgerPayload {
/** plate/vehicle from the vision service (advisory). */ /** plate/vehicle from the vision service (advisory). */
readonly plate?: string; readonly plate?: string;
readonly plateConfidence?: number; 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. */ /** Free-form for forward-compat without a schema change. */
readonly [k: string]: unknown; readonly [k: string]: unknown;
} }
@@ -89,11 +96,22 @@ export interface LedgerPayload {
export type DeviceEventKind = "input" | "relay" | "status" | "read" | "snapshot"; export type DeviceEventKind = "input" | "relay" | "status" | "read" | "snapshot";
/** /**
* The composable rate card stored in a tariff_version.structure. Pure data the * The composable rate card stored in a tariff_version.structure.
* fee function interprets — no rates in code. Stepped duration blocks + caps/grace; *
* a flat rate is just one block. See wiki/concepts/tariff.md. * 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). */ /** Free if exited within this (drop-off/turnaround). */
readonly gracePeriodEntryMin: number; readonly gracePeriodEntryMin: number;
/** Billing granularity; partial increments round UP. */ /** Billing granularity; partial increments round UP. */
@@ -116,6 +134,74 @@ export interface TariffBlock {
readonly priceMinorPerIncrement: number; 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. * 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 * PURE + deterministic + offline — the pay station calls it with asOf = now; the
@@ -131,7 +217,18 @@ export function computeFee(
enteredAt: string, enteredAt: string,
asOf: string, asOf: string,
tariff: TariffStructure, tariff: TariffStructure,
category?: string,
): number { ): 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); const ms = Date.parse(asOf) - Date.parse(enteredAt);
if (!Number.isFinite(ms) || ms <= 0) return 0; if (!Number.isFinite(ms) || ms <= 0) return 0;
const rawMinutes = ms / 60_000; const rawMinutes = ms / 60_000;
@@ -157,49 +254,251 @@ export function computeFee(
return total; 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 * 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) * 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. * so a malformed rate card can never be published. See wiki/concepts/tariff.md.
*/ */
export function validateTariffStructure(s: unknown): string[] { export function validateTariffStructure(s: unknown): string[] {
const errs: string[] = [];
if (!s || typeof s !== "object") return ["structure must be an object"]; 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`); 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"); /** Validate the block ladder (ascending bounds, open-ended last). `prefix` labels
nonNegInt(t.lostTicketMinor, "lostTicketMinor"); * 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;
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(`${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). See wiki/concepts/tariff.md.
const lastBlock = (blocks as Partial<TariffBlock>[])[blocks.length - 1];
if (lastBlock && lastBlock.uptoMin != null) {
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) { if (typeof t.incrementMin !== "number" || !Number.isInteger(t.incrementMin) || t.incrementMin < 1) {
errs.push("incrementMin must be a positive integer"); 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 (t.overstay !== "reprice") errs.push('overstay must be "reprice"');
if (!Array.isArray(t.blocks) || t.blocks.length === 0) { const cards = t.windowedCards ?? [];
errs.push("blocks must be a non-empty array"); // tz is required once there are windowed cards (wall-clock is meaningless without it).
} else { if (cards.length > 0 && (typeof t.tz !== "string" || t.tz.length === 0)) {
let prevBound = 0; errs.push("tz (IANA timezone) is required when windowedCards are present");
t.blocks.forEach((b, i) => {
const last = i === t.blocks!.length - 1;
nonNegInt(b?.priceMinorPerIncrement, `blocks[${i}].priceMinorPerIncrement`);
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})`);
} else {
prevBound = b.uptoMin;
}
}
});
} }
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; 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 /** 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. */ * [prevUpto, uptoMin) contains it; the open-ended (uptoMin=null) block catches the rest. */
function rateAt(blocks: readonly TariffBlock[], cumulativeMin: number): number { function rateAt(blocks: readonly TariffBlock[], cumulativeMin: number): number {
@@ -213,6 +512,131 @@ function rateAt(blocks: readonly TariffBlock[], cumulativeMin: number): number {
return blocks.length ? blocks[blocks.length - 1]!.priceMinorPerIncrement : 0; 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[] = [ export const ROLES: readonly Role[] = [
"admin", "admin",
"operator", "operator",
+257
View File
@@ -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([]);
});
});
+9
View File
@@ -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"],
},
});
+1519 -7
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -14,7 +14,7 @@ session projection.
## The rule ## The rule
An identity (ticket id, [[permit]] credential, or plate) **must not enter while it already has an An identity (ticket id, [[subscription]] credential, or plate) **must not enter while it already has an
OPEN [[parking-session|session]].** At entry: OPEN [[parking-session|session]].** At entry:
``` ```
@@ -25,13 +25,13 @@ identify vehicle → is there already an OPEN session for this id?
This is a **fold over the signed [[append-only-event-chain]]** ("does an entry for this id exist This is a **fold over the signed [[append-only-event-chain]]** ("does an entry for this id exist
with no matching exit?") — not a mutable in/out flag that could be edited. Same projection that with no matching exit?") — not a mutable in/out flag that could be edited. Same projection that
powers [[capacity-occupancy]] and [[permit]] `maxConcurrent`. powers [[capacity-occupancy]] and [[subscription]] `maxConcurrent`.
## Interaction with the limits already designed ## Interaction with the limits already designed
- **Transient ticket** — a single ticket id is inherently one session; a second entry on the same - **Transient ticket** — a single ticket id is inherently one session; a second entry on the same
id is always a violation (or a re-print/duplication attempt). id is always a violation (or a re-print/duplication attempt).
- **Permit** — passback is the *per-car* case of the permit's `maxConcurrent` ([[permit]]): a - **Permit** — passback is the *per-car* case of the permit's `maxConcurrent` ([[subscription]]): a
multi-car permit legitimately has several open sessions, but **the same car/credential** entering multi-car permit legitimately has several open sessions, but **the same car/credential** entering
twice is still a violation. So enforce per-identity, *under* the permit's concurrency allowance. twice is still a violation. So enforce per-identity, *under* the permit's concurrency allowance.
+112
View File
@@ -0,0 +1,112 @@
---
type: concept
tags: [parking, frontend, booth, realtime, ui]
sources: []
updated: 2026-06-18
status: open
---
# Booth Console (operator UI architecture)
The **operator console** — the real-time UI an attendant runs at a manned booth. Built 2026-06-17/18
on top of the [[react-vite-spa]]. This page covers the *architecture* (stack, live feed, layout);
the booth's *business flows* live in [[booth-exit-flow]], [[shift]], [[parking-session]].
## Stack (added 2026-06-17, beyond plain React)
The operator UI outgrew "plain React + useState" once it needed live updates and a real layout:
- **TanStack Query** owns SERVER state (fetch/cache/refetch/loading-error), wrapping the existing thin
`apiFetch` client. Server data is never duplicated into client state.
- **TanStack Router** — real routes (`/booth`, `/shift`, `/setup`, `/tariff`, `/permits`, `/site`),
role-guarded (admin-only routes redirect non-admins to `/booth`). Code-based route tree.
- **Zustand** — small CLIENT state only: the live WebSocket status + a rolling in-memory event feed +
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. 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
> while the UI was a few admin forms; the live booth console justified the additions.
## Live feed — one WebSocket (`/api/ws`)
The booth must reflect entries/exits/payments the instant they happen, so the console opens **one
authenticated WebSocket** app-wide instead of polling. See the WS tap in [[append-only-event-chain]]
(`EventLog.append` fires a read-side `onAppended` callback → the device bus `emitLedger` → the WS
route fans it out):
- On each signed **ledger** append (entry/exit/payment/void/anomaly/cash_movement/shift_*) the server
pushes the event **plus the recomputed [[capacity-occupancy|occupancy]]** (a fold over the same
ledger, always authoritative). Printer-status changes ([[printer-status-monitoring]]) ride the same
socket.
- The client appends to the Zustand feed for the live ticker AND **invalidates the matching Query
caches** (events, occupancy, active-sessions) — so Query stays the source of truth; the WS is the
freshness trigger. Auto-reconnect with capped backoff survives a server restart.
### Auth — anti-CSWSH
The handshake is a normal GET through Fastify, so the **HttpOnly JWT cookie** that guards the REST API
guards the WS too. But a browser `WebSocket` can't send the CSRF double-submit header, which would
leave the socket open to **Cross-Site WebSocket Hijacking** (a malicious page opens
`ws://<booth>/api/ws`, the browser auto-attaches the cookie, the attacker reads the live feed). So the
WS route replaces CSRF with an **Origin allowlist** (same-origin always; extra origins via
`WS_ALLOWED_ORIGINS` for the dev SPA): a missing/cross origin is rejected before auth. The stream is
read-only — it can never mutate state. (Found + fixed by automated security review, 2026-06-17.)
## The booth screen (`/booth`)
Dense terminal layout: a **ticket input** (HID-scanner-friendly — types the id + Enter) spanning the
top; a left column with the **occupancy gauge** above the **[[booth-exit-flow|Active Sessions]]** list;
a right column with the **live event ticker**. Submitting/clicking a ticket opens the **pay/exit
modal** (entry/duration/total, tender, voucher checkbox, entry/exit snapshots). All live-refreshed via
the WS.
## The shift control (header) + the booth gate
The header carries a single **shift button** that expresses the [[shift|site-wide single-open
shift]] (added 2026-06-18):
- **No shift open** → "Open shift" (green, enabled).
- **My shift open** → "Close shift" (red, enabled — signs + prints the Z-report).
- **Another operator's shift open** → **disabled**, titled with who holds it. You can neither open
yours nor close theirs until they hand over.
State comes from one shared Query (`useShift()` → `GET /api/shift/current`, returning `{ open:
{startedAt, operator} | null, isMine }`); the WS invalidates it on `shift_open` / `shift_z_report` /
`cash_movement`, so the button (and the per-shift log scope) update live without polling.
The **booth screen gates on this**: the pay/exit modal shows an "open a shift" banner (with a
one-click *Open shift now*) and disables pay/exit/voucher until **this operator's** shift is open;
the Active-Sessions "Open barrier" is disabled the same way. The server enforces it regardless
(`requireShift` 409 `no_shift`) — the UI just front-runs the rejection. The live feed is **scoped to
the open shift's window** (empty when no shift is open). See [[shift]] for the rule and the routes.
## The device-status footer
A **fixed footer** in the app shell shows the live status of every configured device — relays,
readers, cameras, printers — one chip each (coloured dot + name + fault detail), with an "all ready
/ N offline" roll-up. Fed by the unified [[device-status-monitoring|DeviceMonitor]] over the same
`/api/ws` socket (`hello` carries the initial set; a `device-status` frame per change), held in the
live store keyed by device id, with `GET /api/devices/status` as the seed/fallback. Visible on every
screen, so the operator always sees the barrier relay's reachability and the printer's paper state.
## Dev notes
- Vite proxies `/api/ws` (`ws: true`) to the backend; the backend's Origin allowlist must include the
dev SPA origin (`WS_ALLOWED_ORIGINS=http://localhost:5173`). In production Fastify serves the SPA
same-origin, so the allowlist isn't needed.
- Start the dev SPA via `pnpm dev` from `apps/web` (not `npx vite --host …`, which has mangled args
and served 404s in this environment).
## Open
- **No automated frontend tests** — the booth/live-feed/modal logic is verified manually
(Playwright + curl + DB inspection), not by a suite. The standing test-harness gap (see
[[reconciliation]]-adjacent notes) now spans front and back.
- The pre-existing admin screens (Setup/Tariff/Permits/Site/Shift) still carry their **old inline
styles** — reachable and functional, not yet on the terminal component system.
+175
View File
@@ -0,0 +1,175 @@
---
type: concept
tags: [parking, domain, booth, exit, payment, threat-model]
sources: []
updated: 2026-06-18
status: open
---
# Booth Exit Flow — pay-at-booth, voucher vs. immediate exit
How the **manned booth** takes payment for a transient ticket and lets the car out. Complements the
unattended reader path in [[parking-session]] / the exit flow: same signed events, a booth-driven
trigger. Decided 2026-06-17.
## Operator flow
1. **Ticket input** on the booth screen. The operator scans (HID scanner types the id + Enter) or
keys the ticket number.
2. On submit, the booth **looks up the session** and opens a **modal**: entry time, exit time (now),
**duration**, **total owed** (the [[tariff]] quote), tender (cash/card), and a checkbox
**"Printo biletë dalje"** (print exit ticket).
3. The operator takes payment → a signed `payment` event ([[parking-session]]). What happens next
depends on the checkbox:
- **Checked → print an exit voucher.** The customer carries it to a (distant) exit and
**self-exits by scanning it** there; that scan runs the normal reader exit flow. The booth does
NOT open the barrier.
- **Unchecked → immediate exit.** When the modal closes after a successful payment, the booth
**signs `vehicle_exit`, pulses the exit relay, and fires the exit snapshot** right away (booth is
at/near the exit).
## Settled decisions (2026-06-17)
- **Voucher carries the SAME ticket id** (reprinted as the Code128 barcode). At the exit reader it
runs the existing exit validation — which now finds the session **paid + within walk-back grace**,
so it opens. No new identity or code type; the "biletë dalje" is a *paid reprint* of the entry
ticket id. Reuses [[tariff|walk-back grace]] exactly.
- **The customer always gets a transparency receipt after paying (built 2026-06-18).** Entry time,
payment time, duration parked, amount + tender — one shared `ReceiptData`/`renderReceipt` in
`@parking/devices`, two modes: (a) **voucher mode** = those figures PLUS the scannable barcode and
an emphasised walk-back-grace line ("Dilni brenda N min — skanoni këtë biletë në dalje"), so the
one slip both proves payment and self-exits — this **replaced the old barcode-only voucher**;
(b) **standalone mode** = detail-only (no barcode), **auto-printed at payment** when the checkbox
is OFF (booth at the exit, no voucher). Both fold the figures from the SIGNED ledger (latest
`payment` event) and print on the booth printer (failover to the dispenser). The standalone
auto-print is **best-effort**: a printer fault must NOT block the exit that already happened — the
modal shows a note and offers **"Reprint receipt"** (also used if the slip jams or the customer
asks later). Routes: `POST /api/voucher` (voucher mode), `POST /api/receipt` (standalone/reprint;
requires paid, allows an already-exited session so a reprint still works). Receipt timestamps use
the **host-local clock** (the appliance runs in site time) — distinct from the tariff's frozen tz,
which governs pricing reproducibility, not display.
- **The checkbox default lives in `site_config`** (`exit_voucher_default`, a site-wide boolean edited
in Site settings) — because it's booth geography, not per-ticket. The operator may override per
transaction. (Per-exit-point config deferred until a site has both a near and a far exit.)
- **Payment is never rolled back.** If the checkbox is OFF and `pulseOpen` fails (offline
controller), the signed `payment` + `vehicle_exit` already stand (money was taken, the car is
owed an exit). The booth surfaces a clear error and an **audited `anomaly`** so the operator opens
manually — we never silently drop the payment, and never leave a paid car without an exit event.
## Threat-model notes ([[threat-model|operator as adversary]])
- The booth exit reuses the **same validation as the reader path** (paid + within grace, or free
entry-grace) — there is no booth-only bypass that admits an unpaid car. An unpaid ticket sends the
operator to take payment first.
- Every booth action is a **signed ledger event attributed to the operator's session**: the payment,
the exit, and any `anomaly` (failed open / override). A colluding operator can't wave a car out
without leaving a signed, attributed trail visible to [[reconciliation]].
- The voucher path keeps the **camera snapshot at the physical exit** (the self-scan fires it), so
the evidence is captured where the car actually leaves, not where it paid.
## Active sessions & human-intervention barrier open
**The barrier state is ASSUMED, never confirmed.** We send "open" intent and never truly know the car
cleared ([[barrier-not-a-door]], no wired loop/sensor feedback). So a signed `vehicle_exit` does NOT
mean the car is gone — it may be stuck (damaged ticket / dead scanner, or the barrier re-closed on a
phantom obstacle: an animal, a person, a cardboard box or bag in the wind). These edge cases need a
**human in the booth** to open the barrier, leaving a signed trace.
**A session is "active" (shown in the booth Active Sessions list) while it is EITHER:**
- **open + unpaid** — entered, no `vehicle_exit`, owing money. **Always shown** — a car that owes
money never ages out; it's genuinely still inside until it pays, however long that takes. OR
- **open + paid, still within grace** — paid but no exit recorded yet, `now ≤ graceExpiresAt`. OR
- **exited but `now ≤ graceExpiresAt`** — paid and/or the voucher scanned, but still within the
walk-back grace window. Because the barrier is unconfirmed, the car is presumed *possibly still
present* until grace expires. **Payment and a successful voucher scan do NOT remove it from the
list** — only grace expiry does.
A session drops off the list once it is **past grace** and EITHER exited OR **paid** (presumed truly
gone). The **paid age-out** is important: a paid session whose walk-back grace lapsed has left, so it
is omitted **even if no `vehicle_exit` was ever signed**. Without this, a paid car that left via a
manual barrier re-open (which historically signed no exit — see below) would linger **forever**
(ticket T-397815c0, 2026-06-18). The signed log is untouched — this is purely the list's display
filter (`PayStation.activeSessions()`).
### The one operator action — "Open barrier" (audited re-pulse)
For an active session, the operator can open the barrier as a **human intervention**. This:
- **re-pulses an exit relay** (resolved site-wide, as the booth exit does), and
- signs an **`anomaly`** (`source: booth`, attributed to the operator, reason "manual barrier open"), and
- **closes the session IF it is still open** — i.e. if no `vehicle_exit` exists yet, the re-open *is*
this car leaving, so it also signs a **`vehicle_exit`** (`source: manual`, reason "human-intervention
exit"). If the session is **already exited** (the phantom re-close case — a second exit would
double-count occupancy), it signs **no** second exit: anomaly only.
> **Refined 2026-06-18 (was "NEVER a `vehicle_exit`").** The original rule never signed an exit on a
> re-open, on the assumption a normal `vehicle_exit` had already happened. But when the re-open was the
> *only* way a car left (its walk-back grace had expired, so a normal exit was refused), the session
> kept **no exit event** and lingered as "open" forever (ticket T-397815c0). Fix: sign the exit only
> when the session is **still open**, preserving the no-double-count guarantee for the already-exited
> case. The [[#a-session-is-active|paid age-out]] above is the belt-and-braces safety net for any
> paid session that still slips through.
**Guard — paid OR subscription, else no button.** The "Open barrier" action is shown/active for a
session that **has a payment** (paid, or paid-and-exited-in-grace) **OR is a [[subscription]]
occurrence** (prepaid — the operator must be able to assist a subscriber when the exit reader / card
fails). An **unpaid TRANSIENT** open session has **no barrier-open affordance** — the row routes to
the [[#operator-flow|pay/exit modal]] instead. The no-unpaid-bypass rule is enforced structurally
(server-side in `reopenBarrier`: `paidAt != null || subscription`). A future reason-required *force
exit* for genuine disputes would be a separately-audited path — see Open.
### Subscription occurrences in the booth (built 2026-06-18)
A subscriber's car shows in Active Sessions as a **subscription** session (badge "abonim"; labelled by
the **holder name**, not the raw `SUBSESS-…` key). Opening it shows the **pay/exit modal in
subscription mode**: entry/duration + **PREPAID** (no amount — it is **never quoted or charged**),
the entry/exit **snapshots**, and a single **Open barrier** action (the audited re-pulse). This is
exactly the assist path for a **faulty exit reader or a missing/forgotten card/QR**. The session
view (`lookup` / `activeSessions` in `pay-station.ts`) carries `subscription`, `subscriptionId`,
`subscriptionHolder`, derived from the entry payload's `permit:true` / `permitId`.
This single mechanism covers both edge cases: a **damaged ticket / dead scanner** (find the still-open
session in the list → pay/exit modal, or if already paid → Open barrier, no scan needed), and a
**phantom-obstacle re-close** (the just-exited car is still in the list within grace → Open barrier).
## ⚠ Open question — walk-back grace renews on every payment (voucher overstay)
**Found 2026-06-17. Not yet fixed.** Scenario: customer pays at the booth, takes an exit voucher,
then dawdles past the walk-back grace before reaching the exit.
What the code does today (`exit-flow.ts`, `pay-station.ts`):
- The exit reader's grace check is `now − paidAt ≤ graceExitMin`, reading **the latest payment's**
`graceExitMin`. Over the window → exit **refuses** ("top-up required"). ✓ *Correct — no free exit.*
- The re-quote (`computeFee(enteredAt, now, …)`) always prices from **entry**, never from the last
payment. So a top-up charges the **full** entry→now fee (minus what's paid is implicit via the
ledger). ✓ *Correct — the timer does NOT restart; the customer pays the true total.*
- BUT every `payment` writes its own `graceExitMin`, and the exit flow reads the **latest** one — so
**each top-up grants a fresh, full grace window.** ✗ *This is the bug.*
**The leak is time, not money.** It is not a free-exit hole (the fee always catches up from entry).
But the grace window — meant as a one-time walk-from-pay-to-gate allowance — is re-granted in full on
every payment, so a customer could pay → wait → pay a tiny delta → get another full window → repeat,
riding the gap between "paid" and "next increment accrues." With coarse [[tariff]] increments the
abuse is bounded but real.
**Candidate fixes (business call — fairness vs. anti-abuse):**
1. **Grace on top-up only when the top-up charged new money** (recommended). Kills the "tiny delta
forever" loop while staying fair to a genuine overstay; re-price stays from entry.
2. **Single non-renewing window** anchored to the FIRST payment — cleanest anti-abuse, but can unfairly
trap someone who legitimately paid, walked, then hit a slow elevator after a top-up.
3. **Cap total grace** granted per session regardless of payment count.
Decided halves: **refuse-on-expiry** and **reprice-from-entry** are deliberate and correct. The
**grace-renews-fully-per-payment** consequence was an unintended side effect of reading `graceExitMin`
off the latest payment. See [[tariff]] (walk-back grace) for the pricing side of the same question.
## As-built / open
- Backend: `GET /api/session/:identity` (lookup + quote), `POST /api/exit { identity }` (validated
booth exit), `site_config.exit_voucher_default`. Exit validation shared between the booth and the
reader path (one code path, two triggers).
- **Open: walk-back grace renews on every payment** — see the flagged section above (voucher overstay
re-grants a full grace window; pick a fix before production).
- Voucher/receipt print = `renderReceipt` (shared ESC/POS) on the booth printer ([[ticket-encoding]],
[[printer-status-monitoring]]): voucher mode = figures + barcode + grace; standalone = figures only,
auto-printed at payment. `POST /api/voucher`, `POST /api/receipt`.
- Open: a force-open **override** (lost ticket / equipment fault) — deferred; would be a separately
audited signed event, not folded into the validated path.
+1 -1
View File
@@ -18,7 +18,7 @@ editable and drifts; the chain is the truth). Spaces-free = `capacity − occupa
- **`capacity`** is admin-set per site (and per **zone/level** if the lot has sections — model a - **`capacity`** is admin-set per site (and per **zone/level** if the lot has sections — model a
`zone` on capacity + on the entry so multi-level is a later addition, not a rewrite). `zone` on capacity + on the entry so multi-level is a later addition, not a rewrite).
- Permit concurrency (`maxConcurrent`, see [[permit]]) is the same kind of fold, scoped to one - Permit concurrency (`maxConcurrent`, see [[subscription]]) is the same kind of fold, scoped to one
permit's open sessions. permit's open sessions.
## Full → refuse entry + FULL sign ## Full → refuse entry + FULL sign
+100
View File
@@ -0,0 +1,100 @@
---
type: concept
tags: [parking, device, monitoring, reliability, ui]
sources: []
updated: 2026-06-18
status: open
---
# Device status monitoring (the booth footer)
The booth shows a **fixed footer** with the live status of every configured device — relays,
readers, cameras, printers — so an operator sees at a glance that the barrier relay is reachable,
the exit scanner is up, and the ticket printer has paper. This generalises the printer-only
[[printer-status-monitoring]] to **all four [[device-adapter-pattern|device categories]]**. A
reliability control, not a threat-model one. (Built 2026-06-18.)
## What gets polled, and how
Every **enabled** row in `devices` is polled on an interval, regardless of category — the monitor
talks only to the adapter interfaces ([[device-adapter-pattern]]), never a driver SDK:
- **Printers** → their rich `MonitorableDevice.readStatus()` (paper end / near-end, cover open,
cutter error, off-line) — the same capability the existing [[printer-status-monitoring|PrinterMonitor]]
uses. The footer surfaces the fault detail.
- **Relays / readers / cameras** → the generic `Device.healthCheck()` **reachability** probe every
adapter implements (`ready | degraded | offline`). This is presence/up-ness, not a deep fault
model — a relay either answers or it doesn't.
Both collapse to one **traffic-light**: `ready | degraded | offline`, plus a `detail` string. Fail
**toward "there's a problem"**, never false-healthy: a probe that throws or times out reads
`offline` (consistent with [[printer-status-monitoring]]'s fail-safe mapping); a driver that's no
longer registered reads `offline` ("driver not registered") rather than vanishing.
## The monitor (server)
`DeviceMonitor` (`apps/server/src/device-monitor.ts`), modelled on the PrinterMonitor:
- re-reads the device set each tick (a newly-assigned/removed device appears/disappears without a
restart); drops cached status for devices that are gone or disabled;
- polls every `DEVICE_POLL_MS` (default **8000ms**), never overlapping ticks;
- caches the latest unified status per device id;
- emits a `device-status` bus event **only when a device's state or detail changes** (deduped).
> **Relationship to the PrinterMonitor.** Both run. The PrinterMonitor stays the authority for the
> printer-specific live detail + its SSE stream (`/api/printers/status*`) that the entry flow may
> later depend on for [[printer-roles-failover]]. The DeviceMonitor is the **unified footer feed**
> across all categories. They poll independently (printers get probed by both — cheap HTTP reads);
> the small duplication is deliberate, to avoid coupling the footer to printer internals. Could be
> consolidated later if the overlap ever matters.
## API / live UI
- `GET /api/devices/status` — cached snapshot of all devices (no device round-trip). Any
authenticated role (operational, not a setup action).
- Live updates ride the **one booth [[booth-console|WebSocket]]** (`/api/ws`): the `hello` frame
carries the initial device-status set; a `device-status` frame is pushed per change. The web
[[booth-console|live store]] holds the set keyed by device id; the REST snapshot seeds it / fills
in if the socket is briefly down.
- **`DeviceFooter`** (`apps/web/src/ui/DeviceFooter.tsx`) renders one **compact** chip per device —
a coloured dot + a **role label, never the vendor** — ordered access → reader → camera → printer,
with a right-aligned roll-up ("N with issues" / "all ready"). Mounted in the app shell so it's
visible on every screen.
### Label = role, not vendor (refinement 2026-06-18)
The chip shows **what the device does, not who made it**: the localised category + a role/direction
suffix → `Lexuesi hyrje`, `Printer kabina`, `Kamera dalje`. The server sends a structured
**`roleKind`** token (not a composed string), the client localises it:
- **reader / camera** → the direction inherited from its bound relay (`directionOf()` in
[[entry-exit-points|device-resolve]]): `entry | exit | both`.
- **access controller** → `entry | exit | both` from its `relays[]`, or **`mixed`** when it spans
more than one direction; `null` if it declares none yet.
- **printer** → `lane` (entry-dispenser) | `booth` (booth-receipt) — the [[printer-roles-failover]] role.
- `null` → the chip shows the category alone.
### Detail does NOT pollute the footer (refinement 2026-06-18)
Chips stay short — **no inline fault text**. A device that is `degraded`/`offline` is clickable (so
is the roll-up); clicking opens a small **issues panel** anchored above the footer that lists only
the problem devices with their role label, state, the `detail` string, and the last-checked time.
`ready` chips are non-interactive. The panel closes on outside-click / Escape (a lightweight
popover — no extra dependency; only Radix Dialog is installed).
## Verified (2026-06-18)
On a fresh DB seeded with a stub relay, a TCP reader, and two printers (one reachable, one not):
relay + reader → `ready` via `healthCheck`; the unreachable printer → `offline` (with a detail
string, never threw); the bus emitted once per device on first observation, and a second unchanged
tick was silent (change-only emit). Server + web build clean.
## Open / not yet done
- **Reachability ≠ correctness.** `healthCheck()` says a relay/reader answers, not that it's wired
to the right barrier or reading cards — that's a setup/precondition concern ([[first-run-setup]],
the Dingtian [[access-controller-button-flow|precondition checks]]).
- **No per-device history / alerting.** The footer is point-in-time; a flapping device isn't
tracked over time. Reconciliation-style alerting is out of scope here.
- **Cameras** only expose `healthCheck` reachability today; a "last snapshot age" health signal
could be richer ([[lpr-camera]], [[opencv-anpr-service]]).
- Possible later **consolidation** of PrinterMonitor + DeviceMonitor (see the note above).
+55
View File
@@ -0,0 +1,55 @@
---
type: concept
tags: [parking, frontend, i18n, localization]
sources: []
updated: 2026-06-18
status: open
---
# Internationalization (i18n)
The operator UI ships in **two languages: Albanian (default) and English**. Language is a
**per-user preference stored server-side** and loaded on login — not a browser/localStorage setting,
not a site-wide one. So an operator's choice follows their account and is restored on every login from
any booth. (Decided + built 2026-06-18.)
## Decisions
- **Albanian is the default and fallback.** English is the second language. A missing English key
falls back to Albanian.
- **Per-user, server-side preference.** `users.language` (`'sq' | 'en'`, default `'sq'`; migration
0003). Returned from `/api/auth/login` and `/api/auth/me`, and changed via **`PUT /api/auth/language`**
(self-service, any signed-in role). It is **deliberately NOT in the JWT** (identity/role only) — so
changing language is a DB write + immediate `/me`, with no token refresh / re-login. See
[[local-jwt-auth]].
- **Library: react-i18next** (i18next). Chosen over a hand-rolled `t()` for pluralization,
interpolation, and headroom beyond two languages. The active language is applied after `/me`
resolves (App effect on `user.language`); the header **SQ/EN toggle** switches instantly *and*
persists.
- **Printed tickets/receipts stay Albanian.** Customer-facing paper is **independent** of the
operator's UI language — an operator reading the UI in English still prints Albanian tickets. The
print strings live in the device driver's `STR` table ([[ticket-encoding]], [[site-metadata]]); can
become a `site_config.print_language` setting later if a site ever needs English receipts.
## As-built (2026-06-18)
- **Backend:** `users.language` + the three auth touch-points above (`apps/server/src/routes/auth.ts`).
- **Frontend:** `apps/web/src/lib/i18n/` — `sq.ts` (default/fallback), `en.ts`, and `index.ts` (init +
`setLanguage()`). **Type-safe key parity:** `Catalog` is the *shape* of `sq` with string-typed
values, so TypeScript forces `en.ts` to supply every key (and the build fails on a missing/typo'd
key). Keys are dot-namespaced by area (`common`, `nav`, `status`, `auth`, `booth`, `pay`, `shift`,
`site`, `permits`, `tariff`).
- **Translated screens:** the booth ([[booth-console]] — screen, pay/exit modal, active sessions,
snapshots, status), Login, ShiftControl, SiteSettings, PermitManager, TariffComposer.
## Open / deferred
- **SetupWizard is NOT translated** (deliberate). Its content is mostly **server-provided** — driver
labels and config-field labels/help come from the backend device-catalog API ([[device-registry]],
[[first-run-setup]]). Translating only its static chrome would leave a half-English screen; it's
deferred until **backend catalog i18n** is scoped, then chrome + catalog localize together.
- **Server API error strings** are still English (surfaced raw in the UI). v1 relies on the
client mapping known errors; a fuller approach would translate by error *code*, not message.
- **Behaviour note (not a bug):** a *hard navigation* (new URL) re-bootstraps the language from the
user's stored preference via `/me` — so an un-persisted toggle resets. Correct: the stored pref
wins. The toggle persists via the PUT, so it survives once saved.
+3 -3
View File
@@ -43,7 +43,7 @@ A session needs a key that survives from entry to exit. Two populations, two key
- **Transient:** a **ticket id** (printed, ideally on pre-numbered stock — see [[reconciliation]]) - **Transient:** a **ticket id** (printed, ideally on pre-numbered stock — see [[reconciliation]])
or a **plate** read by [[lpr-camera|LPR]]. This id is carried in the event's `identity` field. or a **plate** read by [[lpr-camera|LPR]]. This id is carried in the event's `identity` field.
- **Permit holder:** a **credential** (card / plate / QR) matched to a [[permit]] record. A valid - **Permit holder:** a **credential** (card / plate / QR) matched to a [[subscription]] record. A valid
permit means the session owes nothing — the PAY step is skipped (see below). permit means the session owes nothing — the PAY step is skipped (see below).
## Lifecycle (pay-on-foot / pay station model) ## Lifecycle (pay-on-foot / pay station model)
@@ -71,7 +71,7 @@ States, as derived from events:
| **CLOSED** | a matching `vehicle_exit` event exists | | **CLOSED** | a matching `vehicle_exit` event exists |
| **VOIDED** | a `void` event references the session (lost ticket written off, error correction) | | **VOIDED** | a `void` event references the session (lost ticket written off, error correction) |
Permit sessions skip PAID: a valid [[permit]] at exit is itself the authorization to close. Permit sessions skip PAID: a valid [[subscription]] at exit is itself the authorization to close.
## Edge cases the model must name (not yet designed in full) ## Edge cases the model must name (not yet designed in full)
@@ -106,7 +106,7 @@ follow this page and [[tariff]]; the decision is recorded in [[session-model]].
fails. See [[device-input-flow]]. fails. See [[device-input-flow]].
- **Read dispatch** (`apps/server/src/read-dispatch.ts`): a credential read routes to the - **Read dispatch** (`apps/server/src/read-dispatch.ts`): a credential read routes to the
**permit flow** if it matches a permit (card/QR/bound plate), else to the transient **exit flow**. **permit flow** if it matches a permit (card/QR/bound plate), else to the transient **exit flow**.
Lane resolved once (`readerLaneWithAccess`). See [[permit]] as-built. Lane resolved once (`readerLaneWithAccess`). See [[subscription]] as-built.
- **Exit flow** (`apps/server/src/exit-flow.ts`): a credential **read** (the `read` bus channel) → - **Exit flow** (`apps/server/src/exit-flow.ts`): a credential **read** (the `read` bus channel) →
fold the signed ledger for that identity → validate **open + PAID + within `gracePeriodExitMin`** fold the signed ledger for that identity → validate **open + PAID + within `gracePeriodExitMin`**
→ signed `vehicle_exit` → `pulseOpen`. Unpaid / expired / unknown → signed `anomaly`, barrier → signed `vehicle_exit` → `pulseOpen`. Unpaid / expired / unknown → signed `anomaly`, barrier
@@ -7,6 +7,11 @@ updated: 2026-06-14
# Printer status monitoring # Printer status monitoring
> **Generalised 2026-06-18:** the booth's all-device status **footer** is a separate, unified
> monitor across every category (relays/readers/cameras/printers) — see
> [[device-status-monitoring]]. This page remains the authority for the *printer-specific* rich
> status (paper/cover/cutter) + its SSE stream; both monitors run.
The booth must know a printer is in trouble **before** a driver presses the entry button and no The booth must know a printer is in trouble **before** a driver presses the entry button and no
ticket comes out. So the system polls each printer's live status (paper out, cover open, cutter ticket comes out. So the system polls each printer's live status (paper out, cover open, cutter
jam, off-line) and pushes changes to the operator UI. A reliability control, like jam, off-line) and pushes changes to the operator UI. A reliability control, like
@@ -30,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 `isMonitorable()`. A future printer with a different status mechanism just implements the same
interface. 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) ## Status mapping (fail safe)
`readStatus()` maps to `ready | degraded | offline`: `readStatus()` maps to `ready | degraded | offline`:
+1 -1
View File
@@ -17,7 +17,7 @@ derived and rebuildable, never a separate ledger.
- **Revenue** — by day/week/shift, by tender (cash vs. card), gross vs. discounts vs. net. Source: - **Revenue** — by day/week/shift, by tender (cash vs. card), gross vs. discounts vs. net. Source:
`payment` events + [[validation-discounts|discount]] events + `shift_z_report` ([[shift]]). `payment` events + [[validation-discounts|discount]] events + `shift_z_report` ([[shift]]).
- **Occupancy** — current ([[capacity-occupancy]]) and historical curve; peak times; turnover. - **Occupancy** — current ([[capacity-occupancy]]) and historical curve; peak times; turnover.
- **Stay analytics** — average/median duration, distribution; transient vs. [[permit]] split. - **Stay analytics** — average/median duration, distribution; transient vs. [[subscription]] split.
- **Permit usage** — active permits, utilisation, concurrency vs. `maxConcurrent`. - **Permit usage** — active permits, utilisation, concurrency vs. `maxConcurrent`.
- **Anomalies** — out-of-band opens, never-exited sessions, occupancy drift, over-validation — - **Anomalies** — out-of-band opens, never-exited sessions, occupancy drift, over-validation —
the `anomaly` events + reconciliation findings ([[reconciliation]]). the `anomaly` events + reconciliation findings ([[reconciliation]]).
+82 -4
View File
@@ -2,7 +2,7 @@
type: concept type: concept
tags: [parking, domain, business, shifts, anti-fraud] tags: [parking, domain, business, shifts, anti-fraud]
sources: [] sources: []
updated: 2026-06-15 updated: 2026-06-18
status: open status: open
--- ---
@@ -21,6 +21,29 @@ is **no operator and no shift**; what replaces it is the pay station's **cash-co
[[reconciliation]] — a separate concept, not a shift. So shifts are scoped to manned operation; [[reconciliation]] — a separate concept, not a shift. So shifts are scoped to manned operation;
don't force one model across both. don't force one model across both.
## Site-wide single-open + the booth gate (decided + built 2026-06-18)
A shift is a **site-wide accountability period**: at most **one shift may be open at a time** across
the whole appliance. This is what makes a taking unambiguously attributable — every payment/exit
falls inside exactly one operator's window. Consequences:
- **Login ≠ shift.** An operator may log in **off-shift** (e.g. to review their own past activity);
logging in never opens a shift. Conversely a shift can't be opened by two people at once.
- **Opening is refused when ANY shift is open** — whether the operator's own (double-open) or
*another* operator's (handover not done). `ShiftService.open()` checks `currentOpenShift()` (the
single site-wide open shift = most recent shift event on the whole chain is a `shift_open`), and
throws `ShiftAlreadyOpenError` carrying `heldBy` so the UI can name who holds it. Operator B can
only start once operator A closes — that's the handover.
- **The booth money path is GATED on an open shift.** `/api/pay`, `/api/exit`, `/api/voucher`,
`/api/barrier/reopen` run a `requireShift` preHandler that 409s `{ code: "no_shift" }` when none
is open. Read-only lookups (`/api/session/:id`, `/api/sessions/active`, `/api/pay/quote`) stay
ungated so the modal can still *display* a session and prompt "open a shift". The server is the
enforcement point; the UI mirrors it (see [[booth-console]]).
- **"Operate under someone else's shift" is deliberately disallowed.** B's takings would land in A's
Z-report and corrupt the attribution, so B is fully blocked until B's own shift is open.
- **Logs are per-shift.** The booth live feed shows only events from the open shift's window
(`GET /api/events?since=<shiftStart>`); no shift open → no feed, just the "open a shift" prompt.
## A shift is NOT time-based ## A shift is NOT time-based
It is delimited by **explicit operator action**, never by a clock: It is delimited by **explicit operator action**, never by a clock:
@@ -40,7 +63,9 @@ login ————————————————————————
## What End Shift does ## What End Shift does
1. Determine the shift's payment set: the signed `payment` events ([[parking-session]], 1. Determine the shift's payment set: the signed `payment` events ([[parking-session]],
[[append-only-event-chain]]) between this shift's start mark and now. [[append-only-event-chain]]) between this shift's start mark and now. This includes a
**[[subscription]] fee** an operator collects during the shift (sold/renewed at the booth → a
signed `payment`, deferred build) — it folds into this set like any transient taking.
2. Sum by **tender**: `cashTotal`, and `cardTotal` from the POS/terminal **if a POS is configured** 2. Sum by **tender**: `cashTotal`, and `cardTotal` from the POS/terminal **if a POS is configured**
(the card line is omitted when there's no terminal). (the card line is omitted when there's no terminal).
3. Append a signed **`shift_z_report`** event (type already in `packages/shared`): `{ operator, 3. Append a signed **`shift_z_report`** event (type already in `packages/shared`): `{ operator,
@@ -56,8 +81,10 @@ no variance gate, no manager override.
- A shift is **two signed ledger events**, no mutable table (decision): `shift_open` (new event - A shift is **two signed ledger events**, no mutable table (decision): `shift_open` (new event
type) at start, `shift_z_report` at close. The operator is the **logged-in user**, carried in the type) at start, `shift_z_report` at close. The operator is the **logged-in user**, carried in the
event `identity`; a shift is **open** iff that operator's most recent shift event is a event `identity`. `ShiftService` (`apps/server/src/shift-service.ts`).
`shift_open`. `ShiftService` (`apps/server/src/shift-service.ts`). > **Superseded 2026-06-18:** open-ness is now judged **site-wide** (`currentOpenShift()` — the most
> recent shift event on the *whole* chain), not per-operator. See "Site-wide single-open" above.
> `openShiftFor(operator)` survives only for `close()` (you close your own shift).
- **Close** sums `payment` events in `[startedAt, endedAt]` by tender (cash vs. card, by **payment - **Close** sums `payment` events in `[startedAt, endedAt]` by tender (cash vs. card, by **payment
time**), appends the signed `shift_z_report` (totals + counts + window), then **prints** via the time**), appends the signed `shift_z_report` (totals + counts + window), then **prints** via the
new generic `PrinterDevice.printReport(title, lines)` (Rongta ESC/POS text) to a booth-receipt new generic `PrinterDevice.printReport(title, lines)` (Rongta ESC/POS text) to a booth-receipt
@@ -70,6 +97,54 @@ no variance gate, no manager override.
close totals correct + signed + printed → close-again 409 → re-open works; readonly 403; close totals correct + signed + printed → close-again 409 → re-open works; readonly 403;
verifyChain ok. verifyChain ok.
## Drawer balance — opening float, cash movements, carry-over (decided 2026-06-18)
The Z-report's payment totals answer "how much did this shift *take*?" — but a manned booth also has a
**physical cash drawer** that carries across shifts. The drawer is tracked as a running balance over
the signed chain, so each shift knows what it **inherited** and what it should **hand over**.
**The events:**
- A new signed **`cash_movement`** event: the admin loads or removes drawer cash, `{ amountMinor
(signed: + load, − removal), reason, operator }`. **Admin-only** (an operator takes payments but
cannot move the float in/out). The opening-day load (+5000 ALL) and a mid-shift withdrawal (−5000)
are both `cash_movement` events.
- The existing `payment` events already add cash to the drawer (cash tender only; card never touches
the drawer).
**The math — drawer is a fold over the chain BY TIME, not by operator** (a `cash_movement` is the
admin's, not the shift operator's, so it can't key off `identity`):
```
expectedDrawer(at) = Σ cash payments (tender=cash) up to `at`
+ Σ cash_movement amounts up to `at`
```
A shift's **opening float = expectedDrawer(shiftStart)** — i.e. everything that happened to the drawer
before this shift's start mark. It is **auto-inherited from the chain** (no operator entry). The
first shift ever opens at **0**; the admin's load makes it 5000.
**The Z-report at close** reports the full drawer picture for the shift window `[start, end]`:
`openingFloat`, `cashTakenMinor` (cash payments in-window), `cashAddedMinor` / `cashRemovedMinor`
(movements in-window), and `expectedDrawerMinor = openingFloat + cashTaken + cashAdded − cashRemoved`.
That `expectedDrawer` is exactly the **next** shift's opening float — the carry-over.
**Worked example (the canonical scenario):**
| Step | Event | Drawer |
| --- | --- | --- |
| Opening day | admin `cash_movement` +5000 | 5000 |
| Shift 1 takes 6500 cash | payments | 11500 |
| Shift 1 closes | Z: open 5000, took 6500, expected **11500** | 11500 |
| Shift 2 opens | opening float = **11500** (inherited) | 11500 |
| admin `cash_movement` −5000 | withdrawal | 6500 |
| Shift 2 takes 4500 cash | payments | 11000 |
| Shift 2 closes | Z: open 11500, took 4500, removed 5000, expected **11000** | 11000 |
| Shift 3 opens | opening float = **11000** | … |
Card payments are excluded from the drawer (they settle to the bank, not the till). The drawer figure
is **expected**, not counted — the optional blind-count enhancement below would record the *variance*
against it.
## Where the fraud control actually lives ## Where the fraud control actually lives
Deliberately **not** in a shift-close ceremony. Because every payment is a **signed event in the Deliberately **not** in a shift-close ceremony. Because every payment is a **signed event in the
@@ -86,6 +161,9 @@ reconciles the signed Z-report against the actual drawer and the bank/POS batch
## Open ## Open
- **Drawer carry-over (decided 2026-06-18, building):** opening float auto-inherits the prior shift's
expected drawer; admin-only `cash_movement` events; Z-report reports the full drawer picture. See
the Drawer balance section above.
- **Shift ↔ session boundary:** a vehicle may enter under one shift and pay under another — the - **Shift ↔ session boundary:** a vehicle may enter under one shift and pay under another — the
Z-report sums by **payment time** (when cash/card was taken), which is the operator who handled Z-report sums by **payment time** (when cash/card was taken), which is the operator who handled
the money. Confirm that's the intended accountability (vs. by entry). the money. Confirm that's the intended accountability (vs. by entry).
+78
View File
@@ -0,0 +1,78 @@
---
type: concept
tags: [parking, domain, config, devices]
sources: []
updated: 2026-06-17
status: settled
---
# Site Metadata (Park Identity)
Optional, admin-set identity/metadata for the park itself, beyond the operational
`capacity` knob. Feeds the ticket/receipt header and admin display. All fields are
**optional** — the lot runs fine with none set (the ticket falls back to a generic
`PARKING` header).
## Where it lives
A single-row extension of the existing **`site_config`** table (`id` always 1) — the
established home for site-wide knobs ([[capacity-occupancy]]). **Not** a new table:
park identity is one-per-site, same cardinality as capacity, so it shares the row.
| Column | Purpose |
| --- | --- |
| `park_name` | Display name on the ticket header / UI (e.g. "Acme Parking"). |
| `operator_name` | Legal entity operating the lot — for receipts; may differ from the display name. |
| `nius` | **NIUS** — Albanian tax/identification number, printed on the receipt when set (commonly required). |
| `address` | Free-text postal address (multi-line allowed; printed line-by-line). |
| `phone` | Contact phone — also the ticket "Keni humbur biletën?" footer. |
| `email` | Contact email (stored; not yet printed). |
All are **nullable `text`**. Added in migration `0001` (additive `ADD COLUMN`, no
data loss). A **metadata change is not a schema change for the ticket id**, but
adding these *fields* IS a schema change — done via a Drizzle migration.
> **Field history.** The first cut (2026-06-17) had `vat_number` + `registration_number`.
> Renamed/trimmed the same day to a single `nius` column (Albanian deployments call the
> tax id NIUS; registration number dropped as unused). Migration `0001` was regenerated
> in place — it had not shipped beyond the dev DB, so there is no migration debt.
## Read / write path
- **API**: `GET /api/site-config` returns capacity + every metadata field (null when
unset). `PUT /api/site-config` (**admin only**) accepts a **partial** body — only the
fields present are updated; blank string → null (clears). `apps/server/src/routes/site.ts`.
- **UI**: `apps/web/src/SiteSettings.tsx` — admin edits capacity + the metadata fields
in one form (`saveSiteConfig`).
- **API client**: `SiteConfig` type + `fetchSiteConfig` / `saveSiteConfig` in `apps/web/src/api.ts`.
## On the ticket
`renderTicket()` ([[rongta-printer]]) prints a header from a `TicketHeader` (the metadata):
park name large (or `PARKING` if unset), then operator / `NIUS: <n>` / address lines
that are present; a `Keni humbur biletën? <phone>` footer if a phone is set. The entry
flow (`#ticketHeader()` in `apps/server/src/entry-flow.ts`) reads `site_config` per print.
See [[ticket-encoding]].
## Localisation (Albanian)
The ticket prints in **Albanian** for now. Strings are centralised in a `STR` table in
[[rongta-printer]] (`Printuar më:`, `Keni humbur biletën?`, `NIUS:`) so a real i18n layer
(per-locale tables + a `t()` helper, covering the web UI too) can replace them later
without touching the render functions — that broader site translation is the next step.
**Codepage (resolved 2026-06-17).** Albanian text needs `ë`/`ç`, which ASCII can't carry.
The driver now selects **CP852 (Latin-2)** via `ESC t 18` in each print preamble and
`line()` encodes text to CP852 (with an ASCII transliteration fallback for anything
unmapped, and `?` as a last resort — never a wrong glyph). Verified at byte level: `ë` →
`0x89` in "Printuar më" / "biletën" / a sample address.
## Open
- **Receipt vs entry ticket** — the same header is used for the entry ticket today;
a paid receipt may want more (fee, tariff version, paid-at). Design with [[tariff]].
- **Email** is stored but not yet printed (no use decided).
- **Full i18n** — only the ticket is Albanian so far; the web UI is still English. A
proper locale system (and admin language choice) is the broader task this seeds.
- **CP852 coverage** — the map covers the common Albanian/Latin-2 letters; extend if a
park name/address uses a glyph outside it (currently transliterated to ASCII).
+163
View File
@@ -0,0 +1,163 @@
---
type: concept
tags: [parking, domain, business, pricing, design]
sources: [parksql2017-legacy-schema]
updated: 2026-06-18
status: settled
---
# Tariff Time Tiers — happy hour, off-peak, weekend, seasonal
**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 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: **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
1. **Legacy `BA_TicketPrice`** ([[parksql2017-legacy-schema]]): each rate-card row is scoped by
`ValidFrom`/`ValidTo` (date window) **and** `ValidFromHour`/`ValidToHour` (daily hour window) **and**
`TicketCategoryID`. Happy hour = a second price row valid 14:00–16:00. Off-peak/season = a row
with a date or hour window. The active rate is selected by **(category, now-or-entry, date)**.
2. **Research (verified):** rates modelled as **time segments nested inside recurring time frames**,
where time frames = days-of-week / holidays / special-event days (US patent 10,762,723, 3-0
verified). Industry APIs (INRIX `structured_rate`) carry `time_in`/`time_out` + `dow` per rate.
Both point at the **same primitive**: a rate that is *active for a wall-clock window*.
Both converge: **happy hour is not a discount flag — it is a selector over which rate card is active
for a given slice of wall-clock time.**
## The decision to make: which-rate selector vs. discount modifier
| Option | Shape | Verdict |
| --- | --- | --- |
| **A. Time-windowed rate cards** (recommended) | A stay is sliced at wall-clock boundaries; each slice priced by the rate card whose window covers it. Happy hour = a card with `window: {dow, fromHour, toHour}`. | Most general: one mechanism covers happy hour, early-bird, night flat, weekend, season. Matches both references. |
| **B. Discount modifier on one ladder** | Keep one ladder; apply `−X%`/`−N min` when the clock is inside a window. | Simpler, but can't express "different ladder at night," daily caps interact badly, and it's a second pricing path. Rejected as the primary model. |
**Recommendation: A.** A discount-style happy hour (B) is then expressible *as* a windowed card (a
cheaper ladder), so we don't lose it.
## The wall-clock slicing consequence (the hard part)
The current `computeFee(enteredAt, asOf, structure)` walks **elapsed** minutes through `blocks`. Time
tiers add a **second clock**: the *wall-clock* time-of-day, which the elapsed walk doesn't track. A
stay 13:30→15:30 that has happy hour 14:00–16:00 must be **split at 14:00**: 30 min normal + 90 min
happy. So the fee function must:
1. Resolve the **applicable rate set** for the stay (all cards matching the category, ordered by
precedence — see below).
2. Walk the stay in wall-clock order, **switching the active card at each window boundary**, while
keeping the **elapsed-duration position** in the block ladder continuous (so block steps and the
daily cap still accrue across a window switch — a happy hour mid-stay must not reset the ladder).
3. Keep it **pure, integer, offline, deterministic** — the same invariants the current engine and the
[[append-only-event-chain|signed chain]] depend on. The `payment` event still records the
`tariffVersionId`; the version now contains the windowed card set, so a past session reprices
identically.
> Open edge: does the block ladder accrue by **elapsed time** (a 2h stay is in the 2nd block
> regardless of windows) or **reset per window**? Legacy `IntervalChange` hints some sites reset.
> **Lean: elapsed-continuous** (predictable, no double-charging), revisit if a site needs otherwise.
## Precedence (when windows overlap)
Multiple cards can match one instant (a weekday-evening card + a holiday card). Need a deterministic
winner. Proposal, most-specific-wins, matching the research's "event rates override":
`special-event/holiday > specific date range > day-of-week + hour > hour-only > default`. Ties broken
by an explicit integer `priority`. This must be **total and pure** — no ambiguity the operator can't
predict, no "depends on row order."
## Vehicle / customer category (the second new axis)
Legacy `BA_TicketCategory` prices by **category** (car/bus/VIP/…), orthogonal to time. Two ways:
- **Multiple tariffs scoped by category** — the schema already reserves `tariffs.scope`
(`site`/`zone`); add `category` cleanly, no migration. The session records which category it was
priced under.
- **Category as another window dimension** on the card. Simpler table, busier card.
**Lean: category as a tariff scope** (a category is a different rate *card*, not a different *window*
of one). Deferred until a site actually needs non-car pricing, but the `scope` hook means **no
migration when it lands**.
## Proposed data shape (illustrative)
Extend the `TariffStructure` JSON (still one immutable [[tariff]] version) with an optional ordered
card list; absence = today's single-ladder behaviour (back-compatible):
```jsonc
{
"currency": "ALL",
"defaultCard": { /* the existing blocks/cap/grace structure */ },
"windowedCards": [
{
"name": "Happy hour",
"priority": 10,
"window": { "dow": [1,2,3,4,5], "fromHour": "14:00", "toHour": "16:00" },
"blocks": [ /* cheaper ladder */ ],
"dailyCapMinor": null
}
]
}
```
A bare `defaultCard` (no `windowedCards`) is exactly today's tariff — so this ships additively and a
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
- 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.
+75 -13
View File
@@ -10,7 +10,7 @@ status: open
How a [[parking-session]]'s fee is computed from its duration. A tariff is **admin-composed data, How a [[parking-session]]'s fee is computed from its duration. A tariff is **admin-composed data,
not code** — the park owner builds and constantly edits the rate card at runtime (like a not code** — the park owner builds and constantly edits the rate card at runtime (like a
[[permit]]), in a selectable currency, with **no numbers hard-coded anywhere** and no code change to [[subscription]]), in a selectable currency, with **no numbers hard-coded anywhere** and no code change to
reprice. The computation is **pure and offline** ([[offline-first]]: no network, no clock authority reprice. The computation is **pure and offline** ([[offline-first]]: no network, no clock authority
beyond the host). beyond the host).
@@ -45,11 +45,12 @@ code. All amounts are **integer minor units** in the tariff's currency.
"currency": "EUR", // ISO 4217; selectable per tariff version "currency": "EUR", // ISO 4217; selectable per tariff version
"gracePeriodEntryMin": 15, // free if exited within this (drop-off/turnaround) "gracePeriodEntryMin": 15, // free if exited within this (drop-off/turnaround)
"incrementMin": 60, // billing granularity; partial increments round UP "incrementMin": 60, // billing granularity; partial increments round UP
"blocks": [ // consumed in order as duration accrues "blocks": [ // consumed in order as duration accrues; uptoMin is
// the CUMULATIVE upper bound in minutes
{ "uptoMin": 60, "priceMinorPerIncrement": 200 }, // first hour { "uptoMin": 60, "priceMinorPerIncrement": 200 }, // first hour
{ "uptoMin": 180, "priceMinorPerIncrement": 150 }, // 60→180 min { "uptoMin": 180, "priceMinorPerIncrement": 150 }, // 60→180 min
{ "uptoMin": null, "priceMinorPerIncrement": 100 } // null = open-ended, thereafter { "uptoMin": null, "priceMinorPerIncrement": 100 } // REQUIRED open-ended last
], ], // block — the explicit "thereafter" rate
"dailyCapMinor": 1200, // cap per rolling 24h (null = no cap) "dailyCapMinor": 1200, // cap per rolling 24h (null = no cap)
"lostTicketMinor": 2000, // flat charge when there's no entry id "lostTicketMinor": 2000, // flat charge when there's no entry id
"gracePeriodExitMin": 15, // pay-on-foot walk-back window "gracePeriodExitMin": 15, // pay-on-foot walk-back window
@@ -92,6 +93,12 @@ because the chain + reconciliation depend on the result being reproducible.
increment would round it up (else rounding defeats the grace window). increment would round it up (else rounding defeats the grace window).
- **The block ladder RESETS each rolling-24h day** — day 2 starts at the first block again (a 25h - **The block ladder RESETS each rolling-24h day** — day 2 starts at the first block again (a 25h
stay = day-1 capped + day-2 first-hour rate), so the "daily" rate truly resets daily. stay = day-1 capped + day-2 first-hour rate), so the "daily" rate truly resets daily.
- **The LAST block MUST be open-ended (`uptoMin: null`)** — enforced on publish (2026-06-18). A
bounded final block silently inherited its own rate past its bound (a hidden, never-stated price);
forcing an open-ended tail makes the "thereafter" rate explicit. `rateAt()` still gracefully prices
legacy bounded-tail versions (validation runs only on publish, never on read), so already-published
immutable versions keep pricing unchanged. This is the "first N hrs × X, next N hrs × Y, …, 24h
cap" model made complete — the same engine, no new axis; the only gap was the unstated tail.
**As-built:** `computeFee(enteredAt, asOf, structure)` in `packages/shared` (pure). Unit-tested **As-built:** `computeFee(enteredAt, asOf, structure)` in `packages/shared` (pure). Unit-tested
across grace, block steps, daily cap, and multi-day reset. across grace, block steps, daily cap, and multi-day reset.
@@ -103,12 +110,17 @@ The admin authors the rate card at runtime — no hand-seeding:
- **API** (`apps/server/src/routes/tariffs.ts`): `GET /api/tariff` (active version + history; any - **API** (`apps/server/src/routes/tariffs.ts`): `GET /api/tariff` (active version + history; any
signed-in role) and `POST /api/tariff/versions` (publish a new immutable version; **admin only**). signed-in role) and `POST /api/tariff/versions` (publish a new immutable version; **admin only**).
Publishing validates the structure via `validateTariffStructure` (shared) — non-negative integers, Publishing validates the structure via `validateTariffStructure` (shared) — non-negative integers,
ordered/ascending block bounds, only the last block open-ended — so a malformed card can never be ordered/ascending block bounds, **the last block open-ended (enforced)**, and **`effectiveFrom`
published. The single site `tariffs` row is created lazily on first read/publish. not in the past** (no backdating) — so a malformed or retroactive card can never be published. The
single site `tariffs` row is created lazily on first read/publish.
- **UI** (`apps/web/src/TariffComposer.tsx`, admin shell): edit currency, grace windows, increment, - **UI** (`apps/web/src/TariffComposer.tsx`, admin shell): edit currency, grace windows, increment,
daily cap, lost-ticket fee, and add/remove rate blocks; amounts entered in major units, converted daily cap, lost-ticket fee, and add/remove rate bands; amounts entered in major units, converted to
to integer minor units on submit. Shows the active version + history; "Publish" creates a new integer minor units on submit. **Bands are edited as a DURATION in hours** ("this band lasts N
version (past sessions keep their pricing). hours") — the owner thinks "first 2 hours, then next 3 hours", not in cumulative minutes; the
composer accumulates per-band hours into the engine's cumulative `uptoMin` (minutes) on submit. The
**last band is always the open-ended "thereafter"** row (not removable, no hours field), so a
published card always satisfies the open-ended-last rule. Shows the active version + history;
"Publish" creates a new version (past sessions keep their pricing).
- Ships **blank** — until a version is published, `GET /api/tariff` returns `active: null` and the - Ships **blank** — until a version is published, `GET /api/tariff` returns `active: null` and the
pay station returns `409 no active tariff`. Verified end to end (publish → pay station prices). pay station returns `409 no active tariff`. Verified end to end (publish → pay station prices).
@@ -124,11 +136,29 @@ time references**, not one:
`gracePeriodExit` is therefore a real revenue/UX parameter, not a nicety: too short traps people `gracePeriodExit` is therefore a real revenue/UX parameter, not a nicety: too short traps people
who paid; too long gives free parking between pay and exit. who paid; too long gives free parking between pay and exit.
> **As-built correction (2026-06-17):** the overstay top-up reprices from **entry**, not `paidAt` —
> `computeFee(enteredAt, now, …)` (so the timer never restarts; the customer pays the true entry→now
> total). The line above (`f(paidAt, now, …)`) was the original sketch; the implementation uses entry.
### ⚠ Open question — walk-back grace renews on every payment
A consequence of the two-time-reference model, surfaced via the [[booth-exit-flow|booth exit /
voucher]] path: every `payment` event stores its own `gracePeriodExit`, and the exit check reads the
**latest** payment's value. So an **overstay top-up re-grants a full, fresh grace window** each time.
The fee is correct (always recomputed from entry — no free exit), but the **walk-back grace doubles**
(or repeats) on every top-up — a customer could pay → wait → pay a tiny delta → earn another window →
repeat. The leak is **time, not money**, bounded by increment coarseness but real.
Candidate policies (business call): grant grace on a top-up **only when it charged new money**
(recommended), a **single non-renewing window** from the first payment, or a **per-session grace
cap**. Full analysis + the decided/undecided halves live in [[booth-exit-flow]]. Pick a policy before
production.
## Permit holders ## Permit holders
A valid [[permit]] bypasses tariff computation entirely for the covered period (subscription A valid [[subscription]] bypasses tariff computation entirely for the covered period (subscription
already paid out-of-band). A permit that has lapsed mid-stay falls back to the transient tariff for already paid out-of-band). A permit that has lapsed mid-stay falls back to the transient tariff for
the uncovered time — an edge case to design with [[permit]]. the uncovered time — an edge case to design with [[subscription]].
## Versioning — edits publish immutable, effective-dated versions ## Versioning — edits publish immutable, effective-dated versions
@@ -145,6 +175,24 @@ that was in force when it was incurred — never today's. So a tariff is **never
- An **in-progress** session that crosses a version boundary uses the version in force at **entry** - An **in-progress** session that crosses a version boundary uses the version in force at **entry**
(consistent, predictable) — confirm vs. pro-rating if an operator ever wants the latter. (consistent, predictable) — confirm vs. pro-rating if an operator ever wants the latter.
### No backdating — versioning would otherwise be retroactive (fixed 2026-06-18)
The two bullets above only hold if a new version's `effectiveFrom` **cannot be in the past**. The
selector is "latest `effectiveFrom ≤ entry time`", so publishing a version with a **backdated**
`effectiveFrom` would silently re-select it for sessions that **already entered** — retroactively
repricing in-progress (and re-quotable) stays. That is exactly the rewrite the versioning exists to
prevent, and it was **publishable** until this fix (the publish handler accepted any `effectiveFrom`,
defaulting to now).
**Rule (enforced server-side in `routes/tariffs.ts`):** on publish, `effectiveFrom` must be **≥ now**
(a 60 s skew tolerance absorbs clock drift + round-trip). A **future** `effectiveFrom` is allowed —
scheduling a forthcoming price change is legitimate and forward-only. A past one is rejected `400`.
Combined with entry-time selection, this makes the guarantee structural: **once a car has entered, no
later publish can change its price**, because no new version can carry an `effectiveFrom` that
predates the entry. We deliberately did **not** also pin `tariffVersionId` onto the `vehicle_entry`
event (entry-time selection + no-backdating already freezes the price); revisit only if multi-tariff
`scope` makes entry-time resolution ambiguous.
## Data model (first cut — with [[session-model]]) ## Data model (first cut — with [[session-model]])
| Table / field | Notes | | Table / field | Notes |
@@ -169,12 +217,26 @@ 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 on the network — [[offline-first]]), a base currency, and a rounding policy. Deferred to
[[open-questions]]. [[open-questions]].
## Extensions
Grounded in [[parksql2017-legacy-schema|the legacy schema]] + external research:
- **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
monthly-invoiced liability derivable from the chain.
## Open ## Open
- The **actual rate cards** are owner-authored at runtime — nothing to confirm at build time; the - The **actual rate cards** are owner-authored at runtime — nothing to confirm at build time; the
composer UI + validation (sane blocks, non-negative, ordered `uptoMin`) is the work. composer UI + validation (sane blocks, non-negative, ordered `uptoMin`) is the work.
- **Time-of-day / weekday tiers** — not in the block model yet; add as a tier wrapper if a site
needs day/night/weekend cards (deferred until asked).
- **Blank-tariff policy** — free vs. gated until a rate card is published (operator policy). - **Blank-tariff policy** — free vs. gated until a rate card is published (operator policy).
- **In-progress version-boundary** — entry-version (decided) vs. pro-rate (revisit if needed). - **In-progress version-boundary** — entry-version (decided) vs. pro-rate (revisit if needed).
- **FX** — exchange-rate system, offline rate source, base currency ([[open-questions]]). - **FX** — exchange-rate system, offline rate source, base currency ([[open-questions]]).
+33 -5
View File
@@ -21,14 +21,35 @@ must have:
- **Opaque + unguessable** — a random id (not a sequential count an attacker could iterate to claim - **Opaque + unguessable** — a random id (not a sequential count an attacker could iterate to claim
someone else's cheaper session). Sequential **physical** stock numbering is a separate someone else's cheaper session). Sequential **physical** stock numbering is a separate
reconciliation aid ([[reconciliation]] pre-numbered stock), not the scan key. reconciliation aid ([[reconciliation]] pre-numbered stock), not the scan key.
- **All-numeric** (as-built 2026-06-17) — so ANY legacy 1D barcode scanner reads it and an operator
can hand-key it. Random (not sequential), so "all-numeric" does not weaken the unguessable
property. Format: **13 digits = 12 cryptographically-random digits + 1 Luhn check digit**
(10^12 space → negligible collisions at lot scale; the Luhn digit lets manual entry reject a typo
rather than fail as "session not found"). `newTicketId()` in `apps/server/src/entry-flow.ts`;
validate with `validateTicketCode()` (gate MANUAL entry only — a scanned/looked-up id already in
the ledger is authoritative regardless of format).
- **Format is a property of minting, not the schema** — `identity` / `sessions.id` are free-form
`text`, so changing the id format is a code change with **no migration**. Legacy `T-<uuid>` ids
(pre-2026-06-17) remain valid keys and coexist with numeric ones.
- **Single logical session** — scanning it at the pay station finds the open session; after payment - **Single logical session** — scanning it at the pay station finds the open session; after payment
it's the proof-of-paid the exit checks. it's the proof-of-paid the exit checks.
## Encoding: QR (preferred) — printed by the booth dispenser ## Encoding: Code128 numeric barcode — printed by the booth dispenser
- The [[rongta-printer]] prints the ticket id as a **2D barcode (QR)** plus human-readable text and - The [[rongta-printer]] prints the ticket id as a **1D Code128 barcode** (the all-numeric code),
entry time. QR over 1D barcode: denser, tolerant of crumpling/partial reads, easy for a cheap with the **same code in large human-readable digits below it**, then the entry time. Code128 over
camera/imager to read. QR for the primary symbology because the booth's reader hardware is unknown and a legacy 1D laser
scanner is the lowest common denominator — and the printed digits mean total reader failure still
leaves a hand-keyable code. A **QR for phone/imager scanning may be added later behind an admin
toggle** (deferred — see Open).
> **As-built (2026-06-17).** `renderTicket()` in [[rongta-printer]]
> (`packages/devices/src/drivers/printer-rongta.ts`) emits the Code128 via ESC/POS `GS k` (code set
> B) — **rendered by the printer firmware**, so there is no image-rendering step and no new
> dependency (keeps the MIT/Apache/BSD constraint). Resilience rationale: the booth's reader is
> uncertain, so the id is carried in two independently-readable forms (1D barcode / printed digits).
> The "operator scans with a phone" path reuses the
> existing dispatch flow ([[entry-exit-readers]]) and is tracked separately (not yet built).
- **Scan points** (both host-side reads — [[entry-exit-readers]]): - **Scan points** (both host-side reads — [[entry-exit-readers]]):
- **Pay station** — customer scans the ticket → host finds the session → shows fee → takes - **Pay station** — customer scans the ticket → host finds the session → shows fee → takes
payment ([[tariff]], pay-on-foot) → appends `payment`. payment ([[tariff]], pay-on-foot) → appends `payment`.
@@ -51,7 +72,14 @@ isn't captured or is low-confidence (recognition is advisory — [[opencv-anpr-s
## Open ## Open
- QR symbology/error-correction level + what else prints (site name, tariff summary, help number). - Primary symbology **decided**: Code128 set B over the all-numeric id (as-built above). Still open:
what *else* prints (site name, tariff summary, help number).
- **Optional QR (deferred)** — an admin toggle to ALSO print a QR for phone/imager users. The
`code128()`/`qrCode()` ESC/POS helpers were prototyped 2026-06-16; QR was dropped 2026-06-17 in
favor of "1D barcode + hand-keyable numeric code" because the booth's reader hardware is unknown.
Revisit when mobile scanning is wanted.
- **Phone-scan fallback** (operator scans a ticket with a phone when a reader is down) — designed
but not built: an authenticated route feeding the same dispatcher + a minimal mobile scan UI.
- Scanner hardware (imager model; same unit at pay station and exit?). - Scanner hardware (imager model; same unit at pay station and exit?).
- Lost/damaged ticket → the lost-ticket path ([[parking-session]], [[tariff]] admin-arbitrary - Lost/damaged ticket → the lost-ticket path ([[parking-session]], [[tariff]] admin-arbitrary
amount). amount).
+10 -1
View File
@@ -39,8 +39,17 @@ Because each validation is signed and attributed (`issuedBy`), over-validation b
merchant is **visible to [[reconciliation]]** (a merchant validating far more than their footfall is merchant is **visible to [[reconciliation]]** (a merchant validating far more than their footfall is
an anomaly), rather than invisible free parking. an anomaly), rather than invisible free parking.
## Postpaid sponsors
When the validating party is a **business with a postpaid agreement** (its customers park free, it's
billed monthly) — not just a one-off discount — the **sponsor account + settlement** layer is in
[[validation-sponsorship]]. That's the distinction between a discount (this page) and a *sponsored*
session that accrues a receivable.
## Open ## Open
- Validation types the site needs (free hours / fixed amount / percentage / flat rate). - Validation types the site needs (free hours / fixed amount / percentage / flat rate) — superset in
[[validation-sponsorship]] (`comp`/`percent`/`fixed`/`time-credit`/`rate-switch`).
- Whether merchants self-serve (portal/terminal) or the operator applies it. - Whether merchants self-serve (portal/terminal) or the operator applies it.
- Caps (max discount, max per merchant/day). - Caps (max discount, max per merchant/day).
- Prepaid coupon pool vs. postpaid accrual — see [[validation-sponsorship]].
+94
View File
@@ -0,0 +1,94 @@
---
type: concept
tags: [parking, domain, business, pricing, validation, design]
sources: [parksql2017-legacy-schema]
updated: 2026-06-17
status: open
---
# Validation & Sponsorship — merchant comps, coupons, postpaid B2B
Builds on [[validation-discounts]] (the signed-event discount mechanism) to add the layer it leaves
open: **a sponsor account and postpaid B2B billing.** The driving case — **a nearby business with a
postpaid agreement whose customers enter and exit freely, billed to the business monthly.**
> This page owns the **sponsor/account/settlement** model and the **permit-vs-validation
> distinction**. The *how a discount is applied* mechanics (signed event, `due = max(0, fee −
> discounts)`, attribution, anti-abuse) live in [[validation-discounts]] — not duplicated here.
> Status: **design, not built.**
## Why this is NOT a permit (the key distinction)
| | [[subscription]] | Validation / sponsorship |
| --- | --- | --- |
| Subject | Known in advance; carries a credential (card/QR/plate) | Anonymous walk-in; identified only by the **ticket they were issued** |
| When applied | At entry (credential opens the lane) | **After entry**, against an existing session — at a pay station, by a code, or by a sponsor rule |
| Who pays | The subscriber, out-of-band | A **third party** (merchant/sponsor), or nobody (comp) |
| Model fit | `permits` + credentials | New: a **validation event** on a session + a **sponsor account** |
A permit bypasses tariff computation; a validation **adjusts the computed fee** (or zeroes it). They
compose — but they are different primitives.
## Two economic models (both real)
- **Prepaid** — merchant buys a pool of value up front (legacy `BA_Cupons`: printed single-use codes
worth `DiscMinutes`; City Center research: merchant pre-buys time tickets 15 min→all-day).
Reconciliation = count used codes against the pool.
- **Postpaid** (the asked-for case) — merchant signs an agreement; their customers park free or
discounted; the system **accrues each validation against a sponsor balance** and **invoices monthly**
(City Center: "billed for the number of tickets validated each month," verified 3-0). No money moves
at the lane.
The legacy system did **only prepaid coupons** — **the postpaid sponsor account is net-new** for this
project.
## Modifier types (extends [[validation-discounts]])
The discount-type enum lives in [[validation-discounts]]; legacy `DiscType` (smallint) and research
(Amano McGann / HUB J4M, abstained-not-refuted) confirm the set: `comp` / `percent` / `fixed` /
`time-credit` (legacy `DiscMinutes`) / `rate-switch`. **Sponsorship adds one field** to a validation:
a `sponsorId`. Full-comp + a sponsor = the "free entry/exit, bill the business" case.
## The sponsor-liability consequence (anti-fraud)
The validation is a signed event ([[validation-discounts]], [[append-only-event-chain]]); what
**sponsorship** adds is that **free-to-the-parker is not free-to-the-ledger** — it is a *receivable
from the sponsor*. Under the [[threat-model|operator-as-adversary]] model:
- A postpaid sponsor's "enter/exit freely" still **mints signed entry + exit events** (and snapshots)
— the audit trail is identical to a paying car; only the **settlement target** differs.
- The **sponsor's period liability = the sum of `sponsorId`-tagged validation events** over the
period — derivable from the chain, reconcilable like a [[shift|shift Z-report]] and visible to
[[reconciliation]] (a sponsor comping far more than plausible footfall is an anomaly).
## Proposed data shape (illustrative — design only)
```
sponsors id, name, contact, mode {prepaid|postpaid},
balance_minor (prepaid pool / postpaid accrual), billing_period, active
validations id, session_id, sponsor_id?, type, amount_minor|minutes,
code?, operator_id, created_at // append-only; one row per application
(coupons) code, value_minutes|minor, single_use, used_at? // prepaid pool, optional
```
- A **postpaid** sponsor: each full-comp validation appends a row and accrues `amount` to the
sponsor; monthly invoice = sum over the period; exit is free at the lane.
- **Free entry/exit "freely"**: either the sponsor issues credentials (then it's closer to a
[[subscription]] — pick that path), or customers take a normal ticket and a sponsor rule / merchant code
comps it at exit. The agreement wording decides which; **both are expressible.**
## Reconciliation & settlement
- **Prepaid**: pool decrements; alert at low balance; no invoice.
- **Postpaid**: accrue; **monthly statement** per sponsor (legacy/City Center cadence ~the 10th).
Statement lines trace to signed validation events → disputes resolvable against the chain.
## Open
- **"Enter/exit freely" mechanism**: sponsor-issued credentials ([[subscription]]-like) vs. ticket +
comp-at-exit. Likely offer both; confirm the operator's actual deal shape.
- Prepaid coupon format: printed codes (legacy) vs. QR vs. merchant web-validation portal.
- Who may apply a validation, and the **per-operator cap** (a comp is a fraud vector — bound it and
always sign it).
- Invoicing: in-app statement only, or export for external billing? FX if sponsor bills in another
currency (defer to [[tariff]] FX).
- Partial-stay sponsorship (merchant covers first 2h, parker pays the rest) — `time-credit` or
`rate-switch` covers it; confirm.

Some files were not shown because too many files have changed in this diff Show More