71 Commits

Author SHA1 Message Date
julian 7317042e8d fix(desktop): WS live feed offline — native plugin sends no Origin header
Build desktop / desktop (push) Successful in 4m42s
CI / check (push) Successful in 43s
Release desktop / bundle (push) Successful in 4m43s
Build & push images / images (push) Successful in 2m46s
Login worked after the mixed-content fix, but the live feed 403'd silently:
tauri-plugin-websocket's connect() runs on Tauri's Rust side, not inside the
webview page, so it never auto-attaches Origin the way a browser WebSocket
would — routes/ws.ts's anti-CSWSH check rejects a missing Origin before
auth. platform-ws.ts now sets Origin: tauri://localhost explicitly.

Also fixes a second, independent gap the above alone wouldn't have caught:
komodo/resources.toml's booth Stacks had WS_ALLOWED_ORIGINS= empty in
production despite .env.example documenting it as required for desktop.
Needs a Komodo sync + redeploy to reach a live booth.
2026-09-03 15:35:04 +02:00
julian 439b11d16d fix(desktop): route fetch + WebSocket through native Tauri plugins (mixed-content)
Build desktop / desktop (push) Successful in 4m33s
Build & push images / images (push) Successful in 2m50s
CI / check (push) Successful in 43s
Release desktop / bundle (push) Successful in 5m13s
Fixing VITE_API_BASE got login to build a correct absolute URL, but it still
failed with WebKit's generic "Load failed" — WebKitGTK treats tauri://localhost
as a secure origin, so http://127.0.0.1:3000 (and ws://) from inside it is
blocked as mixed content, a WebKit limitation CSP's connect-src can't override.

Added tauri-plugin-http (genuine fetch() drop-in, wired via a new
platformFetch() in origin.ts, used by api.ts + logger.ts) and
tauri-plugin-websocket (not a drop-in — adapted behind a native-WebSocket-
shaped interface in the new platform-ws.ts so use-live-feed.ts needed no
changes). Both route through Tauri's Rust side instead of the webview's own
fetch/WebSocket. Capabilities scoped to 127.0.0.1:3000/localhost:3000, matching
the existing CSP allowlist.
2026-09-03 14:57:45 +02:00
julian 276b048fa9 fix(desktop): sync tauri.conf.json version to the release tag, stop swallowing install failures
Build desktop / desktop (push) Successful in 4m13s
Build & push images / images (push) Successful in 2m48s
CI / check (push) Successful in 42s
Release desktop / bundle (push) Successful in 4m37s
v0.1.1 was tagged but tauri.conf.json's own "version" field (what Tauri
bakes into the bundle filename/internal version) stayed at 0.1.0 — the
signed binary didn't match what latest.json claimed to describe, so every
update download failed signature verification. desktop-updater.ts's single
catch{} swallowed that identically to "offline", so it looked like nothing
happened at all. release.yml now syncs tauri.conf.json's version from the
git tag before building; the updater now logs a real post-accept failure
instead of silently reverting.
2026-09-03 12:24:04 +02:00
julian faa3265e49 fix(desktop): restore VITE_API_BASE for the desktop build
Build desktop / desktop (push) Successful in 4m17s
CI / check (push) Successful in 42s
Release desktop / bundle (push) Successful in 4m47s
apps/web/.env.production's VITE_API_BASE went empty in 96fd97e to fix the
booth/browser same-origin case, but the desktop build shares that file and
was never given its own override — login broke with WebKitGTK's "The
string did not match the expected pattern." (a relative fetch() URL with
no base, from tauri://localhost). beforeBuildCommand now sets
VITE_API_BASE=http://127.0.0.1:3000 inline for the desktop build only;
verified both builds independently produce the right output.
2026-09-03 12:01:56 +02:00
julian 21bfdce27a fix(release): surface the actual Gitea API error on mirror failure
Release desktop / bundle (push) Successful in 4m24s
The mirror step's release id came back empty on the last real run but
nothing failed loudly — every curl response was swallowed (|| true, or
piped straight to /dev/null), so we had no idea why. Capture HTTP status +
response body on every call and exit 1 with the actual error instead of
silently uploading to a malformed //assets URL with no release id.
2026-09-03 10:50:36 +02:00
julian d3288e29eb fix(release): don't let a grep-not-found kill the script under set -e
CI / check (push) Successful in 42s
Release desktop / bundle (push) Successful in 4m26s
Every REL_ID lookup piped grep -o '"id":...' straight into head/cut with no
guard. Under set -e + pipefail, a Gitea API response with no id (e.g.
"tag already exists" on a retry, or an empty existing-assets list on the
first desktop-latest publish) makes grep exit 1, which aborts the whole
step immediately — before the intended fallback lookup ever runs. Hit on
retrying v0.1.0 after the previous filename fix: the release already
existed from the earlier failed run, and the script died with no output at
all instead of finding it by tag. Guarded every such pipeline with || true.
2026-09-03 10:33:03 +02:00
julian baf7a4a99d fix(release): strip spaces from bundle filenames before upload
CI / check (push) Successful in 44s
Release desktop / bundle (push) Failing after 3m57s
productName "Parking System" produces installer filenames with a literal
space (e.g. "Parking System_0.1.0_amd64.deb"). curl rejected the resulting
asset-upload URL outright on the first real v0.1.0 release ("Malformed
input to a URL function"), before the job ever reached the new
public_releases mirror step. Sanitized on copy into dist/.
2026-09-03 10:26:17 +02:00
julian 885b410e48 chore(desktop): bump version to 0.1.0 for first tagged release
Build desktop / desktop (push) Successful in 4m21s
CI / check (push) Successful in 44s
Release desktop / bundle (push) Failing after 4m1s
Still at the scaffold default 0.0.0 with no v* tag ever cut. Bumping so a
v0.1.0 tag can exercise release.yml (and the new public_releases mirror
step) for the first time.
2026-09-03 10:11:20 +02:00
julian a1f3103a76 fix(desktop): mirror signed releases to public repo for the updater
Build desktop / desktop (push) Successful in 4m46s
CI / check (push) Successful in 43s
The updater endpoint pointed at mca/parking_solution's own Gitea "latest
release" redirect, but that repo is private and field appliances have no
Gitea credentials — every update check was silently failing. release.yml
now mirrors signed installers to mca/public_releases (public, installers
only) under a fixed desktop-latest tag; tauri.conf.json points there.
Rejected embedding a read token in the app instead, given the booth-operator
threat model.

Also: make the appliance-provisioning root_directory gotcha impossible to
skim past (boxed callout + explicit next-step pointers), after it caused a
second missed step on the park-2 install.
2026-09-03 09:56:49 +02:00
julian 0fd66b261a feat(resources): add park-2 booth Stack (stage tier)
Build & push images / images (push) Successful in 2m59s
CI / check (push) Successful in 46s
New real booth, same tier as park-buzi: tracks stage, pinned stage-<sha>,
own park_2_* secret refs. Periphery had the known /etc/komodo root_directory
permission bug on --user install (wiki gotcha #9); fixed and confirmed OK
in Core before adding this Stack.
2026-09-02 18:29:46 +02:00
julian dfc5a07c10 Retire the park-lab stack from art-docker-station
Build & push images / images (push) Successful in 2m56s
CI / check (push) Successful in 44s
That host is becoming a Traefik edge, and parking's prod override brings its own
Caddy on `network_mode: host` holding :80 — the two cannot share the port. The
lab tier moves to a dedicated bench PC rather than contorting either side.

This also names what has been holding :80 on that box: the edge stack deployed
there on 2026-09-01 failed with "address already in use" and the owner was
recorded as unidentified. It was almost certainly this Caddy.

REMOVING THIS BLOCK DOES NOT STOP ANYTHING. The containers keep running and keep
the port. Destroy park-lab from Komodo Core BEFORE syncing this removal:
DestroyStack names a stack and Core resolves where from its own synced copy of
the definitions, so a sync that drops the block first takes the teardown handle
with it. If that has already happened, remove the containers by hand on the host
— there is no compose project context on a Komodo-managed box.

Three Core secrets are now unreferenced: art_docker_station_jwt_secret,
art_docker_station_event_signing_key, art_docker_station_backup_key. Lab keys
with no real ledger behind them, so they are safe to delete once the stack is
gone.

Claude-Session: https://claude.ai/code/session_01SARfPK19vLBstMWBxubezN
2026-09-01 11:33:22 +02:00
julian 5aabd7a791 fix(.gitignore): add questions.txt to ignore list
CI / check (push) Successful in 43s
2026-08-31 12:23:32 +02:00
julian 0e9b9f5d82 fix(resources): drop stale park-lab-old Stack; docs(wiki): Periphery connect_as and upgrade gotchas
Build & push images / images (push) Successful in 3m17s
CI / check (push) Successful in 46s
park-lab-old referenced a server removed from Komodo, breaking the resource
sync. Also documents two Periphery incidents from this session: a Core-UI
rename doesn't touch the agent's own connect_as, and upgrading Periphery is
a config-preserving re-run of the installer.
2026-08-31 12:17:00 +02:00
julian 642c5f4f70 feat(setup): show running build version in the Setup tab bar
Build desktop / desktop (push) Successful in 4m21s
Build & push images / images (push) Successful in 3m6s
CI / check (push) Successful in 42s
CI already computes <branch>-<short-sha> for image tags but never
surfaced it anywhere reachable from the app, so there was no way to
tell what's actually deployed on a booth without cross-referencing
komodo/resources.toml's TAG by hand.

Thread it through: CI passes BUILD_VERSION as a Docker build-arg,
the Dockerfile captures it as a runtime env var, GET /api/version
(gated by the existing site:read permission) exposes it, and the
Setup page's tab bar shows it right-aligned, muted, absent entirely
on a local/dev build with no CI-supplied value.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-08-30 19:01:15 +02:00
julian cb9f4d4979 fix(resources): rename stacks for clarity and consistency
Build & push images / images (push) Successful in 2m50s
CI / check (push) Successful in 43s
2026-08-30 18:46:55 +02:00
julian ea8fe22969 docs(wiki): USB printer cover-open field bug writeup; add art-docker-station lab box
Build desktop / desktop (push) Successful in 5m14s
Build & push images / images (push) Successful in 3m1s
CI / check (push) Successful in 43s
Printer investigation (park-buzi): cover-open on the USB thermal
printer wedges its status offline/faulty, surviving a full reboot,
recoverable only via `docker restart server`. Traced sendRawUsb/
probeUsb end-to-end — no persistent handle in the app layer, so the
leading theory is the container's /dev/usb directory bind-mount
retaining a stale view across the printer's physical re-enumeration.
Not yet confirmed on hardware; documented with repro/confirmation
commands and ranked candidate fixes.

Also registers a new lab bench box, "art-docker-station", as a Komodo
Stack (dev tier, same shape as park-lab, its own isolated secret refs).

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-08-30 18:11:34 +02:00
julian 2910672b5a fix(backup): persist last-success/error status; wall-clock-based schedule
BackupService tracked last-success/last-error as plain in-process fields
and scheduled the daily backup via setInterval measured from process
start — so any server restart (deploy/crash/OOM/reboot, routine under
`restart: always`) silently reset the admin UI to "last successful
backup: Never" and drifted the actual cadence, independent of whether
backups were writing correctly to disk (they were — a real field
incident at park-buzi showed 7 valid rotating backups on disk with the
status stuck on "Never").

Persist last-success/error to new site_config columns (migration 0025)
and add BackupService.isDue(), computed from the persisted timestamp
instead of process uptime; server.ts now polls every 15 min and lets
isDue() gate the actual run. No API/UI contract change.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-08-30 18:11:23 +02:00
julian 3a176c5cc8 docs(wiki): DS-2CD1047G3H-LIU main-stream ISAPI snapshot is a firmware bug — camera line to be replaced
CI / check (push) Successful in 1m1s
Build & push images / images (push) Successful in 3m20s
Full live investigation of the persistent 503 "deviceBusy" on main-stream ISAPI
snapshots (10.0.10.13): ruled out config (byte-identical to a working sibling
model), ruled out firmware age (reproduced on both the original V5.8.11 and
current V5.11.0 builds, ~15 months apart), and ruled out real resource
contention (a full channel-ID sweep shows every ID fails identically except
the one hardcoded working value, including nonexistent channels) — pointing
at a broken/incomplete ISAPI snapshot handler that mislabels itself as
"busy," not a real encoder ceiling.

RTSP main-stream frame-grab was confirmed as a working route around it, but
given the bug and the sub-stream's real-world plate-read accuracy problems,
the owner decided to replace the DS-2CD1047G3H-LIU units rather than carry
an ffmpeg/RTSP dependency to work around vendor firmware. Ingested the
vendor datasheet as a source page along the way.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-08-23 14:50:22 +02:00
julian 19dff97c74 fix(web): permission-degrade the app shell for merchant-only users
Build desktop / desktop (push) Successful in 4m51s
Build & push images / images (push) Successful in 3m8s
CI / check (push) Successful in 52s
A user whose role has only validation:create (the bar/lavazh validator) made
the shell misbehave: useLiveFeed() connected /api/ws unconditionally, the
server's report:read guard 403'd the upgrade, and the capped-backoff
reconnect hammered it forever — a 403 in the server log every few seconds.
Gate the socket on report:read (mirrors routes/ws.ts WATCH_PERMISSION) and
render StatusDot / ShiftButton / DeviceFooter only with their backing
permissions (report:read / shift:read / device:read), so a merchant's shell
is just the nav + their /validate screen, with zero doomed requests.

Claude-Session: https://claude.ai/code/session_01YYkpEsLmoQPaize5ec3oUm
2026-07-13 20:12:43 +02:00
julian 0ed43239c3 bump(resources): update TAG to stage-28bd838 for deployment consistency
Build & push images / images (push) Successful in 3m1s
CI / check (push) Successful in 48s
2026-07-13 20:11:27 +02:00
julian 28bd838696 docs(wiki): merchant validations settled + as-built; scan input decided (camera paths postponed)
Build desktop / desktop (push) Successful in 5m5s
CI / check (push) Successful in 47s
Build & push images / images (push) Successful in 2m59s
validation-discounts: driving cases → the settled validation-only model (all
money/paper at the booth) → setup UX/storage/RBAC → full as-built record.
DECIDED: merchant stations scan with a USB/HID barcode scanner on the
web/desktop app (hand-keying + Luhn as fallback); POSTPONED with analysis:
web getUserMedia scanning (secure-context TLS prerequisite on the LAN +
Code128-via-camera weakness → QR-on-ticket first) and a Tauri v2 Android
merchant app (native ML Kit scanning; Android build/sideload overhead +
configurable-server-URL prerequisite). Also: wsl-dev-networking gains the
mirrored-mode gotcha where a Windows-side listener makes a port EADDRINUSE
inside WSL while invisible to ss — Vite auto-increments and tauri dev's fixed
devUrl waits on the wrong port.

Claude-Session: https://claude.ai/code/session_01YYkpEsLmoQPaize5ec3oUm
2026-07-13 19:50:09 +02:00
julian 692dff5f89 feat(validations): merchant (bar/lavazh) ticket validations end-to-end
In-park merchants discharge customers' parking: a merchant user scans the
ticket on their device (/validate; validation:create + program↔user binding)
and applies their program — comp / first-N-minutes free / amount-off (capped,
typed at scan) / percent. All money stays at the booth: the quote folds live
validations in a canonical order (timeCredit → percent → fixed → comp, net
floors at 0, Σ lines ≡ gross − net), the payment records gross/discount and
CONSUMES the validation ids (an overstay's fresh period never re-applies
them), the receipt prints the gross → lines → net story, and the Z/X-report
carries discountTotalMinor leakage. Every apply/void is a signed, attributed
ledger event (refId = append-only void); program config is /setup/site master
data (Bar/Lavazh checkboxes + right-column panel, tabs when both) whose saves
sign config_change. Migration 0024 + reset-db drift-guard entries; 8 route
integration tests + priceSession fold suite.

See wiki/concepts/validation-discounts.md for the full design record.

Claude-Session: https://claude.ai/code/session_01YYkpEsLmoQPaize5ec3oUm
2026-07-13 19:49:58 +02:00
julian ba7538aeb5 docs(wiki): capture cloud-service SaaS requirements (postponed)
Multi-tenant SaaS layered on the offline model: link-up monitoring of the
signed ledger, device status, financials; one admin → many sites; per-site
secret custody; recurring fee. Records the four tensions, the confirmed
secrets boundary (sync creds + device-password escrow + app identity, NOT
the signing key), and the two in-discussion corrections that stand (NetBird
already solves booth isolation; remote barrier-open is pulseOpen-and-signed,
driven by the unmanned future). status: open, postponed.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-13 14:51:01 +02:00
julian bb365b5d6e fix(booth-pay): entry/exit timestamps read alike (Sot 19:25:44)
The pay modal rendered entry via formatRelativeDateTime (relative day, no
seconds → "Sot 19:25") and exit/now via the legacy formatTime (raw
HH:MM:SS, no day → "19:25:44") — inconsistent on both day context and
seconds. Added a { seconds } option to formatRelativeDateTime and routed
all four call sites (entry, exit, live now, alreadyClosed toast) through
it, so every row reads "Sot 19:25:44". Removed formatTime — the last raw
toTimeString() helper and the source of the mismatch; BoothPayModal was
its only caller.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-11 10:38:12 +02:00
julian c52a42dad2 fix(resources): update TAG to stage-22544ec for deployment consistency
Build & push images / images (push) Successful in 2m52s
CI / check (push) Successful in 44s
2026-07-10 08:50:51 +02:00
julian 22544ecf63 docs(wiki): log-storm hardening + reset drift guard (2026-07-07 incident)
Build desktop / desktop (push) Successful in 4m37s
CI / check (push) Successful in 42s
Build & push images / images (push) Successful in 2m51s
button-light-indicator: failure backoff + rate-limited logging rationale;
app-logs: storm coalescing invariant + --diagnostics wipe; local-dev-workflow
and appliance-provisioning §7d: new reset flag table + drift guard; log entry
tying all three layers to the ENETUNREACH incident.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-10 08:29:59 +02:00
julian ba5b4b1f4e fix(reset-db): app_logs + tariff_drafts were uncategorized — add a drift guard
Both tables belonged to NO reset category and silently survived every
reset, --all included (the hand-maintained table list lagged the schema
twice). app_logs gets a new --diagnostics category; tariff_drafts joins
--config. A drift guard now compares the category union against
sqlite_master before doing anything and refuses on any uncategorized
table, so the next new table forces a deliberate one-line decision instead
of escaping by omission. Verified on a scratch DB: guard refuses a planted
table (exit 1), --all lists both new tables, --diagnostics wipes app_logs.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-10 08:29:52 +02:00
julian 51b160bfc9 feat(logs): coalesce repeated identical lines into one row (×N badge)
A line identical to the last persisted row (level+source+message+path)
within a 5-min refreshing window updates that row — context._repeat counts
the fold, _firstAt keeps the first occurrence, createdAt tracks the latest
so the storm stays at the top of the newest-first viewer. A continuous
storm stays ONE row however long it rages, so it can't evict unrelated
history via the 50k row cap or grind the appliance disk. LogsViewer badges
coalesced rows ×N (tooltip: count + first occurrence, sq/en). In-memory
last-row cache only; a pruned-under-us row falls through to a fresh insert.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-10 08:29:43 +02:00
julian e2d5105da2 fix(button-light): back off failed setAux sends — kill the ENETUNREACH hot loop
An unreachable controller rejects the UDP send instantly, and #pump's
failure re-pump retried inline: a tight loop logging hundreds of identical
errors per minute (park-buzi, 2026-07-07). Failed sends now arm a 1s→30s
exponential retry (reset on success); desiredOn keeps tracking the truth
table meanwhile and the armed retry converges to it. Logging is
rate-limited: first failure of a streak in full, then one summary/minute,
one info line on recovery. #finalOff waives the backoff so the last-gasp
OFF on drop/shutdown still gets an immediate try.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-10 08:29:33 +02:00
julian 5287be5278 docs(wiki): catch-up sweep — five pages lagging the log
Build & push images / images (push) Successful in 2m52s
CI / check (push) Successful in 43s
rongta-printer still named the cashino driver id (→ escpos + migration
0023 note); tariff-time-tiers listed the composer price preview as
deferred (→ delivered by the lab fee breakdown); tariff.md lab section
gained the breakdown + composer increment-guard paragraph; i18n.md now
records the "25 Qer 14:30" date standard + never-toLocaleString-for-
dates rule; fleet-deployment-komodo gained the park-lab stack + tier
table (the park-lab addition had also slipped the log — both fixed).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-07 13:08:55 +02:00
julian 3a85483e6c deploy(park-buzi): pin TAG=stage-6ceaadf (supersedes cd3b534)
Adds on top of the un-deployed cd3b534 pin: camera clock sync via ISAPI
(heals the 1970 power-cut reset at the offline→ready edge + daily
backstop). Everything since the deployed f9887c2 rides along: USB
printer close-cancel fix (hardware-verified at the lab), USB device
dropdown (lp1 shows by model name), printers addable without a
controller. No migrations.

Post-deploy validation: pull a camera's power, let it come back, then
docker logs | grep "clock synced" — expect a warn with a huge drift.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-07 13:08:55 +02:00
julian 6ceaadfbf2 feat(devices): camera clock sync via ISAPI — heal the 1970 power-cut reset
Build desktop / desktop (push) Successful in 4m18s
CI / check (push) Successful in 44s
Build & push images / images (push) Successful in 2m51s
park-buzi field observation: after a power cut the Hikvision cameras
reboot at the 1970 epoch (no/dead RTC battery, no NTP) and stay there
until a human logs into the web UI (which silently pushes the browser
clock) — corrupting the snapshot OSD timestamps (the evidence trail) and
ANPR push times meanwhile.

The host is the site's time authority (offline-first, no NTP infra):

- Device monitor triggers a sync at each camera's offline→ready edge —
  exactly the power-restored moment — plus a 24h backstop; the attempt
  is stamped before the async call so a failing camera retries at
  backstop cadence, never every poll.
- HikvisionCamera.syncClock: GET /ISAPI/System/time; drift ≤60s → leave
  alone; beyond (or unparseable = infinite drift) → PUT timeMode=manual
  with the site wall-clock now WITH explicit utc offset
  (localIsoWithOffset), echoing the camera's timeZone verbatim — correct
  the clock, never fight its tz/DST config.
- Jumps >1h (the power-cut signature) log warn (persisted to app_logs);
  small corrections info. Capability-guarded (isClockSyncable) —
  hikvision only; dahua's CGI has no such endpoint.
- http-digest generalised to digestRequest (GET/PUT/POST + body); the
  handshake was already method-aware. digestGet delegates unchanged.

8 new tests: in-sync no-op, 1970 PUT shape (manual + host instant +
echoed tz), unparseable→sync, failed-set surfaces, dahua non-capability,
DST-both-sides pins on the offset formatter.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-07 12:56:51 +02:00
julian 7f42805e8d bump(resources): update TAG to stage-cd3b534 for deployment consistency
Build & push images / images (push) Successful in 2m51s
CI / check (push) Successful in 42s
2026-07-07 11:57:09 +02:00
julian cd3b534e51 feat(setup): USB printer discovery — pick a real /dev/usb device
Build desktop / desktop (push) Successful in 4m21s
CI / check (push) Successful in 50s
Build & push images / images (push) Successful in 2m54s
The kernel numbers usblp nodes by plug/boot order (park-buzi's printer
is lp1); the wizard hardcoded lp0 in labels/default and the admin had to
shell in and `ls /dev/usb`. Now:

- GET /api/setup/usb-printers enumerates /dev/usb/lpN (visible via the
  compose bind-mount) and enriches each with the printer's self-reported
  make/model from sysfs ieee1284_id (readable through Docker's ro /sys).
- The wizard's devicePath becomes a SELECT of printers actually present
  ("/dev/usb/lp1 — Xprinter XP-K200L"): a fresh form preselects the
  first real device; a saved-but-unplugged path stays selectable,
  flagged "saved — not present now"; zero found falls back to free text
  + a check-the-cable hint.
- Transport option label no longer hardcodes lp0.

Wiki: printer-usb-transport marked HARDWARE-VERIFIED (lab 2026-07-07:
full slip + feed + cut over USB — parity with TCP).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-07 11:35:25 +02:00
julian 011fe5a4c4 fix(devices): USB truncation mode 2 — close() kills the in-flight usblp URB
Build desktop / desktop (push) Successful in 4m16s
CI / check (push) Successful in 43s
Build & push images / images (push) Successful in 2m51s
The chunked-write fix (81bc2e3) still truncated on hardware: the lab
test slip stopped mid-sentence with no feed and no cut (text hidden
until the feed button). Verified against drivers/usb/class/usblp.c:

- write() returns at URB SUBMISSION, not completion;
- only ONE write URB is in flight (the next write EAGAINs until it
  completes);
- usblp_release() — our close() — KILLS in-flight URBs.

The printer drains bulk data at PRINT speed (tiny internal buffer on
these clones), so closing right after the last accepted write cancels
the still-transferring tail — exactly where the feed + GS V cut bytes
live. Kernel-accepted ≠ printer-received.

Fix: the one-URB rule makes acceptance of write N a completion
certificate for write N−1. writeAllUsb now writes the payload's FINAL
BYTE alone — its acceptance proves everything before it is physically
in the printer — then drains 300 ms for that single packet before the
caller closes. New test pins the final-byte-alone chunking; wiki
printer-usb-transport.md carries the kernel-level account.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-07 10:50:47 +02:00
julian 6f3f6ca596 fix(fleet): park-lab stack points at server "park-lab"
Build & push images / images (push) Successful in 2m52s
CI / check (push) Successful in 43s
The lab box's Periphery onboarded as park-lab (the earlier park-test
name was from the first, discarded install attempt).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-07 10:06:47 +02:00
julian 5443b910c6 feat(fleet): add park-lab stack (lab bench, dev tier)
Build desktop / desktop (push) Successful in 4m15s
Build & push images / images (push) Successful in 2m52s
CI / check (push) Successful in 43s
Second [[stack]] block: server park-test (the lab box's Periphery
connect_as), compose files from the dev branch, MOVING TAG=dev (a lab
may float; real booths pin), its own park_lab_* secret refs (per-box
blast radius — never shared with a real booth). park-buzi is untouched
on stage + pinned.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-07 10:04:08 +02:00
julian a02957034d fix(web): setup allows adding a printer with no controller configured
Second half of the printer/relay decoupling: the category section's
add-button gate ("add a controller first — a printer points at one of
its relays") blocked every non-access category while zero controllers
existed — hit on the lab bench (USB printer test, no relays on hand).
Printers don't bind (role + failoverRank route jobs), so the gate now
exempts them like the form's requirement already does.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-07 09:54:59 +02:00
julian ee61c24bb9 docs(wiki): Periphery v2.2.0 --user installer defaults root_directory=/etc/komodo
Lab box (park-test) crash-looped: panic writing the agent key to
/etc/komodo/keys/periphery.key (Permission denied). Gotcha #9 was framed
as a hand-config hazard; v2.2.0's installer now writes the system-style
default even with --user. §7a: verify root_directory after every
install + sed fix + reset-failed/restart; user-unit vs sudo note; the
onboarding key survives a pre-connect crash.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-07 09:34:00 +02:00
julian 3a186d29df docs(wiki): runbook §5c uses gpasswd -d — deluser rejects hyphenated users
Build & push images / images (push) Successful in 3m14s
CI / check (push) Successful in 43s
Demoting the operator on park-buzi failed with "sanitize_string: invalid
characters in 'park-operator'" — Ubuntu's perl adduser/deluser tooling
rejects the hyphenated username. §5c now prescribes gpasswd -d for
sudo/lxd/lpadmin (shadow-suite, no perl sanitize) and documents that
group removal lands at NEXT login: the auto-login operator session keeps
its old memberships until reboot/relog, so verify `groups` from inside
the session afterwards.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-07 08:55:51 +02:00
julian 827445d514 deploy(park-buzi): pin TAG=stage-f9887c2 (supersedes d905dd1)
Carries since the deployed d905dd1: reports dashboard (occupancy curve,
hour×dow heatmap, stay histogram, fraud KPIs), USB printer chunked-write
fix (barcode + cut over usblp), driver rename cashino→escpos (migration
0023 rewrites device rows on boot), setup wizard printer-binding fix,
composer published-versions sidebar + increment-unit guards + currency-
scaled examples, lab fee breakdown, UI-wide "25 Qer" date standard,
camera health-check log bucketing, seed-admin role self-heal + signed
ledger event, Z-report label wording.

Post-deploy on-site: switch the ICS printer's driver to the generic
ESC/POS entry if still on rongta; USB print test (barcode + cut).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-07 08:55:51 +02:00
julian f9887c2a76 fix(server): seed-admin self-heals the admin role + signs a ledger event
Build desktop / desktop (push) Successful in 4m28s
CI / check (push) Successful in 44s
Build & push images / images (push) Successful in 2m59s
Field failure on park-buzi: reset-db --users wipes the roles table and
points to seed-admin — which inserted the user with roleId "admin"
without recreating the role row (migration 0007 never re-runs), dying on
the role_id FOREIGN KEY. The script now upserts the built-in admin role
first (the row alone suffices — admin permissions resolve in code).

It also appends a SIGNED config_change (admin.passwordReset /
admin.seeded, operator console:seed-admin) via the server's compiled
EventLog + signer: a console seed/reset by the Linux admin can't be
gated by the app, but it stays attributable in the chain. Best-effort —
no build/signing key warns loudly and proceeds (locking an admin out to
protect an audit line would invert the priority). Both paths verified
against a scratch DB reproducing the post-reset state.

Runbook: appliance-provisioning §7e — lost app-admin password reset via
FORCE=1 (interactive preferred; sessions not revoked → rotate JWT_SECRET
if theft suspected); §7d notes the FK failure + self-heal.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-06 15:41:40 +02:00
julian 7649b897c4 feat(tariff): lab explains the sum — fee breakdown from the engine walk
"ALL 740 / 3h 2m" gave no derivation. explainFee in @parking/shared runs
the EXACT computeFee walk with an optional trace collector — one code
path, so Σ line items ≡ the amount by construction (golden V1 regression
byte-identical; instrumentation changes no fee). Items: contiguous
same-price increment runs (time window · N × unit · tier-card name),
window-package occurrences, stepped day totals (top-tier repeat
flagged), daily-cap clamps as NEGATIVE adjustments, entry grace.

/api/tariff/simulate returns `breakdown` (null when settled); the lab's
Outcome panel renders the lined table with a rounding note (raw min →
billed min at the increment — answers "why does 3h 2m bill as 4h") and
a total row. Works against active/historical versions and drafts alike,
so a night-package draft can be verified line by line before publish.
Largely delivers the wiki's open "composer price preview" item.

4 new engine tests pin the sum invariant + item shapes (97 shared green).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-06 15:41:40 +02:00
julian ab968eb25e feat(web): composer states the billing unit — the 60→10 price trap closed
Ladder/flat prices are PER BILLING INCREMENT, but the form said only
"Çmimi / interval" — so changing the increment 60→10 silently multiplied
every price ×6 (operator walked into it). Now:

- Price headers name the real unit live: "Çmimi / orë" at 60,
  "Çmimi / N min" otherwise (flat-mode radio label likewise).
- Amber warning whenever the increment ≠ 60: every price below is
  charged per started N minutes, NOT per hour.
- Per-row "= X / orë" equivalence next to each ladder/flat price when
  the tick isn't an hour — the multiplication nobody should do mentally.
- Example defaults are currency-scaled: ALL gets 200/100 ladder, 200/500
  up-to, 2000 lost ticket (the old "2.00/1.00" euro-scale examples read
  as 2 lekë/hour); EUR/USD keep 2/1/5/20. Threaded through empty forms,
  new tier rows, and mode-switch templates alike.

Band DURATIONS stay in hours — real wall time, increment-independent.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-06 15:41:40 +02:00
julian 5e1a885dcb feat(web): one date standard across the UI — "25 Qer 14:30"
Dates were a mix: catalog-formatted "25 Qershor 20:01" where screens used
formatRelativeDateTime, and browser-locale "7/6/2026, 9:34 AM" in ~20
places that called raw toLocaleString/-Date-/-Time-String. Unified:

- common.monthsShort in both catalogs (Jan/Shk/…/Qer/Korr/…/Dhj);
  formatDate ("25 Qer", year only when not current), formatDateTime
  ("25 Qer 14:30", optional seconds), formatClock ("HH:mm", 24h) in
  lib/format.ts. formatRelativeDateTime keeps Sot/Dje and switches its
  older-dates branch to the same short months.
- Every raw toLocale* DATE call swept: shifts X-report line, plan
  effective dates, sub version labels, drawer today feed, snapshot
  tooltips, device footer checkedAt, event-detail timestamp (keeps
  seconds — chain evidence), tariff composer active-since + version
  sidebar. Number toLocaleString (thousand separators) untouched.

The catalogs in this commit also carry the keys for the two follow-up
commits (fee breakdown, composer increment labels).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-06 15:41:40 +02:00
julian 6cf3492bff chore(shift): Z-report slip label wording (Albanian)
Operator-adjusted labels on the printed Z-report: "Gjëndje fillestare"
for the opening float, aligned "Abonime"/"Jashtë orarit" rows.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-06 12:36:14 +02:00
julian ffe8c13a1c fix(web): setup wizard no longer forces printers to bind to a barrier
"Cilën barrierë shërben kjo pajisje?" is load-bearing for readers and
cameras (which barrier a scan opens + inherited direction) but nothing
consumes it on a printer — print routing is role + failoverRank
(printer-routing.ts). The wizard applied the requirement to every
non-controller device, so adding a printer demanded a meaningless relay
pick that got stored as dead config.

Printers are now exempt: no requirement, the binding panel is hidden,
the binding is not persisted (a stale pre-fix one drops off on next
edit), and the device list shows the printer's ROLE instead of a bogus
amber "unbound". Server never validated it — no API change.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-06 12:36:14 +02:00
julian fcea992e1e refactor(devices): rename driver "cashino" → "escpos" (generic ESC/POS)
The reachability-only clone driver carried its first unit's vendor name,
which read as misleading in the setup UI once other clones (ICS/Xprinter
XP-K200L, verified 2026-07-06: no /prn_stat.htm) used it. It was always
the generic ESC/POS driver — now named so:

- printer-cashino.ts → printer-generic.ts; GenericEscposPrinter;
  id "escpos", label "Generic ESC/POS 80mm printer (Cashino,
  ICS/Xprinter…)".
- Migration 0023 rewrites stored devices.driver_id rows.
- The registry keeps a PERMANENT cashino→escpos alias so restored
  pre-rename backups still resolve instead of "unknown driver".

Prose mentions of the Cashino as physical hardware stay — it's a real,
verified-fit printer; only the driver identity stopped being vendor-named.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-06 12:36:14 +02:00
julian 81bc2e357c fix(devices): USB printing dropped the job tail — chunked write loop
Field bug (ICS XP-K200L over USB): text printed, barcode + cut missing;
same bytes over TCP fine. sendRawUsb did ONE write() on an O_NONBLOCK
usblp fd and never checked bytesWritten — the kernel accepts only what
fits the printer's ~8 KB USB buffer and returns a short write, so the
tail of any job bigger than one buffer (the barcode mid-payload, the cut
at the end) was silently discarded. The regular-file test stand-in can't
short-write, which is why tests never caught it.

writeAllUsb now pushes 4 KB chunks until every byte is accepted,
continues after partial writes, retries EAGAIN/zero-byte with a short
pause, and fails at the deadline with an (N/M bytes) diagnostic. Driven
by fake-handle tests (short writes, EAGAIN interleave, wedged-printer
timeout, non-EAGAIN passthrough).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-06 12:36:14 +02:00
julian 7ef332999e feat(reports): occupancy curve, hour×dow heatmap, stay histogram, fraud KPIs
The dashboard had generic BI views but nothing parking-shaped. Added:

- Occupancy step-area over the range with the configured capacity as a
  red reference line. occupancyStart folds the ENTIRE prior ledger
  (voided entries excluded, clamped ≥0); each series point carries
  occupancyEnd. Answers "when are we near full".
- Entries heatmap hour × day-of-week (7×24, row 0 = Monday, site tz) as
  a pure CSS-grid intensity map — weekday-vs-weekend at a glance, the
  direct evidence for tariff windows. Replaces the flat hour histogram
  (strictly contains it).
- Stay-duration histogram at tariff-shaped edges (30m/1h/2h/4h/8h/24h/
  tail): where ladder/up-to breakpoints should sit.
- Voids + anomalies KPIs (accented when >0) — the look-closer counters
  the signed chain exists for; peak-occupancy KPI (peak / capacity).
- Revenue bars stacked cash vs card (the drawer's money vs the bank's);
  CSV export gains cash, card, occupancy_end columns.

Internals: localParts caches its Intl formatter per tz (was one new
formatter per ledger row); @parking/db re-exports lt/gt. 5 new tests.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-06 12:35:54 +02:00
julian a2e102f3dd bump(resources): update TAG to stage-14638c2 for deployment consistency
Build & push images / images (push) Successful in 2m53s
CI / check (push) Successful in 42s
2026-07-05 16:53:32 +02:00
julian 14638c2e13 docs(wiki): industry survey of parking tariff systems + session log
Build desktop / desktop (push) Successful in 4m14s
CI / check (push) Successful in 43s
Build & push images / images (push) Successful in 2m49s
New reference page tariff-industry-survey.md (2026-07 web research):
field taxonomy — per-started-increment hourly (per-minute tried and
rolled back in practice), degressive ladders, day caps, up-to matrices,
day tickets, evening/overnight packages, event rates, early bird
(entry-time-conditioned), day/night + weekend/holiday/seasonal windows,
category pricing, contracts, merchant validations (amount/percent/
time-credit/re-rate), SFpark-style dynamic pricing. Coverage map: our
engine expresses everything a staffed single lot advertises; real gaps =
early bird (the pick-table-by-entry-time future design, same mechanism
as weekend menus) and validation overlays; anti-features = per-minute
billing + dynamic pricing. Indexed + logged.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-05 16:37:53 +02:00
julian 4f902d869e feat(web): published-versions sidebar on the composer page
The lab redesign gave only the lab tab the published-history sidebar;
the composer page was expected to have it too. /setup/tariff now lists
every published version (name or effective date, active badge, currency)
on the right; clicking one loads it into the editor as the SEED for the
next publish — which always creates a new immutable version (the sidebar
hint states this), making "roll back to last month's prices" a two-click
republish while the history stays append-only.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-05 16:37:53 +02:00
julian a5e54a8b93 fix(devices): bucket camera health-check detail — stop per-frame log/status churn
The device monitor logs + re-emits a status only when state OR detail
changes, but the camera probe's detail was the exact snapshot byte count,
which differs on every JPEG frame — so healthy cameras "changed" on
nearly every poll, writing a log line + websocket event each time
(inflating the freshly budgeted container logs). The detail is now a
stable power-of-two bucket ("snapshot ≈16 KB" / "≈256 KB") that moves
only on a real shift (stream/resolution change); an empty-ish 200 body
is flagged as "<1 KB" rather than bucketed away. Failure details
(auth/HTTP/timeout) unchanged. 3 tests pin the no-flap behavior.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-05 16:37:53 +02:00
julian 0180394c45 bump(resources): update TAG to stage-d905dd1 for deployment consistency
Build & push images / images (push) Successful in 2m53s
CI / check (push) Successful in 43s
2026-07-05 16:09:35 +02:00
julian d905dd19b4 Merge branch 'dev' into stage
Build & push images / images (push) Successful in 2m52s
CI / check (push) Successful in 42s
2026-07-05 15:56:39 +02:00
julian c5ed3f1308 feat(drawer): drawer hub — balance now, this-shift figure, daily activity, shift history; busy spinners
Build desktop / desktop (push) Successful in 4m12s
Build & push images / images (push) Successful in 2m53s
CI / check (push) Successful in 41s
/drawer was record + review only: no current balance, no sight of the open
shift's incomings, no daily activity, no shift history. Rebuilt as a hub:

- Drawer now: the till's running balance (new GET /api/drawer/balance,
  shift:read — exposes the service's existing drawerBalance(); the drawer
  is one site-wide till, same exposure the X-report already had) with the
  open shift's X-report breakdown alongside (float + takings + vouchers =
  expected = balance) and a "This shift: ±X" figure (expected − opening
  float — the shift's own contribution vs what it inherited).
- Today's cash activity: every cash payment + voucher since local
  midnight from the signed chain, live, with day totals (card never
  enters the till).
- Record + movements/review: the 2026-07-01 flow, unchanged.
- Closed shifts: drawer-focused history via the scope-aware /api/shifts
  (float → takings ± vouchers → expected per shift).

Also: every shift open/close button (header, /shifts, pay modal, end-
shift confirm) now shows an animated spinner + dims while busy — the old
label-swap-only feedback read as a dead click when a shift open ran slow.
The slowness itself (drawer/shift reads fold the WHOLE chain, O(chain))
is recorded as an open item in wiki/concepts/shift.md with the fix
sketch: fold from the last z-report's signed expectedDrawerMinor forward.

No new ledger surface — one read-only endpoint; RBAC test added.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-05 15:56:30 +02:00
julian 0b7eb28dfa Merge branch 'dev' into stage
Build & push images / images (push) Successful in 2m50s
2026-07-05 15:24:09 +02:00
julian d5ff2097bd feat(web): currency becomes a closed select (ALL / EUR / USD)
Build desktop / desktop (push) Successful in 4m13s
Build & push images / images (push) Successful in 2m51s
CI / check (push) Successful in 41s
Currency was free text in the tariff editor (composer page + lab draft
modal — shared form) and the subscription plan editor; a typo could
publish an unknown code onto immutable versions. Both now offer a closed
select from lib/currencies.ts. An out-of-set code already stored on an
old record is appended as an extra option so it displays + round-trips
unchanged. Blank tariff form defaults to ALL (was EUR) — the site's
actual currency.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-05 15:23:29 +02:00
julian 1de209be48 fix(shifts): operator filter — select over real operators, no more focus loss
The admin operator filter was a free-text input that broke three ways at
once: its visibility hangs off the query response (scope === "all") and
its value is part of the query key, so every keystroke started a new
query, data went undefined for the round-trip, and the input UNMOUNTED
mid-keystroke (lost focus, list blanking that read as a page reload).
Filtering also silently failed — the server matches the operator by
exact username, so partial text matched nothing.

- keepPreviousData on the shifts query: previous data (and scope) stays
  live during refetch, so filter controls never unmount and the list
  never blanks on preset/filter changes.
- The filter is now a <select> of operators that HAVE shifts: the server
  returns the distinct list (signed z-reports + the open shift's holder)
  on GET /api/shifts, admin scope only — operators still can't see other
  names. Exact match by construction.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-05 15:23:29 +02:00
julian dc2cdc0a91 feat(web): self-host Chakra Petch as the app's primary face
The booth is an offline appliance — no webfont CDN — so the font ships
from public/fonts/chakra-petch: latin subset (covers en + sq ë/ç), the
weights the UI actually uses (400/600/700 + 400 italic, ~40 KB total),
SIL OFL license alongside the files. Chakra Petch leads all four family
tokens (mono/display/ui/body) with the previous stacks kept as fallback;
index.html preloads the two everywhere-weights so first paint doesn't
flash the fallback. Not a true monospace — .num/.tabular still request
tabular figures and columns verified aligned in the built app.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-05 15:23:15 +02:00
julian fd9885e9ec feat(tariff-lab): DB-backed draft tariffs + named published versions
Experimenting used to mean publishing — churning the immutable version
history and risking real tickets pricing against a half-baked card while
the admin iterated. The lab is now a true sandbox:

- tariff_drafts table (migration 0021): MUTABLE by design — the one
  exception to "editing publishes a version"; a draft prices nothing and
  signs nothing. Drafts are validated + tz-stamped on save exactly like a
  publish, so a saved draft always simulates and never fails at publish.
- CRUD under /api/tariff/drafts (list tariff:read, mutations
  tariff:update); publishing a draft goes through the normal immutable
  POST /api/tariff/versions path.
- Lab UI rebuilt: sidebar lists lab drafts AND the full published history
  (click any to price against it); main pane cut to pure entry/exit
  (ticket loader, payment, category inputs dropped); the composer form is
  extracted to TariffEditorForm.tsx and reused in a modal (new drafts
  prefill from the active card); per-draft Publish with confirm.
- tariff_versions.name (migration 0022): optional label stamped at
  publish — carried from the lab draft, or typed in the composer's new
  optional field — so history reads "Winter 2027", not UUID prefixes.
- Includes the composer UI + sq/en labels for the package mode (engine
  landed in d9e6c13) and the "Flat price / hour" relabel.

5 new server integration tests (RBAC, roundtrip, validation, tz-stamp +
simulate + publish w/ name); server suite 288 green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-05 14:31:42 +02:00
julian 52a89bfa56 feat(web): move tariff lab under /setup/tariff as a sub-tab
The lab lived at /subscriptions/tariff-lab — the wrong neighborhood for a
tool that tests the rate card. /setup/tariff is now a small layout with
two sub-tabs (composer at the index, lab at /setup/tariff/lab) behind the
existing tariff:read gate. Old URLs (/subscriptions/tariff-lab and the
original /setup/tariff-lab) redirect, and the tariff-read-only redirect
branch on /subscriptions is gone with the tab.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-05 14:31:32 +02:00
julian d9e6c13831 feat(tariff): whole-window package pricing mode (packageMinor)
A windowed card can now charge ONE total for any presence in its window —
the real night rate ("20:00–07:00 = 400, leave earlier and it's still
400"), which the per-increment flatMinor could not express (park-buzi's
"night 400" card billed 400/HOUR). Engine charges once per contiguous run
of increments the card wins, tracked across rolling-day segments so a
night crossing the 24h boundary charges once; out-of-window increments
price by the base card as usual.

Operator decisions (2026-07-05): per-occurrence repeat (two nights = two
charges), any-touch-pays-full, windowed cards only (a base "price per
day" is a 1-row up-to table). Validator: mutually exclusive with
flat/blocks/steps, no per-card cap, forbidden on the defaultCard.
flatMinor docs clarified as PER INCREMENT. 6 new engine tests.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-05 14:31:25 +02:00
julian 493210bbb0 Merge branch 'dev' into stage
Build & push images / images (push) Successful in 2m48s
2026-07-05 10:41:04 +02:00
julian a9f18be700 feat(logging): extend log retention to 60 days and update log level options
Build & push images / images (push) Successful in 2m51s
CI / check (push) Successful in 40s
2026-07-05 10:40:42 +02:00
julian 72ad504b8d deploy(park-buzi): pin TAG=stage-365b648 (supersedes 93f9ebe, adds anpr do-while fix)
Build & push images / images (push) Successful in 2m50s
Same payload as the 93f9ebe pin (camera press-gate + cooldown backstop +
duplicate-plate anomaly, reader channel tagging + phantom feed filter, log
rotation/format) plus the anpr poll-loop do-while fix (at least one analyze
attempt per detection). Code-only — no migration.

Manual Komodo step: refresh ResourceSync → Execute → Deploy. Then the
vendor-tool reader session (prefixes Q:/K:, Card Input format 8H, symbology
cut) — server first, readers second.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
(cherry picked from commit 5cdf8f227b)
2026-07-05 09:21:00 +02:00
julian 5cdf8f227b deploy(park-buzi): pin TAG=stage-365b648 (supersedes 93f9ebe, adds anpr do-while fix)
Build & push images / images (push) Successful in 3m7s
CI / check (push) Successful in 42s
Same payload as the 93f9ebe pin (camera press-gate + cooldown backstop +
duplicate-plate anomaly, reader channel tagging + phantom feed filter, log
rotation/format) plus the anpr poll-loop do-while fix (at least one analyze
attempt per detection). Code-only — no migration.

Manual Komodo step: refresh ResourceSync → Execute → Deploy. Then the
vendor-tool reader session (prefixes Q:/K:, Card Input format 8H, symbology
cut) — server first, readers second.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-05 09:20:58 +02:00
julian 365b648282 Merge branch 'dev' into stage
Build & push images / images (push) Successful in 2m50s
2026-07-04 20:17:00 +02:00
julian c03ef2a34b fix(anpr): guarantee at least one analyze attempt per vehicle detection
Build & push images / images (push) Successful in 2m49s
CI / check (push) Successful in 40s
CI flake root cause (Gitea runner, anpr-entry.test.ts "records an advisory
anpr-skip"): the poll-until-confident loop was a plain
`while (Date.now() < deadline)` — zero iterations were possible when the
window elapsed between deadline-set and loop-entry (the tests run a 5ms
window; a slow runner loses that race). Zero attempts → no frame analyzed →
"gave up" → no anpr-skip row → assertion fails. Not a regression: nothing in
the recent merges touched this path; the race existed since the poll loop
was built.

The invariant is real beyond tests: on a sufficiently loaded booth the old
loop could silently drop a real car's detection the same way. The loop is
now do-while (exit via the existing breaks: confident read, or next tick
past the slid deadline/hard cap), so a detection ALWAYS analyzes at least
one frame.

New regression test forces ANPR_POLL_WINDOW_MS=0 (the CI scenario, made
deterministic) and asserts exactly one capture attempt + the recorded skip.
Suite 283 green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-04 20:16:52 +02:00
julian bafa3282c7 deploy(park-buzi): pin TAG=stage-93f9ebe (press-gate + reader hardening + logging)
Build & push images / images (push) Successful in 2m52s
CI / check (push) Successful in 41s
Carries: camera press-gate + cooldown backstop + duplicate-plate anomaly
(b4f1418), reader channel tagging + structural phantom filter (43c1f45),
log rotation/format (c21babf). Code-only — no migration; boot log should
pass straight through [migrate] done. The compose logging-option change
forces container recreation, which the Komodo deploy does anyway.

Deploy is the manual Komodo step: refresh ResourceSync → Execute → Deploy.
Reminder: deploy server BEFORE the vendor-tool reader changes (prefixes
Q:/K:, Card Input format 8H, symbology cut).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-04 20:03:01 +02:00
125 changed files with 9163 additions and 1232 deletions
+2
View File
@@ -92,6 +92,8 @@ jobs:
context: .
file: apps/server/Dockerfile
push: true
build-args: |
BUILD_VERSION=${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
tags: |
${{ env.REGISTRY }}/parking-server:${{ steps.meta.outputs.branch }}
${{ env.REGISTRY }}/parking-server:${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
+135 -12
View File
@@ -1,12 +1,24 @@
name: Release desktop
# Build the signed Tauri desktop installers on a version tag and publish them as
# a Gitea Release. The Tauri auto-updater (apps/web/src/lib/desktop-updater.ts)
# fetches these; latest.json + each installer + its .sig are what it needs.
# a Gitea Release — TWICE: once on this (private, source) repo for our own
# records/history, and once mirrored to mca/public_releases, which is what the
# Tauri auto-updater (apps/web/src/lib/desktop-updater.ts) actually points at.
#
# WHY a separate public repo: the updater runs on offline-first field appliances
# with no Gitea credentials, so its endpoint + installer downloads must be
# reachable unauthenticated. Mirroring compiled installers to a public
# releases-only repo avoids embedding any read token in the shipped app (which
# would leak the moment a booth PC is compromised — this box's threat model
# names the operator/booth as the primary adversary, see CLAUDE.md). Source
# stays private; only signed installers become public, same as most desktop
# software. mca/public_releases is shared across apps in the org, not
# parking-specific — namespace release tags/asset names accordingly if another
# app starts publishing there too.
#
# Trigger: push a tag like v0.1.0. The job builds .deb/.rpm/.AppImage, signs them
# with the updater key (Gitea secrets), assembles latest.json, and uploads
# everything to the Release for that tag.
# with the updater key (Gitea secrets), assembles latest.json pointing at the
# MIRROR repo's asset URLs, uploads to both repos, and mirrors the same assets.
on:
push:
@@ -63,6 +75,27 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Sync tauri.conf.json version to the git tag
# tauri.conf.json's own "version" field is what Tauri bakes into the
# bundle filename, the app's internal version, AND the updater's
# "current vs. new" comparison — it is NOT derived from the git tag
# automatically. Hit in v0.1.1: the tag was bumped but this file
# wasn't, so the signed binary + its .sig were still built (and
# named) as 0.1.0 while latest.json (built from TAG below) claimed
# 0.1.1 — the updater found the "update", downloaded a file whose
# signature didn't match what the manifest claimed to sign, and
# silently failed (a separate bug in desktop-updater.ts's error
# handling made this invisible — also fixed). Patch it here so the
# checked-in value is only ever a placeholder for local dev builds;
# a real release's version is always driven by the tag.
run: |
set -e
VERSION="${TAG#v}"
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${VERSION}\"/" apps/desktop/src-tauri/tauri.conf.json
grep '"version"' apps/desktop/src-tauri/tauri.conf.json
env:
TAG: ${{ github.ref_name }}
- name: Build + sign desktop bundle
env:
# Updater signing key (Gitea repo/org secrets). Without these the
@@ -73,30 +106,41 @@ jobs:
- name: Collect artifacts
id: collect
# Gather the installers + their .sig into a flat dist/ for upload.
# Gather the installers + their .sig into a flat dist/ for upload, spaces
# stripped from filenames. productName is "Parking System" (a space), so
# Tauri's bundle output is e.g. "Parking System_0.1.0_amd64.deb" — an
# unescaped space in a filename breaks the later curl asset-upload URL
# ("URL rejected: Malformed input to a URL function", hit on the very
# first v0.1.0 release) AND would land in latest.json's asset url, which
# the updater's plain HTTP GET can't handle either. Rename on copy.
run: |
set -e
BUNDLE=apps/desktop/src-tauri/target/release/bundle
mkdir -p dist
find "$BUNDLE" \( -name '*.AppImage' -o -name '*.deb' -o -name '*.rpm' \
-o -name '*.AppImage.sig' -o -name '*.deb.sig' -o -name '*.rpm.sig' \) \
-exec cp {} dist/ \;
-print0 | while IFS= read -r -d '' f; do
name=$(basename "$f" | tr ' ' '-')
cp "$f" "dist/${name}"
done
echo "Artifacts:"; ls -la dist/
- name: Assemble latest.json
# The Tauri updater fetches a manifest describing the newest version, its
# notes, and per-target {signature, url}. We point the AppImage target at
# this release's asset URL. Adjust the platform keys you actually ship.
# notes, and per-target {signature, url}. The URL points at the MIRROR
# repo (mca/public_releases) — that's the unauthenticated endpoint field
# appliances actually reach; see the workflow header for why. Adjust the
# platform keys you actually ship.
env:
SERVER_URL: ${{ github.server_url }}
REPO: ${{ github.repository }}
MIRROR_REPO: mca/public_releases
TAG: ${{ github.ref_name }}
run: |
set -e
VERSION="${TAG#v}"
APPIMAGE=$(cd dist && ls *.AppImage | head -1)
SIG=$(cat "dist/${APPIMAGE}.sig")
ASSET_URL="${SERVER_URL}/${REPO}/releases/download/${TAG}/${APPIMAGE}"
ASSET_URL="${SERVER_URL}/${MIRROR_REPO}/releases/download/desktop-latest/${APPIMAGE}"
cat > dist/latest.json <<JSON
{
"version": "${VERSION}",
@@ -129,12 +173,12 @@ jobs:
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"draft\":false,\"prerelease\":false}" \
"${API}/repos/${REPO}/releases" || true)
REL_ID=$(printf '%s' "$REL" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
REL_ID=$(printf '%s' "$REL" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
if [ -z "$REL_ID" ]; then
# Release may already exist for this tag — look it up by tag.
REL_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
"${API}/repos/${REPO}/releases/tags/${TAG}" \
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
fi
echo "release id: ${REL_ID}"
for f in dist/*; do
@@ -147,3 +191,82 @@ jobs:
"${API}/repos/${REPO}/releases/${REL_ID}/assets?name=${name}" >/dev/null
done
echo "done"
- name: Mirror release to mca/public_releases (Gitea API)
# This is the release the updater and any human downloader actually use —
# public_releases has no source, only installers, so it can be public
# without exposing this repo. RELEASES_MIRROR_TOKEN is a write:repository
# token scoped for pushing releases into that repo (Gitea's org secrets,
# not exposed to any deployed client).
#
# Publishes to TWO tags there, since public_releases is shared across
# apps in the org and Gitea's "latest release" redirect resolves by
# newest tag on the WHOLE repo (would break the moment another app
# publishes something newer):
# - desktop-<TAG> versioned, permanent — audit trail / rollback.
# - desktop-latest moving — assets deleted + re-uploaded each release.
# This is the fixed URL tauri.conf.json's updater endpoint points at
# (a stable name every appliance can always resolve, regardless of
# what else gets released in this repo meanwhile).
env:
TOKEN: ${{ secrets.RELEASES_MIRROR_TOKEN }}
API: ${{ github.api_url }}
MIRROR_REPO: mca/public_releases
TAG: ${{ github.ref_name }}
run: |
set -e
create_or_get_release() {
local mirror_tag="$1" prerelease="$2"
REL=$(curl -sS -w '\n%{http_code}' -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"${mirror_tag}\",\"name\":\"Parking System ${TAG}\",\"draft\":false,\"prerelease\":${prerelease}}" \
"${API}/repos/${MIRROR_REPO}/releases" || true)
echo "create response (${mirror_tag}): ${REL}"
REL_ID=$(printf '%s' "$REL" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
if [ -z "$REL_ID" ]; then
LOOKUP=$(curl -sS -w '\n%{http_code}' -H "Authorization: token ${TOKEN}" \
"${API}/repos/${MIRROR_REPO}/releases/tags/${mirror_tag}")
echo "tag lookup response (${mirror_tag}): ${LOOKUP}"
REL_ID=$(printf '%s' "$LOOKUP" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
fi
if [ -z "$REL_ID" ]; then
echo "::error::could not create or find release for tag ${mirror_tag} on ${MIRROR_REPO} — see responses above"
exit 1
fi
}
upload_assets() {
local rel_id="$1"
for f in dist/*; do
name=$(basename "$f")
echo "mirroring ${name} -> release ${rel_id}"
HTTP_CODE=$(curl -sS -o /tmp/upload_resp.json -w '%{http_code}' -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary @"${f}" \
"${API}/repos/${MIRROR_REPO}/releases/${rel_id}/assets?name=${name}")
if [ "$HTTP_CODE" -ge 300 ]; then
echo "::error::upload of ${name} failed (HTTP ${HTTP_CODE}): $(cat /tmp/upload_resp.json)"
exit 1
fi
done
}
# 1. Versioned, permanent.
create_or_get_release "desktop-${TAG}" false
echo "versioned mirror release id: ${REL_ID}"
upload_assets "${REL_ID}"
# 2. Moving desktop-latest — delete existing assets first (re-upload
# with the same name 409s otherwise), then re-upload.
create_or_get_release "desktop-latest" false
LATEST_REL_ID="${REL_ID}"
echo "latest mirror release id: ${LATEST_REL_ID}"
EXISTING=$(curl -sS -H "Authorization: token ${TOKEN}" \
"${API}/repos/${MIRROR_REPO}/releases/${LATEST_REL_ID}/assets")
printf '%s' "$EXISTING" | grep -o '"id":[0-9]*' | cut -d: -f2 | while read -r asset_id; do
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
"${API}/repos/${MIRROR_REPO}/releases/${LATEST_REL_ID}/assets/${asset_id}" >/dev/null
done || true
upload_assets "${LATEST_REL_ID}"
echo "done"
+2 -1
View File
@@ -26,4 +26,5 @@ dist/
# Graphify knowledge-graph output (dev tool; generated, not committed)
graphify-out/
parking.sqlite*.bak-*
parking.sqlite*.bak-*
questions.txt
+11 -3
View File
@@ -35,8 +35,16 @@ pnpm --filter @parking/desktop bundle # build the SPA + bundle the desktop app
Requires the Rust toolchain and (on Linux) WebKitGTK 4.1 + libsoup-3 dev libraries. Under WSL2 the
window needs a display (WSLg or an X server).
## Auto-update
Signed updates are built and published by `.gitea/workflows/release.yml` on a `vX.Y.Z` tag, mirrored
to the public `mca/public_releases` repo (this repo is private; the updater runs on offline-first
field appliances with no Gitea credentials, so its endpoint must be reachable unauthenticated —
see that workflow's header and `wiki/decisions/desktop-shell-tauri.md`). The updater config and
signing pubkey live in `tauri.conf.json`; the private signing key is held outside the repo, never
committed.
## Not here (deliberately)
Kiosk lockdown (fullscreen/no-decorations), auto-update, code signing, and launching Fastify from
the shell are out of scope for the scaffold — on the appliance Fastify runs as its own service and
this shell connects to it.
Kiosk lockdown (fullscreen/no-decorations) and launching Fastify from the shell are out of scope for
the scaffold — on the appliance Fastify runs as its own service and this shell connects to it.
+549 -8
View File
@@ -318,6 +318,23 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "chacha20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
"rand_core 0.10.1",
]
[[package]]
name = "chrono"
version = "0.4.45"
@@ -346,10 +363,39 @@ version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
dependencies = [
"percent-encoding",
"time",
"version_check",
]
[[package]]
name = "cookie_store"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
dependencies = [
"cookie",
"document-features",
"idna",
"log",
"publicsuffix",
"serde",
"serde_derive",
"serde_json",
"time",
"url",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
@@ -373,7 +419,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [
"bitflags 2.13.0",
"core-foundation",
"core-foundation 0.10.1",
"core-graphics-types",
"foreign-types",
"libc",
@@ -386,7 +432,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"bitflags 2.13.0",
"core-foundation",
"core-foundation 0.10.1",
"libc",
]
@@ -399,6 +445,15 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
@@ -506,6 +561,18 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "data-encoding"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "data-url"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
[[package]]
name = "dbus"
version = "0.9.11"
@@ -635,6 +702,15 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"litrs",
]
[[package]]
name = "dom_query"
version = "0.27.0"
@@ -721,6 +797,15 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -1034,8 +1119,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
@@ -1057,8 +1144,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 6.0.0",
"rand_core 0.10.1",
"wasm-bindgen",
]
[[package]]
@@ -1209,6 +1299,25 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "h2"
version = "0.4.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap 2.14.0",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@@ -1298,6 +1407,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
@@ -1321,6 +1431,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots 1.0.9",
]
[[package]]
@@ -1341,9 +1452,11 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@@ -1744,6 +1857,12 @@ version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]]
name = "lock_api"
version = "0.4.14"
@@ -1759,6 +1878,12 @@ version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "markup5ever"
version = "0.38.0"
@@ -2178,8 +2303,10 @@ dependencies = [
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-http",
"tauri-plugin-process",
"tauri-plugin-updater",
"tauri-plugin-websocket",
]
[[package]]
@@ -2330,6 +2457,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "precomputed-hash"
version = "0.1.1"
@@ -2398,6 +2534,22 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "psl-types"
version = "2.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
[[package]]
name = "publicsuffix"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
dependencies = [
"idna",
"psl-types",
]
[[package]]
name = "quick-xml"
version = "0.39.4"
@@ -2407,6 +2559,62 @@ dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83"
dependencies = [
"bytes",
"getrandom 0.4.3",
"lru-slab",
"rand 0.10.2",
"rand_pcg",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.61.2",
]
[[package]]
name = "quote"
version = "1.0.45"
@@ -2428,6 +2636,61 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [
"rand_chacha",
"rand_core 0.9.5",
]
[[package]]
name = "rand"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
"chacha20",
"getrandom 0.4.3",
"rand_core 0.10.1",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_pcg"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "raw-window-handle"
version = "0.6.2"
@@ -2503,6 +2766,49 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"cookie",
"cookie_store",
"encoding_rs",
"futures-core",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"mime",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots 1.0.9",
]
[[package]]
name = "reqwest"
version = "0.13.4"
@@ -2616,6 +2922,7 @@ version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [
"web-time",
"zeroize",
]
@@ -2625,7 +2932,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
dependencies = [
"core-foundation",
"core-foundation 0.10.1",
"core-foundation-sys",
"jni 0.22.4",
"log",
@@ -2663,6 +2970,12 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "same-file"
version = "1.0.6"
@@ -2745,7 +3058,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.13.0",
"core-foundation",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
@@ -2885,6 +3198,18 @@ dependencies = [
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "serde_with"
version = "3.21.0"
@@ -2948,6 +3273,17 @@ dependencies = [
"stable_deref_trait",
]
[[package]]
name = "sha1"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest",
]
[[package]]
name = "sha2"
version = "0.10.9"
@@ -2955,7 +3291,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.2.17",
"digest",
]
@@ -3137,6 +3473,17 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "sync_wrapper"
version = "1.0.2"
@@ -3157,6 +3504,27 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.13.0",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "system-deps"
version = "6.2.2"
@@ -3178,7 +3546,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
dependencies = [
"bitflags 2.13.0",
"block2",
"core-foundation",
"core-foundation 0.10.1",
"core-graphics",
"crossbeam-channel",
"dbus",
@@ -3268,7 +3636,7 @@ dependencies = [
"percent-encoding",
"plist",
"raw-window-handle",
"reqwest",
"reqwest 0.13.4",
"serde",
"serde_json",
"serde_repr",
@@ -3367,6 +3735,54 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de22eef34fd78c0da050e748710edd50bf127e651d02ea1b2bfada1523cc5c51"
dependencies = [
"anyhow",
"dunce",
"glob",
"log",
"objc2-foundation",
"percent-encoding",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"toml 1.1.2+spec-1.1.0",
"url",
]
[[package]]
name = "tauri-plugin-http"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7241a0c762649be8fba7dd4cc84684d0e409f26b335a978ef4dd5fe78da74ce6"
dependencies = [
"bytes",
"cookie_store",
"data-url",
"http",
"regex",
"reqwest 0.12.28",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.18",
"tokio",
"url",
"urlpattern",
]
[[package]]
name = "tauri-plugin-process"
version = "2.3.1"
@@ -3393,7 +3809,7 @@ dependencies = [
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest",
"reqwest 0.13.4",
"rustls",
"semver",
"serde",
@@ -3410,6 +3826,26 @@ dependencies = [
"zip",
]
[[package]]
name = "tauri-plugin-websocket"
version = "2.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca243c7f0bf935cd81123e07f82188ccb919b19fbfc74518b947eedc4619bbb"
dependencies = [
"futures-util",
"http",
"log",
"rand 0.9.5",
"rustls",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
"tokio",
"tokio-tungstenite",
]
[[package]]
name = "tauri-runtime"
version = "2.11.3"
@@ -3639,9 +4075,21 @@ dependencies = [
"mio",
"pin-project-lite",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
@@ -3652,6 +4100,22 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-tungstenite"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
dependencies = [
"futures-util",
"log",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tungstenite",
"webpki-roots 0.26.11",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
@@ -3877,6 +4341,24 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tungstenite"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
dependencies = [
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand 0.9.5",
"rustls",
"rustls-pki-types",
"sha1",
"thiserror 2.0.18",
]
[[package]]
name = "typeid"
version = "1.0.3"
@@ -4141,6 +4623,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "web_atoms"
version = "0.2.5"
@@ -4206,6 +4698,24 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.9",
]
[[package]]
name = "webpki-roots"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.2"
@@ -4391,6 +4901,17 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
]
[[package]]
name = "windows-result"
version = "0.3.4"
@@ -4820,6 +5341,26 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zerocopy"
version = "0.8.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "zerofrom"
version = "0.1.8"
+11
View File
@@ -22,6 +22,17 @@ serde_json = "1"
# Auto-update: prompt the operator, download a signed update, relaunch.
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
# HTTP client for the SPA's API/WS calls to the local Fastify server. The window
# runs at tauri://localhost, which WebKitGTK treats as a secure origin — a plain
# http://127.0.0.1:3000 fetch() from inside it is blocked as mixed content (a
# long-standing WebKit limitation, not fixable via CSP). Routing through this
# plugin sends the request via Tauri's Rust side instead of the webview's own
# fetch, sidestepping the browser mixed-content check entirely.
tauri-plugin-http = "2"
# Same mixed-content problem as above, but for the live-feed WebSocket
# (ws://127.0.0.1:3000 from the secure tauri://localhost origin) — HTTP and WS
# are separate browser checks, so this needs its own plugin.
tauri-plugin-websocket = "2"
[features]
# Used by `tauri dev`/CLI for hot-reload of the Rust side.
@@ -6,6 +6,14 @@
"permissions": [
"core:default",
"updater:default",
"process:default"
"process:default",
"websocket:default",
{
"identifier": "http:default",
"allow": [
{ "url": "http://127.0.0.1:3000" },
{ "url": "http://localhost:3000" }
]
}
]
}
+7
View File
@@ -16,6 +16,13 @@ pub fn run() {
// endpoint + signing pubkey live in tauri.conf.json.
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
// Routes the SPA's fetch()/WS calls to the local Fastify server through
// Tauri's native HTTP client — see the Cargo.toml comment on why the
// webview's own fetch() can't reach http://127.0.0.1:3000 directly.
.plugin(tauri_plugin_http::init())
// Live-feed WebSocket — same mixed-content reason as the HTTP plugin
// above, but WS needs its own plugin (separate browser check).
.plugin(tauri_plugin_websocket::init())
.run(tauri::generate_context!())
.expect("error while running the Parking System desktop shell");
}
+4 -4
View File
@@ -1,13 +1,13 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Parking System",
"version": "0.0.0",
"version": "0.1.0",
"identifier": "com.parking.desktop",
"build": {
"devUrl": "http://localhost:5173",
"frontendDist": "../../web/dist",
"beforeDevCommand": "pnpm --filter @parking/web dev",
"beforeBuildCommand": "pnpm --filter @parking/web build"
"beforeBuildCommand": "VITE_API_BASE=http://127.0.0.1:3000 pnpm --filter @parking/web build"
},
"app": {
"windows": [
@@ -41,9 +41,9 @@
},
"plugins": {
"updater": {
"//": "Stable 'latest release' path on Gitea — redirects to the newest tag's latest.json (published by .gitea/workflows/release.yml). The updater GETs this, gets the manifest (platforms.linux-x86_64.{signature,url}), and compares versions. The release is reachable to the appliance only when it's brought online (phone hotspot); offline-first means a failed check is a no-op.",
"//": "Points at mca/public_releases, NOT this (private, source) repo — the updater runs on offline-first field appliances with no Gitea credentials, so the endpoint must be reachable unauthenticated. That repo is public and holds only compiled installers (no source), mirrored here by .gitea/workflows/release.yml. NOT the 'latest release' redirect: public_releases is shared across apps in the org, so 'latest' there could be someone else's release. This URL names our own most-recent tag directly (desktop-vX.Y.Z, bumped by the release workflow each publish) so a newer unrelated app release never shadows ours. The updater GETs this, gets the manifest (platforms.linux-x86_64.{signature,url}), and compares versions. The release is reachable to the appliance only when it's brought online (phone hotspot); offline-first means a failed check is a no-op.",
"endpoints": [
"https://git.infra.msai.al/mca/parking_solution/releases/latest/download/latest.json"
"https://git.infra.msai.al/mca/public_releases/releases/download/desktop-latest/latest.json"
],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgxNzg5RUQ1QkM0Q0FDRjYKUldUMnJFeTgxWjU0Z1RlNmhneDVZQlVVTVZZdGhJTkUxTGdDeGYwQSttZmNKVVp5WEdVMWlBb1YK"
}
+5
View File
@@ -47,6 +47,11 @@ RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
# ---- runtime: slim, non-root ----
FROM node:22-alpine AS runtime
WORKDIR /app
# Set by CI to "<branch>-<short-sha>" (e.g. "stage-28bd838"), matching the same string used
# as the Komodo Stack's TAG (komodo/resources.toml) — so the version shown in the app is the
# same string an admin would look up there. Empty/absent on a local `docker build` (dev only).
ARG BUILD_VERSION=""
ENV BUILD_VERSION=$BUILD_VERSION
ENV NODE_ENV=production
RUN apk add --no-cache libstdc++ # better-sqlite3 native runtime
RUN addgroup -S app && adduser -S -G app app
+35 -1
View File
@@ -16,7 +16,7 @@ import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const bcrypt = require("bcrypt");
const { createDb, users, eq } = require("@parking/db");
const { createDb, users, roles, eq } = require("@parking/db");
const DEFAULT_USERNAME = "admin";
@@ -53,6 +53,14 @@ if (!password || password.length < 8) {
}
const db = createDb();
// Self-heal the built-in `admin` ROLE row. Migration 0007 seeds it once, but the
// training reset (reset-db.mjs --users/--all) wipes the roles table and points here
// to re-seed — without this, the user insert dies on the role_id FOREIGN KEY (field
// failure 2026-07-06). The admin permission SET is resolved in code (auth.ts), so
// the row alone is all the FK needs.
await db.insert(roles).values({ id: "admin", name: "Admin", builtin: 1 }).onConflictDoNothing();
const existing = await db.select().from(users).where(eq(users.username, username)).get();
if (existing && process.env.FORCE !== "1") {
console.error(`user "${username}" already exists (set FORCE=1 to reset the password)`);
@@ -73,4 +81,30 @@ if (existing) {
});
console.log(`created admin "${username}"`);
}
// Record the action into the SIGNED ledger (config_change). A console seed/reset is
// a Linux-admin action the app can't gate — but it must stay ATTRIBUTABLE after the
// fact (the chain is the audit record; whoever holds root can reset a password, they
// can't do it silently). Uses the server's own compiled EventLog + signer from dist/
// (present in the container; in a dev checkout run `pnpm build` first). Best-effort:
// a missing build or signing key WARNS loudly but never blocks the seed — locking an
// admin out to protect an audit line would invert the priority.
try {
const { EventLog } = await import("../dist/event-log.js");
const { buildSigner } = await import("../dist/signer.js");
const log = new EventLog(db, buildSigner());
await log.append({
type: "config_change",
source: "manual",
identity: `user:${username}`,
payload: {
setting: existing ? "admin.passwordReset" : "admin.seeded",
username,
operator: "console:seed-admin",
},
});
console.log("recorded to the signed ledger (config_change)");
} catch (err) {
console.warn(`WARNING: NOT recorded to the signed ledger: ${err.message}`);
}
process.exit(0);
+16
View File
@@ -256,6 +256,22 @@ describe("AnprBridge", () => {
expect((skips[0].detail as { plate?: string }).plate).toBe("ZZ999ZZ");
});
it("analyzes AT LEAST ONE frame even if the poll window already elapsed (loaded host)", async () => {
// Regression for a CI flake (2026-07-04): with a plain `while`, a window that lapsed
// between deadline-set and loop-entry (slow runner; here forced with a 0ms window)
// meant ZERO analyze attempts — the detection was silently dropped ("gave up") and no
// skip was recorded. The do-while guarantees one frame per detection regardless of load.
process.env.ANPR_POLL_WINDOW_MS = "0";
const cam = seedCamera({ anpr: true });
const vision = fakeVision({ plate: "ZZ999ZZ", confidence: 0.97 });
const bridge = new AnprBridge(db, vision, fakeSubFlow(null), silentLogger());
await captureReads(() => bridge.onVehicleDetected(cam));
expect(captureSnapshot).toHaveBeenCalledTimes(1); // the guaranteed first attempt
const skips = db.select().from(deviceEventsTable).where(eq(deviceEventsTable.kind, "anpr-skip")).all();
expect(skips).toHaveLength(1);
});
it("debounces: two vehicle events within the window analyze/emit at most once", async () => {
const cam = seedCamera({ anpr: true });
const vision = fakeVision({ plate: "AA111BB", confidence: 0.97 });
+7 -2
View File
@@ -192,7 +192,12 @@ export class AnprBridge {
const hardCap = Date.now() + this.#pollMaxMs;
let attempts = 0;
try {
while (Date.now() < Math.min(this.#pollDeadline.get(deviceId) ?? 0, hardCap)) {
// DO-while: a detection always analyzes AT LEAST ONE frame, however loaded the
// host — a plain while could zero-iterate if the window elapsed between setting
// the deadline and reaching the loop (seen as a CI flake with the tests' 5ms
// window; on a busy booth it would silently drop a real car's detection). Exit
// is via the breaks below (confident read, or next tick would pass the deadline).
do {
attempts++;
const shot = await camera.captureSnapshot({ direction });
const r = await this.#vision.analyze(shot.bytes, shot.contentType);
@@ -229,7 +234,7 @@ export class AnprBridge {
const effDeadline = Math.min(this.#pollDeadline.get(deviceId) ?? 0, hardCap);
if (Date.now() + this.#pollMs >= effDeadline) break;
await sleep(this.#pollMs);
}
} while (true);
} finally {
this.#polling.delete(deviceId);
this.#pollDeadline.delete(deviceId);
+139
View File
@@ -0,0 +1,139 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { eq, siteConfig } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { BackupService } from "./backup-service.js";
// BackupService previously tracked last-success/last-error as plain in-process fields, so a
// server restart (a fresh BackupService instance, exactly as happens on every deploy/crash/OOM
// reboot under `restart: always`) silently reset the admin UI to "last successful backup:
// Never" — even with valid, correctly-rotating backups already on disk (2026-08-30 field
// incident, park-buzi). These tests exercise the fix: status is read from site_config, so a new
// BackupService instance pointed at the same DB sees the prior instance's last-run outcome, and
// the schedule is wall-clock-based (isDue()) rather than time-since-process-start.
// See wiki/concepts/backup-recovery.md.
const KEY = "a-test-backup-key-that-is-long-enough";
let workDir: string;
let target: string;
beforeEach(() => {
workDir = mkdtempSync(join(tmpdir(), "pk-backup-service-test-"));
target = join(workDir, "target");
process.env.BACKUP_KEY = KEY;
});
afterEach(() => {
rmSync(workDir, { recursive: true, force: true });
delete process.env.BACKUP_KEY;
});
function setTargetDir(db: ReturnType<typeof createTestDb>["db"], dir: string): void {
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
if (existing) {
db.update(siteConfig).set({ backupTargetDir: dir }).where(eq(siteConfig.id, 1)).run();
} else {
db.insert(siteConfig).values({ id: 1, backupTargetDir: dir }).run();
}
}
describe("BackupService — persisted status survives a restart", () => {
it("a fresh instance sees the previous instance's last success", async () => {
const t = createTestDb();
setTargetDir(t.db, target);
const first = new BackupService(t.db);
expect(first.status().lastSuccessAt).toBeNull();
const result = await first.run("manual");
// Simulate a process restart: a brand-new BackupService over the SAME db handle (in
// production this would be a fresh process re-opening the same sqlite file).
const second = new BackupService(t.db);
const status = second.status();
expect(status.lastSuccessAt).not.toBeNull();
expect(status.lastResult).toEqual({ path: result.path, bytes: result.bytes, prunedFiles: result.prunedFiles });
expect(status.lastError).toBeNull();
t.close();
});
it("a fresh instance sees the previous instance's last error, and it clears on next success", async () => {
const t = createTestDb();
// Target dir set, but as a FILE (not a directory) — runBackup's mkdir(recursive) will
// throw, giving us a real, deterministic failure without needing to mock anything.
const badTarget = join(workDir, "not-a-dir");
writeFileSync(badTarget, "x");
setTargetDir(t.db, badTarget);
const first = new BackupService(t.db);
await expect(first.run("manual")).rejects.toThrow();
const second = new BackupService(t.db);
const status = second.status();
expect(status.lastError).not.toBeNull();
expect(status.lastErrorAt).not.toBeNull();
expect(status.lastSuccessAt).toBeNull();
// Now point at a real directory and succeed — the persisted error must clear.
setTargetDir(t.db, target);
await second.run("manual");
const third = new BackupService(t.db);
const finalStatus = third.status();
expect(finalStatus.lastSuccessAt).not.toBeNull();
expect(finalStatus.lastError).toBeNull();
expect(finalStatus.lastErrorAt).toBeNull();
t.close();
});
});
describe("BackupService — isDue() is wall-clock-based, not process-uptime-based", () => {
it("is due immediately when no success has ever been recorded", () => {
const t = createTestDb();
const svc = new BackupService(t.db);
expect(svc.isDue()).toBe(true);
t.close();
});
it("is NOT due right after a fresh instance is constructed, if a recent success is persisted", async () => {
const t = createTestDb();
setTargetDir(t.db, target);
const first = new BackupService(t.db);
await first.run("manual");
// The whole point of the fix: a brand-new instance (simulating a restart moments after a
// real backup completed) must NOT think a backup is due just because ITS OWN uptime is ~0.
const second = new BackupService(t.db);
expect(second.isDue()).toBe(false);
t.close();
});
it("is due once the persisted last-success timestamp is old enough", async () => {
const t = createTestDb();
setTargetDir(t.db, target);
const svc = new BackupService(t.db);
await svc.run("manual");
const almostADayLater = new Date(Date.now() + 23 * 60 * 60 * 1000);
expect(svc.isDue(almostADayLater)).toBe(false);
const overADayLater = new Date(Date.now() + 24 * 60 * 60 * 1000 + 1000);
expect(svc.isDue(overADayLater)).toBe(true);
t.close();
});
it("runScheduled() is a no-op when not yet due, even if configured", async () => {
const t = createTestDb();
setTargetDir(t.db, target);
const svc = new BackupService(t.db);
await svc.run("manual");
const afterFirst = svc.status().lastSuccessAt;
await svc.runScheduled(); // not due yet — must not run again
expect(svc.status().lastSuccessAt).toBe(afterFirst);
t.close();
});
});
+71 -17
View File
@@ -11,6 +11,12 @@ import { DEFAULT_BACKUP_RETENTION, runBackup, type BackupResult, type BackupRete
// a key must never live in the DB it backs up. Remembers the last outcome so the route + UI can
// show last-success / last-error, and serializes concurrent runs (manual + timer). See
// wiki/concepts/backup-recovery.md.
//
// Last-success/last-error are PERSISTED to site_config (backup_last_*), not just held in
// memory — an earlier version tracked these as plain in-process fields only, so every server
// restart (deploy, crash, OOM, host reboot — all routine under `restart: always`) silently
// reset the admin UI to "last successful backup: Never", even with valid, correctly-rotating
// backups already on disk (2026-08-30 field incident, park-buzi). See wiki/concepts/backup-recovery.md.
/** The dedicated backup-encryption key, from env (NOT the DB). Separate from EVENT_SIGNING_KEY. */
export function backupKeyFromEnv(): string {
@@ -65,16 +71,33 @@ export class BackupService {
readonly #logger?: FastifyBaseLogger;
#running = false;
#lastSuccessAt: string | null = null;
#lastResult: BackupResult | null = null;
#lastErrorAt: string | null = null;
#lastError: string | null = null;
constructor(db: Db, logger?: FastifyBaseLogger) {
this.#db = db;
this.#logger = logger;
}
/** Fresh read of the persisted row (single source of truth — no in-memory cache to go stale
* or reset on restart). */
#row(): { backupLastSuccessAt: string | null; backupLastResultJson: string | null; backupLastErrorAt: string | null; backupLastError: string | null } | undefined {
return this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
}
#persist(patch: {
backupLastSuccessAt?: string | null;
backupLastResultJson?: string | null;
backupLastErrorAt?: string | null;
backupLastError?: string | null;
}): void {
const updatedAt = new Date().toISOString();
const existing = this.#row();
if (existing) {
this.#db.update(siteConfig).set({ ...patch, updatedAt }).where(eq(siteConfig.id, 1)).run();
} else {
this.#db.insert(siteConfig).values({ id: 1, ...patch, updatedAt }).run();
}
}
/** The admin-chosen target dir from site_config (null/empty = unset). Read fresh each call. */
targetDir(): string | null {
const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
@@ -104,6 +127,15 @@ export class BackupService {
status(): BackupStatus {
const r = this.retention();
const row = this.#row();
let lastResult: BackupStatus["lastResult"] = null;
if (row?.backupLastResultJson) {
try {
lastResult = JSON.parse(row.backupLastResultJson) as BackupStatus["lastResult"];
} catch {
lastResult = null; // corrupt/foreign value in the column — don't let it crash status()
}
}
return {
configured: this.configured,
targetDir: this.targetDir(),
@@ -111,12 +143,10 @@ export class BackupService {
keepDailyDays: r.keepDailyDays,
keyPresent: this.keyPresent,
running: this.#running,
lastSuccessAt: this.#lastSuccessAt,
lastResult: this.#lastResult
? { path: this.#lastResult.path, bytes: this.#lastResult.bytes, prunedFiles: this.#lastResult.prunedFiles }
: null,
lastErrorAt: this.#lastErrorAt,
lastError: this.#lastError,
lastSuccessAt: row?.backupLastSuccessAt ?? null,
lastResult,
lastErrorAt: row?.backupLastErrorAt ?? null,
lastError: row?.backupLastError ?? null,
};
}
@@ -139,14 +169,17 @@ export class BackupService {
try {
this.#logger?.info(`backup: starting (${trigger}) → ${targetDir}`);
const res = await runBackup(this.#db, { targetDir, key, retention: this.retention() }, this.#logger);
this.#lastResult = res;
this.#lastSuccessAt = new Date().toISOString();
this.#lastError = null;
this.#persist({
backupLastSuccessAt: new Date().toISOString(),
backupLastResultJson: JSON.stringify({ path: res.path, bytes: res.bytes, prunedFiles: res.prunedFiles }),
backupLastErrorAt: null,
backupLastError: null,
});
return res;
} catch (err) {
this.#lastError = (err as Error).message;
this.#lastErrorAt = new Date().toISOString();
this.#logger?.error(`backup: failed (${trigger}): ${this.#lastError}`);
const message = (err as Error).message;
this.#persist({ backupLastErrorAt: new Date().toISOString(), backupLastError: message });
this.#logger?.error(`backup: failed (${trigger}): ${message}`);
throw err;
} finally {
this.#running = false;
@@ -156,13 +189,34 @@ export class BackupService {
return this.#inflight;
}
/** Scheduled-run wrapper: never throws (a timer must not crash the process). */
/**
* Scheduled-run wrapper: never throws (a timer must not crash the process). Safe to call on
* a short, frequent poll (see server.ts) — it's a no-op unless `isDue()` says a full interval
* has actually elapsed since the last recorded success, so frequent polling doesn't cause
* frequent backups.
*/
async runScheduled(): Promise<void> {
if (!this.configured) return; // silent no-op when backups aren't set up
if (!this.isDue()) return;
try {
await this.run("scheduled");
} catch {
/* recorded in last-error; already logged */
}
}
/**
* Wall-clock check: has enough time elapsed since the last successful backup for a new one
* to be due? Deliberately based on the PERSISTED last-success instant, not "time since this
* process started" — a `setInterval(..., 24h)` measured from process start silently drifts
* (or skips a whole day) across every restart, since the countdown restarts from zero each
* time regardless of when the last real backup happened. See wiki/concepts/backup-recovery.md.
*/
isDue(now: Date = new Date(), intervalMs = 24 * 60 * 60 * 1000): boolean {
const lastSuccessAt = this.#row()?.backupLastSuccessAt;
if (!lastSuccessAt) return true; // never recorded a success → due immediately once configured
const last = new Date(lastSuccessAt).getTime();
if (Number.isNaN(last)) return true;
return now.getTime() - last >= intervalMs;
}
}
+5
View File
@@ -80,6 +80,8 @@ function receiptFigures(
currency?: string;
tender?: "cash" | "card";
graceExitMin?: number;
grossMinor?: number;
validationLines?: { label: string; discountMinor: number }[];
};
return {
ticketId,
@@ -89,6 +91,9 @@ function receiptFigures(
currency: p.currency ?? "ALL",
tender: p.tender === "card" ? "card" : "cash",
graceExitMin: typeof p.graceExitMin === "number" ? p.graceExitMin : null,
// Merchant validations, as settled on the signed payment (gross → lines → net).
grossMinor: typeof p.grossMinor === "number" ? p.grossMinor : null,
validationLines: Array.isArray(p.validationLines) ? p.validationLines : undefined,
};
}
+68 -1
View File
@@ -192,11 +192,78 @@ describe("ButtonLightController truth table", () => {
// First write (initial off) throws — must be swallowed.
expect(() => ctl.start()).not.toThrow();
await flush();
// Subsequent writes work; driving to solid still converges to ON.
// The failure arms a backoff (1s) rather than retrying inline; desired-state
// changes during the window just update the target the retry will assert.
lane(true);
radar(true);
await flush();
expect(ctl.confirmedOf(CONTROLLER)).toBeNull(); // still backing off
await vi.advanceTimersByTimeAsync(1000); // retry fires; aux is healthy again
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // converged to solid ON
ctl.stop();
});
it("an unreachable controller backs off (1s→30s), not a hot retry loop", async () => {
let attempts = 0;
const aux: AuxOutputDevice = {
async setAux() {
attempts += 1;
throw new Error("send ENETUNREACH 10.0.10.5:60000");
},
};
const errors: string[] = [];
const logger = silentLogger();
(logger as { error: (msg: string) => void }).error = (msg) => errors.push(msg);
const ctl = new ButtonLightController(db, logger, () => aux);
ctl.start(); // initial OFF write → attempt 1 fails at t=0
await flush();
expect(attempts).toBe(1); // the old code hot-looped here
// Failures at t≈0,1,3,7,15,31 (doubling, capped 30s) → 6 attempts in the first
// minute instead of thousands.
await vi.advanceTimersByTimeAsync(60_000);
expect(attempts).toBeGreaterThanOrEqual(5);
expect(attempts).toBeLessThanOrEqual(7);
// Only the FIRST failure was logged so far; the next log is a ≥60s summary.
expect(errors).toHaveLength(1);
await vi.advanceTimersByTimeAsync(35_000); // t≈95s → the t=61s attempt logged a summary
expect(errors.length).toBe(2);
expect(errors[1]).toContain("still failing");
ctl.stop();
});
it("logs a single recovery line and resets the backoff after success", async () => {
let failing = true;
let attempts = 0;
const aux: AuxOutputDevice = {
async setAux() {
attempts += 1;
if (failing) throw new Error("send ENETUNREACH 10.0.10.5:60000");
},
};
const infos: string[] = [];
const logger = silentLogger();
(logger as { info: (msg: string) => void }).info = (msg) => infos.push(msg);
const ctl = new ButtonLightController(db, logger, () => aux);
ctl.start();
await flush();
await vi.advanceTimersByTimeAsync(3_000); // attempts at t=0,1,3 all fail
const failed = attempts;
expect(failed).toBeGreaterThanOrEqual(3);
failing = false; // controller reachable again
await vi.advanceTimersByTimeAsync(8_000); // next armed retry succeeds
expect(ctl.confirmedOf(CONTROLLER)).toBe(false); // OFF asserted on the device
expect(infos.filter((m) => m.includes("recovered"))).toHaveLength(1);
// Backoff reset: a fresh state change sends immediately (no lingering retryAt).
const before = attempts;
lane(true);
radar(true);
await flush();
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
expect(attempts).toBe(before + 1);
ctl.stop();
});
+78 -14
View File
@@ -21,6 +21,14 @@ type LightState = "off" | "solid" | "blink";
const DEFAULT_BLINK_MS = 500;
// Failed-send retry backoff: 1s doubling to 30s, reset on success. Without this an
// unreachable controller (ENETUNREACH) became a hot loop — the failure re-pump retried
// instantly, thousands of sends + error lines per minute (field incident 2026-07-07).
const RETRY_BASE_MS = 1_000;
const RETRY_MAX_MS = 30_000;
/** After the first failure of a streak, log at most one summary line per this window. */
const FAIL_LOG_EVERY_MS = 60_000;
/** Per-lamp live state for the alert rule (one per radarAlert relay). */
interface LampState {
/** The controller this lamp lives on (its deviceId) — for resolving the aux adapter. */
@@ -44,6 +52,16 @@ interface LampState {
/** True while a send is in flight for this lamp — serializes UDP so on/off can't
* overlap or reorder (UDP is unordered; concurrent toggles left the relay stuck). */
sending: boolean;
/** Consecutive failed sends (0 = healthy). Drives the backoff delay + log summaries. */
failCount: number;
/** Epoch ms before which #pump must not send (0 = no backoff). The armed retry
* timer re-pumps when it elapses; desired-state changes in between just update
* `desiredOn` and are picked up by that same retry. */
retryAt: number;
/** The armed backoff retry, if any. */
retryTimer: ReturnType<typeof setTimeout> | null;
/** Epoch ms of the last failure line we actually logged (rate-limits the flood). */
lastFailLogAt: number;
}
/** Resolves a controller's live aux-output adapter. The default goes through the
@@ -111,6 +129,10 @@ export class ButtonLightController {
desiredOn: false,
confirmedOn: null,
sending: false,
failCount: 0,
retryAt: 0,
retryTimer: null,
lastFailLogAt: 0,
});
}
}
@@ -118,10 +140,7 @@ export class ButtonLightController {
// Drop lamps whose controller no longer declares one (or was disabled/removed).
for (const [key, lamp] of this.#lamps) {
if (seen.has(key)) continue;
if (lamp.blink) {
clearInterval(lamp.blink);
lamp.blink = null;
}
this.#disarm(lamp);
this.#finalOff(lamp); // best-effort fail-OFF before forgetting it
this.#lamps.delete(key);
}
@@ -207,10 +226,17 @@ export class ButtonLightController {
* time. Because UDP is unordered, concurrent on/off sends previously raced and left
* the relay stuck on a stale packet. Here a single in-flight send is guaranteed
* (`sending` guard); when it resolves, if the desired state moved on we send again —
* so the LAST desired state is always the one finally asserted on the device. */
* so the LAST desired state is always the one finally asserted on the device.
*
* Failures back off (1s → 30s, reset on success) instead of retrying inline: an
* unreachable controller rejects instantly, and an immediate re-pump was a hot loop.
* During backoff `desiredOn` keeps tracking the truth table; the armed retry timer
* converges to whatever it says when it fires. Only the FIRST failure of a streak is
* logged, then one summary per minute, and an info line on recovery. */
#pump(lamp: LampState): void {
if (lamp.sending) return; // a send is already in flight; it'll re-check on completion
if (lamp.confirmedOn === lamp.desiredOn) return; // already there — no redundant UDP
if (Date.now() < lamp.retryAt) return; // backing off — the retry timer will re-pump
const aux = this.#resolveAux(lamp.controllerId);
if (!aux) return;
const target = lamp.desiredOn;
@@ -219,15 +245,42 @@ export class ButtonLightController {
.setAux(lamp.spec.relay, target)
.then(() => {
lamp.confirmedOn = target;
if (lamp.failCount > 0) {
this.#logger.info(
`button-light setAux recovered (${lamp.controllerId} R${lamp.spec.relay}) after ${lamp.failCount} failed attempts`,
);
}
lamp.failCount = 0;
lamp.retryAt = 0;
lamp.lastFailLogAt = 0;
})
.catch((err: unknown) => {
// Leave confirmedOn unchanged so the next pump retries this state. Never escalates.
this.#logger.error(`button-light setAux failed (${lamp.controllerId} R${lamp.spec.relay}): ${(err as Error).message}`);
// Leave confirmedOn unchanged so the armed retry re-asserts the (then-current)
// desired state. Never escalates — a dead lamp is "no hint", never a fault.
lamp.failCount += 1;
const delay = Math.min(RETRY_BASE_MS * 2 ** (lamp.failCount - 1), RETRY_MAX_MS);
lamp.retryAt = Date.now() + delay;
const now = Date.now();
if (lamp.failCount === 1 || now - lamp.lastFailLogAt >= FAIL_LOG_EVERY_MS) {
lamp.lastFailLogAt = now;
const streak =
lamp.failCount > 1 ? ` — still failing (attempt ${lamp.failCount}, retrying ≤${RETRY_MAX_MS / 1000}s)` : "";
this.#logger.error(
`button-light setAux failed (${lamp.controllerId} R${lamp.spec.relay}): ${(err as Error).message}${streak}`,
);
}
if (lamp.retryTimer) clearTimeout(lamp.retryTimer);
lamp.retryTimer = setTimeout(() => {
lamp.retryTimer = null;
this.#pump(lamp);
}, delay);
lamp.retryTimer.unref?.();
})
.finally(() => {
lamp.sending = false;
// Desired state may have changed (or the send failed) while we were busy —
// re-pump to converge. This is what makes the final state authoritative.
// Desired state may have changed while we were busy — re-pump to converge (the
// backoff gate above makes this a no-op right after a failure). This is what
// makes the final state authoritative.
if (lamp.confirmedOn !== lamp.desiredOn) this.#pump(lamp);
});
}
@@ -261,20 +314,31 @@ export class ButtonLightController {
this.#unsubInput = null;
this.#unsubLane = null;
for (const lamp of this.#lamps.values()) {
if (lamp.blink) {
clearInterval(lamp.blink);
lamp.blink = null;
}
this.#disarm(lamp);
// Best-effort fail-OFF on shutdown.
this.#finalOff(lamp);
}
}
/** Stop a lamp's timers (blink + backoff retry) without touching the device. */
#disarm(lamp: LampState): void {
if (lamp.blink) {
clearInterval(lamp.blink);
lamp.blink = null;
}
if (lamp.retryTimer) {
clearTimeout(lamp.retryTimer);
lamp.retryTimer = null;
}
}
/** Drive a lamp OFF as a one-shot (used when dropping/stopping a lamp): set desired
* OFF and pump. The serialized worker still applies, so this can't collide with an
* in-flight send — it converges to OFF. */
* in-flight send — it converges to OFF. Any backoff is waived so the last-gasp OFF
* gets one immediate try (a lamp mid-backoff may just have recovered). */
#finalOff(lamp: LampState): void {
lamp.desiredOn = false;
lamp.retryAt = 0;
this.#pump(lamp);
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { localIsoWithOffset } from "./device-monitor.js";
// The camera clock-sync sends the SITE's wall-clock now with an explicit UTC offset
// (ISAPI localTime) — the offset is what makes the instant unambiguous regardless of
// the camera's own tz/DST config. Pin the DST both-sides behaviour for the site tz.
describe("localIsoWithOffset (camera clock sync payload)", () => {
it("Tirane summer = +02:00 (CEST)", () => {
expect(localIsoWithOffset("Europe/Tirane", new Date("2026-07-07T10:00:00Z"))).toBe(
"2026-07-07T12:00:00+02:00",
);
});
it("Tirane winter = +01:00 (CET)", () => {
expect(localIsoWithOffset("Europe/Tirane", new Date("2026-01-15T10:00:00Z"))).toBe(
"2026-01-15T11:00:00+01:00",
);
});
it("UTC = +00:00", () => {
expect(localIsoWithOffset("UTC", new Date("2026-07-07T10:00:00Z"))).toBe(
"2026-07-07T10:00:00+00:00",
);
});
});
+68 -2
View File
@@ -1,9 +1,10 @@
import type { FastifyBaseLogger } from "fastify";
import { devices, type Db, type DeviceRow } from "@parking/db";
import { isMonitorable, registry } from "@parking/devices";
import { isClockSyncable, isMonitorable, registry, type Device } from "@parking/devices";
import { deviceEvents, type DeviceStatusEvent } from "./device-events.js";
import { directionOf, relaysOf } from "./device-resolve.js";
import type { VisionClient } from "./vision-client.js";
import { siteTz } from "./subscription-window.js";
/** Synthetic device id for the vision service in the status footer (it's a service,
* not a device row, but shares the footer's traffic-light + WS plumbing). */
@@ -23,6 +24,40 @@ const VISION_STATUS_ID = "vision-service";
const POLL_MS = Number(process.env.DEVICE_POLL_MS ?? 8000);
// Camera clock sync (Hikvision loses its clock on power cuts — reboots at the 1970
// epoch until a human logs into its web UI). The monitor re-syncs from the HOST
// clock (the site's offline time authority) at the offline→ready edge — exactly the
// power-restored moment — plus a daily backstop; drift under the threshold is left
// alone. See wiki/entities/lpr-camera.md (clock sync).
const CLOCK_SYNC_BACKSTOP_MS = 24 * 60 * 60 * 1000;
const CLOCK_MAX_DRIFT_SEC = 60;
/** The site's wall-clock now as ISO WITH utc offset (e.g. 2026-07-07T15:30:22+02:00)
* — what ISAPI's localTime wants. Derived via Intl for the site tz (no dep). */
export function localIsoWithOffset(tz: string, at = new Date()): string {
const fmt = new Intl.DateTimeFormat("en-CA", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h23",
});
const p = Object.fromEntries(fmt.formatToParts(at).map((x) => [x.type, x.value]));
const wallAsUtcMs = Date.UTC(
Number(p.year), Number(p.month) - 1, Number(p.day),
Number(p.hour), Number(p.minute), Number(p.second),
);
const offMin = Math.round((wallAsUtcMs - at.getTime()) / 60_000);
const sign = offMin < 0 ? "-" : "+";
const abs = Math.abs(offMin);
const hh = String(Math.floor(abs / 60)).padStart(2, "0");
const mm = String(abs % 60).padStart(2, "0");
return `${p.year}-${p.month}-${p.day}T${p.hour}:${p.minute}:${p.second}${sign}${hh}:${mm}`;
}
/**
* The device's ROLE descriptor for the footer (never the vendor). Direction-style
* tokens the client localises next to the category:
@@ -139,6 +174,7 @@ export class DeviceMonitor {
};
let next: DeviceStatusEvent;
let device: Device | null = null;
const driver = registry.get(row.driverId);
if (!driver) {
// Configured against a driver that's no longer registered — surface it,
@@ -146,7 +182,7 @@ export class DeviceMonitor {
next = { ...base, state: "offline", detail: "driver not registered", checkedAt: new Date().toISOString() };
} else {
try {
const device = driver.create(cfg as never);
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)) {
@@ -163,6 +199,33 @@ export class DeviceMonitor {
}
}
// Camera clock re-sync at the power-restored edge (prev offline/unknown →
// ready) + a daily backstop. Stamped BEFORE the async attempt so a failing
// camera is retried at backstop cadence, never every poll.
if (row.category === "camera" && next.state === "ready" && device && isClockSyncable(device)) {
const prev = this.#latest.get(row.id);
const cameBack = !prev || prev.state === "offline";
const last = this.#clockSyncedAt.get(row.id) ?? 0;
if (cameBack || Date.now() - last > CLOCK_SYNC_BACKSTOP_MS) {
this.#clockSyncedAt.set(row.id, Date.now());
const cam = device;
void (async () => {
try {
const r = await cam.syncClock(localIsoWithOffset(siteTz(this.#db)), CLOCK_MAX_DRIFT_SEC);
if (r.synced) {
// A large jump is the 1970 power-cut signature — warn (persisted) so
// the reboot stays visible; a small correction is routine info.
const msg = `device-monitor: camera ${row.id} clock synced (was ${r.driftSeconds ?? "unparseable"}s off)`;
if (r.driftSeconds == null || r.driftSeconds > 3600) this.#log.warn(msg);
else this.#log.info(msg);
}
} catch (err) {
this.#log.warn(`device-monitor: camera ${row.id} clock sync failed: ${(err as Error).message}`);
}
})();
}
}
this.#publish(row.id, next);
}
@@ -183,6 +246,9 @@ export class DeviceMonitor {
});
}
/** Per-camera timestamp of the last clock-sync ATTEMPT (backstop pacing). */
readonly #clockSyncedAt = new Map<string, number>();
/** Cache + emit a status, but only when it CHANGED (state or detail). */
#publish(id: string, next: DeviceStatusEvent): void {
const prev = this.#latest.get(id);
+63 -1
View File
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { appLogs, type Db } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import { LogService, pinoDbStream } from "./log-service.js";
@@ -52,3 +52,65 @@ describe("pinoDbStream level encodings", () => {
expect(teed).toHaveLength(2);
});
});
// Storm coalescing: a line identical to the LAST persisted row (level+source+message+
// path), arriving within 5 min of its previous occurrence, UPDATES that row (bumping
// context._repeat) instead of inserting — one screaming device can't evict unrelated
// history. The row's createdAt tracks the LATEST occurrence; the first is preserved in
// context._firstAt.
describe("storm coalescing", () => {
afterEach(() => {
vi.useRealTimers();
});
it("folds a burst of identical error lines into ONE row with a repeat counter", () => {
for (let i = 0; i < 200; i++) {
stream.write(`{"level":"error","msg":"button-light setAux failed (ctl R3): send ENETUNREACH"}\n`);
}
const all = rows();
expect(all).toHaveLength(1);
expect(all[0].context).toMatchObject({ _repeat: 200 });
expect(teed).toHaveLength(200); // stdout still gets every line
});
it("keeps first-occurrence time in _firstAt while createdAt tracks the latest", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-08T10:00:00.000Z"));
stream.write(`{"level":"warn","msg":"same"}\n`);
vi.setSystemTime(new Date("2026-07-08T10:02:00.000Z"));
stream.write(`{"level":"warn","msg":"same"}\n`);
const [row] = rows();
expect(row.createdAt).toBe("2026-07-08T10:02:00.000Z");
expect(row.context).toMatchObject({ _repeat: 2, _firstAt: "2026-07-08T10:00:00.000Z" });
});
it("a different message (or level) breaks the run — separate rows", () => {
stream.write(`{"level":"error","msg":"boom A"}\n`);
stream.write(`{"level":"error","msg":"boom A"}\n`);
stream.write(`{"level":"error","msg":"boom B"}\n`);
stream.write(`{"level":"warn","msg":"boom B"}\n`);
expect(rows()).toHaveLength(3);
});
it("an occurrence past the 5-minute window starts a fresh row", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-08T10:00:00.000Z"));
stream.write(`{"level":"error","msg":"slow leak"}\n`);
vi.setSystemTime(new Date("2026-07-08T10:06:00.000Z"));
stream.write(`{"level":"error","msg":"slow leak"}\n`);
expect(rows()).toHaveLength(2);
});
it("a CONTINUOUS storm stays one row past the window (each hit refreshes it)", () => {
vi.useFakeTimers();
let t = new Date("2026-07-08T10:00:00.000Z").getTime();
for (let i = 0; i < 10; i++) {
vi.setSystemTime(new Date(t));
stream.write(`{"level":"error","msg":"storm"}\n`);
t += 240_000; // 4 min apart — each inside the window of the PREVIOUS hit
}
const all = rows();
expect(all).toHaveLength(1);
expect(all[0].context).toMatchObject({ _repeat: 10 });
});
});
+54 -5
View File
@@ -25,6 +25,14 @@ const MAX_MESSAGE = 4_000;
const MAX_STACK = 16_000;
const MAX_CONTEXT_JSON = 16_000;
/** Storm coalescing: a line identical to the LAST persisted one (level+source+message+
* path) within this window of its previous occurrence UPDATES that row (bumping a
* `_repeat` counter in its context) instead of inserting a new one. A continuous storm
* keeps refreshing the window, so it stays ONE row however long it rages — repeated
* errors can't evict unrelated history or grind the appliance disk (field incident
* 2026-07-07: one unreachable controller ≈ hundreds of identical rows/minute). */
const COALESCE_WINDOW_MS = 300_000;
export interface LogRetention {
/** Delete logs older than this many days. */
readonly maxAgeDays: number;
@@ -62,6 +70,16 @@ export class LogService {
readonly #retention: LogRetention;
/** Reentrancy guard: never let persisting a log itself emit a persisted log. */
#writing = false;
/** The last persisted row, for storm coalescing (in-memory only; a restart just
* starts a fresh row — best-effort, like everything in this sink). */
#last: {
id: string;
key: string;
count: number;
firstAt: string;
lastAtMs: number;
baseContext: Record<string, unknown> | null;
} | null = null;
constructor(db: Db, retention: LogRetention = DEFAULT_RETENTION) {
this.#db = db;
@@ -85,22 +103,53 @@ export class LogService {
if (this.#writing) return;
this.#writing = true;
try {
const createdAt = row.createdAt ?? new Date().toISOString();
const message = clamp(row.message, MAX_MESSAGE) ?? "";
const path = clamp(row.path, 512);
const key = `${row.level}|${row.source}|${message}|${path ?? ""}`;
const nowMs = Date.now();
// Storm coalescing: identical to the last persisted row, within the window →
// bump that row instead of inserting. createdAt moves to the LATEST occurrence
// (keeps the storm visible at the top of the newest-first viewer); the first
// occurrence's time is preserved in context._firstAt.
const last = this.#last;
if (last && last.key === key && nowMs - last.lastAtMs <= COALESCE_WINDOW_MS) {
const res = this.#db
.update(appLogs)
.set({
context: { ...(last.baseContext ?? {}), _repeat: last.count + 1, _firstAt: last.firstAt },
createdAt,
})
.where(eq(appLogs.id, last.id))
.run();
if ((res.changes ?? 0) > 0) {
last.count += 1;
last.lastAtMs = nowMs;
return;
}
// The row was pruned out from under us — fall through to a fresh insert.
}
const id = randomUUID();
const baseContext = safeContext(row.context);
this.#db
.insert(appLogs)
.values({
id: randomUUID(),
id,
level: row.level,
source: row.source,
message: clamp(row.message, MAX_MESSAGE) ?? "",
context: safeContext(row.context),
message,
context: baseContext,
httpStatus: row.httpStatus ?? null,
path: clamp(row.path, 512),
path,
stack: clamp(row.stack, MAX_STACK),
userId: row.userId ?? null,
userAgent: clamp(row.userAgent, 512),
createdAt: row.createdAt ?? new Date().toISOString(),
createdAt,
})
.run();
this.#last = { id, key, count: 1, firstAt: createdAt, lastAtMs: nowMs, baseContext };
} catch {
// Swallow — diagnostics must never take down the path they observe. (Can't log
// it; that's the recursion we're guarding against.)
+47 -2
View File
@@ -1,9 +1,10 @@
import { desc, eq, ledgerEvents, sessions, subscriptions, tariffVersions, tariffs, type Db } from "@parking/db";
import { priceSession, type TariffStructure, type Tender } from "@parking/shared";
import { priceSession, type TariffStructure, type Tender, type ValidationLine } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify";
import type { EventLog } from "./event-log.js";
import { plateForIdentity, platesForIdentities } from "./plate-lookup.js";
import { windowOwedBetween } from "./subscription-window.js";
import { liveValidations } from "./validations.js";
// The PAY STATION: a customer pays for an open session BEFORE walking back to the
// car (pay-on-foot — payment is decoupled from exit). Two steps:
@@ -38,8 +39,17 @@ export interface Quote {
* is priced as a fresh stay from there → now, with its own daily-cap ladder, NOT
* "full stay minus paid" (which a daily cap collapses toward zero). */
readonly periodStart: string;
/** Amount owed now: the fee for [periodStart → now]. */
/** Amount owed now: the fee for [periodStart → now], NET of merchant validations. */
readonly amountMinor: number;
/** The pre-validation fee (= amountMinor when no validations apply). */
readonly grossMinor: number;
/** Total the merchant validations took off (gross − net). */
readonly discountMinor: number;
/** Per-validation receipt/display lines (empty when none apply). */
readonly validationLines: ValidationLine[];
/** The validation event ids this quote applied — the payment stamps them as
* CONSUMED so an overstay's fresh period never re-applies them. */
readonly validationIds: string[];
/** True when this quote prices an overstay period (grace lapsed), not the first stay. */
readonly overstay: boolean;
readonly currency: string;
@@ -117,6 +127,12 @@ export interface SessionLookup {
/** Advisory licence plate recognized for this session (ANPR-on-snapshot). Null when
* none. Display/audit only — never an access decision. */
readonly plate: string | null;
/** Merchant validations folded into `amountMinor` (which is NET): the pre-discount
* fee, the total taken off, and the per-validation lines for the modal/receipt.
* grossMinor/discountMinor are null when no quote resolved. */
readonly grossMinor: number | null;
readonly discountMinor: number | null;
readonly validationLines: ValidationLine[];
}
export class PayStation {
@@ -155,19 +171,28 @@ export class PayStation {
// Pure pricing shared with the Tariff Lab (priceSession). Only the latest payment
// matters for grace/overstay; pass it through. Overstay → fresh period from
// grace-expiry; within-grace → settled; unpaid → entry→now running total.
// Merchant validations: fold the LIVE ones (applied, unvoided, not consumed by a
// prior payment) so the quote is NET — the payment then stamps their ids as
// consumed. See wiki/concepts/validation-discounts.md.
const last = this.#lastPayment(identity);
const validations = liveValidations(this.#db, identity);
const p = priceSession(
entry.occurredAt,
new Date().toISOString(),
structure,
last ? [last] : [],
category,
validations,
);
return {
identity,
enteredAt: entry.occurredAt,
periodStart: p.periodStart,
amountMinor: p.amountMinor,
grossMinor: p.grossMinor,
discountMinor: p.discountMinor,
validationLines: p.validationLines,
validationIds: validations.map((v) => v.eventId),
overstay: p.overstay,
currency: tv.currency,
tariffVersionId: tv.id,
@@ -246,6 +271,18 @@ export class PayStation {
// The exit flow reads graceExitMin off the payment to validate the
// walk-back window without re-resolving the tariff.
graceExitMin: q.graceExitMin,
// Merchant validations: record the gross/discount split + CONSUME the applied
// validation ids, so reporting sees the leakage and a later overstay period
// never re-applies them. A zero-net settlement (full comp) is still a signed
// payment — grace/voucher/exit work unchanged. See validation-discounts.md.
...(q.validationIds.length
? {
grossMinor: q.grossMinor,
discountMinor: q.discountMinor,
validationIds: q.validationIds,
validationLines: q.validationLines.map((l) => ({ ...l })),
}
: {}),
...(overrideMinor != null ? { reason: "operator-set amount", quotedMinor: q.amountMinor } : {}),
},
});
@@ -282,6 +319,7 @@ export class PayStation {
paidAt: null, amountMinor: null, currency: null, paidMinor: null, paidCurrency: null,
withinGrace: false, graceExpiresAt: null,
overstay: false, subscription: false, subscriptionId: null, subscriptionHolder: null, plate: null,
grossMinor: null, discountMinor: null, validationLines: [],
};
}
// Subscription occurrence? The entry payload carries permit:true + permitId.
@@ -319,11 +357,17 @@ export class PayStation {
// exit gate clears. See wiki/entities/subscription.md.
let amountMinor: number | null = null;
let currency: string | null = null;
let grossMinor: number | null = null;
let discountMinor: number | null = null;
let validationLines: ValidationLine[] = [];
if (open && !isSubscription) {
try {
const q = this.quote(id);
amountMinor = q.amountMinor;
currency = q.currency;
grossMinor = q.grossMinor;
discountMinor = q.discountMinor;
validationLines = q.validationLines;
} catch {
/* no active tariff — leave null; modal shows session without a price */
}
@@ -344,6 +388,7 @@ export class PayStation {
subscription: isSubscription, subscriptionId,
subscriptionHolder: this.#holderOf(subscriptionId),
plate: plateForIdentity(this.#db, id)?.plate ?? null,
grossMinor, discountMinor, validationLines,
};
}
+65
View File
@@ -181,3 +181,68 @@ describe("reportSummary — duration (sessions cache) + subscriptions", () => {
expect(r.subscriptions.coveredCars).toBe(2);
});
});
describe("reportSummary — occupancy, heatmap, stay histogram, look-closer counters (2026-07-05)", () => {
it("folds prior ledger into occupancyStart and walks occupancyEnd through the series", async () => {
// Before the range: 3 entries, 1 exit → 2 cars inside when June opens.
await entry(at("2026-05-20T08:00:00Z"));
await entry(at("2026-05-20T09:00:00Z"));
await entry(at("2026-05-21T10:00:00Z"));
await exit(at("2026-05-21T12:00:00Z"));
// In range: +2 on the 10th, −1 on the 11th.
await entry(at("2026-06-10T08:00:00Z"));
await entry(at("2026-06-10T09:00:00Z"));
await exit(at("2026-06-11T09:00:00Z"));
const r = reportSummary(db, { ...RANGE, bucket: "day" });
expect(r.occupancyStart).toBe(2);
expect(r.series.map((p) => [p.bucket, p.occupancyEnd])).toEqual([
["2026-06-10", 4],
["2026-06-11", 3],
]);
});
it("a voided pre-range entry does not inflate occupancyStart", async () => {
const id = randomUUID();
await log.append({ type: "vehicle_entry", direction: "entry", identity: id, occurredAt: at("2026-05-20T08:00:00Z") });
await log.append({ type: "void", identity: id, occurredAt: at("2026-05-20T08:05:00Z"), payload: { reason: "misprint" } });
const r = reportSummary(db, { ...RANGE, bucket: "day" });
expect(r.occupancyStart).toBe(0);
});
it("entriesByDowHour lands on the local weekday/hour (row 0 = Monday)", async () => {
// 2026-06-10 is a WEDNESDAY; 08:00Z = 10:00 in Tirane (UTC+2 in June).
await entry(at("2026-06-10T08:00:00Z"));
const r = reportSummary(db, { ...RANGE, bucket: "day" });
expect(r.entriesByDowHour[2]![10]).toBe(1); // Wed row, 10h column
expect(r.entriesByDowHour.flat().reduce((a, b) => a + b, 0)).toBe(1);
});
it("stay histogram buckets closed sessions; series carries the cash/card split", async () => {
db.insert(sessions).values({ id: "h1", identity: "h1", enteredAt: at("2026-06-10T08:00:00Z"), exitedAt: at("2026-06-10T08:20:00Z"), state: "closed" }).run(); // 20m → ≤30
db.insert(sessions).values({ id: "h2", identity: "h2", enteredAt: at("2026-06-10T08:00:00Z"), exitedAt: at("2026-06-10T09:30:00Z"), state: "closed" }).run(); // 90m → ≤120
db.insert(sessions).values({ id: "h3", identity: "h3", enteredAt: at("2026-06-08T08:00:00Z"), exitedAt: at("2026-06-10T09:00:00Z"), state: "closed" }).run(); // 2 days → >24h tail
await payment(at("2026-06-10T09:00:00Z"), 500, { tender: "cash" });
await payment(at("2026-06-10T09:30:00Z"), 700, { tender: "card" });
const r = reportSummary(db, { ...RANGE, bucket: "day" });
const counts = Object.fromEntries(r.stayHistogram.map((b) => [String(b.uptoMin), b.count]));
expect(counts["30"]).toBe(1);
expect(counts["120"]).toBe(1);
expect(counts["null"]).toBe(1);
const day = r.series.find((p) => p.bucket === "2026-06-10")!;
expect(day.cashMinor).toBe(500);
expect(day.cardMinor).toBe(700);
});
it("counts voids and anomalies in range (the look-closer counters)", async () => {
const id = randomUUID();
await log.append({ type: "vehicle_entry", direction: "entry", identity: id, occurredAt: at("2026-06-10T08:00:00Z") });
await log.append({ type: "void", identity: id, occurredAt: at("2026-06-10T08:05:00Z"), payload: { reason: "misprint" } });
await log.append({ type: "anomaly", identity: "X", occurredAt: at("2026-06-10T09:00:00Z"), payload: { reason: "test" } });
const r = reportSummary(db, { ...RANGE, bucket: "day" });
expect(r.totals.voids).toBe(1);
expect(r.totals.anomalies).toBe(1);
expect(r.totals.entries).toBe(0); // the voided entry stays excluded
});
});
+105 -14
View File
@@ -4,9 +4,11 @@ import {
desc,
eq,
gte,
lt,
lte,
ledgerEvents,
sessions,
siteConfig,
subscriptions,
tariffVersions,
tariffs,
@@ -46,8 +48,13 @@ export interface SeriesPoint {
readonly exits: number;
/** Net transient revenue collected in the bucket (minor units), all tenders. */
readonly revenueMinor: number;
/** Tender split of the bucket's revenue (cash = everything not card). */
readonly cashMinor: number;
readonly cardMinor: number;
/** Payment COUNT in the bucket (transactions, not amount). */
readonly payments: number;
/** Cars inside at the END of the bucket (occupancyStart + running entries−exits). */
readonly occupancyEnd: number;
}
export interface ReportTotals {
@@ -67,6 +74,10 @@ export interface ReportTotals {
readonly totalParkedMinutes: number;
readonly avgParkedMinutes: number;
readonly medianParkedMinutes: number;
/** Cancelled tickets + signed anomalies in range — the "look closer" counters
* (the operator at the booth is the threat model's primary adversary). */
readonly voids: number;
readonly anomalies: number;
}
export interface SubscriptionStats {
@@ -79,6 +90,13 @@ export interface SubscriptionStats {
readonly coveredCars: number;
}
/** One bar of the stay-duration histogram: stays up to `uptoMin` minutes (null = the
* open-ended tail). Edges chosen to mirror how tariffs are designed (see tariff.md). */
export interface StayBucket {
readonly uptoMin: number | null;
readonly count: number;
}
export interface ReportSummary {
readonly from: string;
readonly to: string;
@@ -89,25 +107,43 @@ export interface ReportSummary {
readonly series: SeriesPoint[];
/** Entries by local hour-of-day (0–23), summed across the range — the peak-hour view. */
readonly entriesByHour: number[];
/** Entries by [day-of-week][hour-of-day] — 7×24, row 0 = Monday. The heatmap that
* shows weekday-vs-weekend patterns (feeds tariff-window design). */
readonly entriesByDowHour: number[][];
/** Stay-duration histogram over closed sessions in range. */
readonly stayHistogram: StayBucket[];
/** Cars inside when the range OPENS (folded from the whole prior ledger). */
readonly occupancyStart: number;
/** Nominal capacity from site config (null = uncapped) — the reference line. */
readonly capacity: number | null;
readonly subscriptions: SubscriptionStats;
}
/** Local wall-clock parts of an ISO instant in a given IANA tz. Reuses Intl (no dep). */
function localParts(iso: string, tz: string): { y: number; mo: number; d: number; h: number } {
const fmt = new Intl.DateTimeFormat("en-CA", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
hourCycle: "h23",
});
const fmtCache = new Map<string, Intl.DateTimeFormat>();
const DOW_INDEX: Record<string, number> = { Mon: 0, Tue: 1, Wed: 2, Thu: 3, Fri: 4, Sat: 5, Sun: 6 };
function localParts(iso: string, tz: string): { y: number; mo: number; d: number; h: number; dow: number } {
// Cached per tz — this runs once per ledger row in a report.
let fmt = fmtCache.get(tz);
if (!fmt) {
fmt = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
hourCycle: "h23",
weekday: "short",
});
fmtCache.set(tz, fmt);
}
const parts = Object.fromEntries(fmt.formatToParts(new Date(iso)).map((p) => [p.type, p.value]));
return {
y: Number(parts.year),
mo: Number(parts.month),
d: Number(parts.day),
h: Number(parts.hour),
dow: DOW_INDEX[parts.weekday ?? ""] ?? 0, // row 0 = Monday
};
}
@@ -162,6 +198,7 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
const seriesMap = new Map<string, SeriesPoint>();
const entriesByHour = new Array<number>(24).fill(0);
const entriesByDowHour = Array.from({ length: 7 }, () => new Array<number>(24).fill(0));
const totals = {
entries: 0,
exits: 0,
@@ -172,12 +209,14 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
ticketMinor: 0,
subscriptionSalesMinor: 0,
subscriptionWindowMinor: 0,
voids: 0,
anomalies: 0,
};
function point(label: string): SeriesPoint {
let p = seriesMap.get(label);
if (!p) {
p = { bucket: label, entries: 0, exits: 0, revenueMinor: 0, payments: 0 };
p = { bucket: label, entries: 0, exits: 0, revenueMinor: 0, cashMinor: 0, cardMinor: 0, payments: 0, occupancyEnd: 0 };
seriesMap.set(label, p);
}
return p;
@@ -196,11 +235,16 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
if (row.identity && voided.has(row.identity)) continue; // cancelled — not a real entry
totals.entries++;
p.entries++;
const h = localParts(row.occurredAt, tz).h;
entriesByHour[h] = (entriesByHour[h] ?? 0) + 1;
const lp = localParts(row.occurredAt, tz);
entriesByHour[lp.h] = (entriesByHour[lp.h] ?? 0) + 1;
entriesByDowHour[lp.dow]![lp.h] = (entriesByDowHour[lp.dow]![lp.h] ?? 0) + 1;
} else if (row.type === "vehicle_exit") {
totals.exits++;
p.exits++;
} else if (row.type === "void") {
totals.voids++;
} else if (row.type === "anomaly") {
totals.anomalies++;
} else if (row.type === "payment") {
const pl = (row.payload ?? {}) as PaymentPayload;
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
@@ -209,8 +253,13 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
totals.revenueMinor += amt;
p.payments++;
p.revenueMinor += amt;
if (pl.tender === "card") totals.cardMinor += amt;
else totals.cashMinor += amt;
if (pl.tender === "card") {
totals.cardMinor += amt;
p.cardMinor += amt;
} else {
totals.cashMinor += amt;
p.cashMinor += amt;
}
// Revenue split mirrors the shift Z-report: subscription sale / window charge /
// (the rest is) transient ticket revenue.
if (pl.subscriptionSale === true) totals.subscriptionSalesMinor += amt;
@@ -221,6 +270,31 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
const series = [...seriesMap.values()].sort((a, b) => a.bucket.localeCompare(b.bucket));
// --- Occupancy: fold the PRIOR ledger for cars-inside at range start, then walk the
// series. Voided pre-range entries cancel out the same way the in-range pass does.
// Sparse buckets (no events) simply carry the previous level — the step line is exact
// at every plotted point.
const prior = db
.select({ type: ledgerEvents.type, identity: ledgerEvents.identity })
.from(ledgerEvents)
.where(lt(ledgerEvents.occurredAt, q.from))
.all();
const priorVoided = new Set<string>();
for (const r of prior) if (r.type === "void" && r.identity) priorVoided.add(r.identity);
let occupancyStart = 0;
for (const r of prior) {
if (r.type === "vehicle_entry" && !(r.identity && priorVoided.has(r.identity))) occupancyStart++;
else if (r.type === "vehicle_exit") occupancyStart--;
}
occupancyStart = Math.max(0, occupancyStart);
let running = occupancyStart;
for (const p of series) {
running = Math.max(0, running + p.entries - p.exits);
(p as { -readonly [K in keyof SeriesPoint]: SeriesPoint[K] }).occupancyEnd = running;
}
const capacity = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get()?.capacity ?? null;
// No payment in range? Fall back to the site tariff's latest version currency, so a
// zero-revenue range still labels its money column.
if (!currency) {
@@ -251,6 +325,19 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
durations.sort((a, b) => a - b);
const totalParkedMinutes = durations.reduce((a, b) => a + b, 0);
// Stay-duration histogram. Edges mirror how rate cards are designed (30m/1h bands,
// the 8h working day, the 24h rolling day) so the chart answers "where should the
// ladder/up-to breakpoints sit". Last bucket is the open-ended >24h tail.
const STAY_EDGES_MIN = [30, 60, 120, 240, 480, 1440];
const stayHistogram: { uptoMin: number | null; count: number }[] = [
...STAY_EDGES_MIN.map((uptoMin) => ({ uptoMin, count: 0 })),
{ uptoMin: null, count: 0 },
];
for (const mins of durations) {
const i = STAY_EDGES_MIN.findIndex((edge) => mins <= edge);
stayHistogram[i === -1 ? STAY_EDGES_MIN.length : i]!.count++;
}
// --- Subscriptions: status counts + currently-valid (window covers `to`).
const subs = db.select().from(subscriptions).all();
const subStats = { active: 0, suspended: 0, revoked: 0, currentlyValid: 0, coveredCars: 0 };
@@ -283,6 +370,10 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
},
series,
entriesByHour,
entriesByDowHour,
stayHistogram,
occupancyStart,
capacity,
subscriptions: subStats,
};
}
+7
View File
@@ -9,6 +9,9 @@ import { InvalidCashMovementError, type MovementStatus, type ShiftService } from
// - GET /api/drawer/movements: list with review status. Operators see (shift:read)
// only their own; reviewers see all + can filter status.
// - POST /api/drawer/review : admin authorize/deny a movement. (drawer:review)
// - GET /api/drawer/balance : the physical drawer balance NOW (cash (shift:read)
// payments + vouchers over the whole chain — the
// amount that carries across shifts).
// The drawer BALANCE math is unchanged — a movement counts immediately; a denial is a
// judgment about the operator settled outside the app, never a cash reversal.
@@ -73,6 +76,10 @@ export async function drawerRoutes(app: FastifyInstance, shift: ShiftService): P
return { movements, scope: canReview ? "all" : "self" };
});
// The physical drawer balance now. Same visibility as the open shift's X-report
// (shift:read) — the drawer is a single site-wide till, not per-operator data.
app.get("/api/drawer/balance", { preHandler: readGuard }, async () => shift.drawerBalance());
// Admin AUTHORIZES or DENIES a recorded movement. A flag only — no cash reversal.
app.post<{ Body: ReviewBody }>("/api/drawer/review", { preHandler: reviewGuard }, async (req, reply) => {
const b = req.body ?? ({} as ReviewBody);
+11 -2
View File
@@ -48,9 +48,18 @@ export async function reportRoutes(app: FastifyInstance, db: Db): Promise<void>
async (req, reply) => {
const summary = reportSummary(db, parseQuery(req.query));
const lines = [
"bucket,entries,exits,payments,revenue",
"bucket,entries,exits,payments,revenue,cash,card,occupancy_end",
...summary.series.map((p) =>
[p.bucket, p.entries, p.exits, p.payments, (p.revenueMinor / 100).toFixed(2)].join(","),
[
p.bucket,
p.entries,
p.exits,
p.payments,
(p.revenueMinor / 100).toFixed(2),
(p.cashMinor / 100).toFixed(2),
(p.cardMinor / 100).toFixed(2),
p.occupancyEnd,
].join(","),
),
];
reply
+49
View File
@@ -56,6 +56,37 @@ describe("auth guard — no token", () => {
});
});
describe("GET /api/version", () => {
it("without a session is 401", async () => {
const res = await app.inject({ method: "GET", url: "/api/version" });
expect(res.statusCode).toBe(401);
});
it("a site:read user gets the BUILD_VERSION env var, null when unset", async () => {
const { username, password } = await seedUser(db, {
username: "viewer2", roleId: "viewer2", permissions: ["site:read"],
});
const { cookie } = await login(app, username, password);
const res = await app.inject({ method: "GET", url: "/api/version", headers: { cookie } });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ buildVersion: null }); // no BUILD_VERSION set in the test env
});
it("reflects a real BUILD_VERSION when the env var is set", async () => {
process.env.BUILD_VERSION = "stage-abc1234";
try {
const { username, password } = await seedUser(db, {
username: "viewer3", roleId: "viewer3", permissions: ["site:read"],
});
const { cookie } = await login(app, username, password);
const res = await app.inject({ method: "GET", url: "/api/version", headers: { cookie } });
expect(res.json()).toEqual({ buildVersion: "stage-abc1234" });
} finally {
delete process.env.BUILD_VERSION;
}
});
});
describe("RBAC permission gate", () => {
it("a site:read-only user can GET occupancy but is 403 on PUT site-config", async () => {
const { username, password } = await seedUser(db, {
@@ -101,3 +132,21 @@ describe("CSRF double-submit on mutations", () => {
expect(put.statusCode).toBe(403);
});
});
describe("drawer balance (the till NOW)", () => {
it("shift:read gets the balance; a role without it is 403; no auth 401", async () => {
const anon = await app.inject({ method: "GET", url: "/api/drawer/balance" });
expect(anon.statusCode).toBe(401);
const viewer = await seedUser(db, { username: "till", roleId: "till", permissions: ["shift:read"] });
const { cookie } = await login(app, viewer.username, viewer.password);
const ok = await app.inject({ method: "GET", url: "/api/drawer/balance", headers: { cookie } });
expect(ok.statusCode).toBe(200);
expect(ok.json()).toEqual({ balanceMinor: 0, currency: null });
const outsider = await seedUser(db, { username: "noshift", roleId: "noshift", permissions: ["site:read"] });
const other = await login(app, outsider.username, outsider.password);
const denied = await app.inject({ method: "GET", url: "/api/drawer/balance", headers: { cookie: other.cookie } });
expect(denied.statusCode).toBe(403);
});
});
+33
View File
@@ -560,6 +560,39 @@ export async function setupRoutes(
},
);
// USB printers PRESENT on the box: enumerate /dev/usb/lpN (the usblp nodes the
// container sees via the /dev/usb bind-mount) and enrich each with the printer's
// self-reported make/model from sysfs (ieee1284_id — readable through Docker's
// default ro /sys). The wizard offers these as a SELECT so the admin never has to
// shell in and `ls /dev/usb` to learn the kernel picked lp1 (field friction,
// park-buzi 2026-07-07). Empty list = no usblp printer plugged/visible.
app.get("/api/setup/usb-printers", { preHandler: adminGuard }, async () => {
const { readdir, readFile } = await import("node:fs/promises");
let names: string[] = [];
try {
names = (await readdir("/dev/usb")).filter((n) => /^lp\d+$/.test(n)).sort();
} catch {
return { printers: [] }; // no /dev/usb at all — nothing plugged (or no mount)
}
const printers = await Promise.all(
names.map(async (n) => {
// ieee1284_id: "MFG:Xprinter;CMD:ESCPOS;MDL:XP-K200L;…" — best-effort.
let description: string | null = null;
try {
const id = await readFile(`/sys/class/usbmisc/${n}/device/ieee1284_id`, "utf8");
const pick = (key: string) => id.match(new RegExp(`(?:^|;)\\s*${key}:([^;]+)`, "i"))?.[1]?.trim();
const mfg = pick("MFG") ?? pick("MANUFACTURER");
const mdl = pick("MDL") ?? pick("MODEL");
description = [mfg, mdl].filter(Boolean).join(" ") || null;
} catch {
/* sysfs not readable / attribute absent — path alone is still useful */
}
return { path: `/dev/usb/${n}`, description };
}),
);
return { printers };
});
// Assign a device. Validates the chosen driver + config, configures the device
// (fix preconditions + set up Digest-authenticated input push — no manual device-
// web-UI step by the admin), then persists. Fails the save if the device can't be
+4 -1
View File
@@ -63,7 +63,10 @@ export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Pr
const from = canSeeAll ? q.from?.trim() || undefined : undefined;
const to = canSeeAll ? q.to?.trim() || undefined : undefined;
const shifts = shift.listShifts({ operator, from, to });
return { shifts, scope: canSeeAll ? "all" : "self" };
// Admins also get the distinct operator list (unfiltered) for the filter
// dropdown — operators don't see other names, so it's scope-gated.
if (canSeeAll) return { shifts, scope: "all", operators: shift.listOperators() };
return { shifts, scope: "self" };
});
// NB: drawer cash movements (record/review) moved to routes/drawer.ts (2026-07-01) — the
+9
View File
@@ -78,6 +78,15 @@ export async function siteRoutes(app: FastifyInstance, db: Db, eventLog?: EventL
// Live occupancy: cars inside, capacity, free, full. Any signed-in role.
app.get("/api/occupancy", { preHandler: readGuard }, async () => getOccupancy(db));
// Running build version ("<branch>-<short-sha>", matching the Komodo Stack's TAG in
// komodo/resources.toml) — baked in at image build time (apps/server/Dockerfile
// BUILD_VERSION ARG), read here from the running process env. null on a local/dev
// build with no CI-supplied value. Purely informational (Setup nav display); not
// site config, so it isn't stored in site_config.
app.get("/api/version", { preHandler: readGuard }, async () => ({
buildVersion: process.env.BUILD_VERSION?.trim() || null,
}));
// Read site config (capacity + park metadata).
app.get("/api/site-config", { preHandler: readGuard }, async () => {
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
@@ -0,0 +1,177 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTestDb } from "@parking/db/testing";
import { type Db } from "@parking/db";
import type { FastifyInstance } from "fastify";
import { buildServer } from "../server.js";
import { seedUser, login } from "../test-helpers.js";
// Tariff-lab drafts: the MUTABLE experiment scratchpad next to the immutable
// published versions. The contract under test: drafts are validated + tz-stamped on
// save exactly like a publish (so "publish this draft" can never fail on a card that
// saved fine), mutations need tariff:update, and publishing a draft goes through the
// normal immutable-version path untouched.
let db: Db;
let close: () => void;
let app: FastifyInstance;
beforeEach(async () => {
const t = createTestDb();
db = t.db;
close = t.close;
app = await buildServer({ db });
await app.ready();
});
afterEach(async () => {
await app.close();
close();
});
const V1_STRUCTURE = {
gracePeriodEntryMin: 5,
incrementMin: 60,
lostTicketMinor: 2000,
gracePeriodExitMin: 10,
overstay: "reprice",
blocks: [{ uptoMin: null, priceMinorPerIncrement: 200 }],
dailyCapMinor: null,
};
// A V2 card with a night package — tz left blank on purpose: the server must stamp it.
const V2_STRUCTURE = {
version: 2,
tz: "",
gracePeriodEntryMin: 5,
incrementMin: 60,
lostTicketMinor: 2000,
gracePeriodExitMin: 10,
overstay: "reprice",
defaultCard: { name: "default", priority: 0, blocks: [{ uptoMin: null, priceMinorPerIncrement: 200 }], dailyCapMinor: null },
windowedCards: [{ name: "night", priority: 10, window: { fromHour: "20:00", toHour: "07:00" }, packageMinor: 40000 }],
};
async function editor() {
const { username, password } = await seedUser(db, {
username: "editor",
roleId: "editor",
permissions: ["tariff:read", "tariff:update"],
});
return login(app, username, password);
}
describe("tariff drafts", () => {
it("requires auth", async () => {
const res = await app.inject({ method: "GET", url: "/api/tariff/drafts" });
expect(res.statusCode).toBe(401);
});
it("a tariff:read-only user can list but not create", async () => {
const { username, password } = await seedUser(db, {
username: "viewer",
roleId: "viewer",
permissions: ["tariff:read"],
});
const { cookie, csrf } = await login(app, username, password);
const list = await app.inject({ method: "GET", url: "/api/tariff/drafts", headers: { cookie } });
expect(list.statusCode).toBe(200);
expect(list.json().drafts).toEqual([]);
const create = await app.inject({
method: "POST",
url: "/api/tariff/drafts",
headers: { cookie, "x-csrf-token": csrf },
payload: { name: "x", currency: "ALL", structure: V1_STRUCTURE },
});
expect(create.statusCode).toBe(403);
});
it("create → list → update → delete roundtrip", async () => {
const { cookie, csrf } = await editor();
const headers = { cookie, "x-csrf-token": csrf };
const create = await app.inject({
method: "POST",
url: "/api/tariff/drafts",
headers,
payload: { name: "Winter proposal", currency: "all", structure: V1_STRUCTURE },
});
expect(create.statusCode).toBe(201);
const draft = create.json();
expect(draft.name).toBe("Winter proposal");
expect(draft.currency).toBe("ALL"); // normalised to upper case
expect(draft.createdBy).toBe("editor");
const list = await app.inject({ method: "GET", url: "/api/tariff/drafts", headers: { cookie } });
expect(list.json().drafts).toHaveLength(1);
const update = await app.inject({
method: "PUT",
url: `/api/tariff/drafts/${draft.id}`,
headers,
payload: { name: "Winter v2", currency: "ALL", structure: V1_STRUCTURE },
});
expect(update.statusCode).toBe(200);
expect(update.json().name).toBe("Winter v2");
const del = await app.inject({ method: "DELETE", url: `/api/tariff/drafts/${draft.id}`, headers });
expect(del.statusCode).toBe(204);
const after = await app.inject({ method: "GET", url: "/api/tariff/drafts", headers: { cookie } });
expect(after.json().drafts).toEqual([]);
});
it("rejects an invalid structure with problems (validated like a publish)", async () => {
const { cookie, csrf } = await editor();
const res = await app.inject({
method: "POST",
url: "/api/tariff/drafts",
headers: { cookie, "x-csrf-token": csrf },
payload: { name: "broken", currency: "ALL", structure: { ...V1_STRUCTURE, blocks: [] } },
});
expect(res.statusCode).toBe(400);
expect(res.json().problems?.length).toBeGreaterThan(0);
});
it("stamps the site timezone on a V2 draft, and the draft simulates + publishes as-is", async () => {
const { cookie, csrf } = await editor();
const headers = { cookie, "x-csrf-token": csrf };
const create = await app.inject({
method: "POST",
url: "/api/tariff/drafts",
headers,
payload: { name: "Night package", currency: "ALL", structure: V2_STRUCTURE },
});
expect(create.statusCode).toBe(201);
const draft = create.json();
expect(draft.structure.tz).toBe("Europe/Tirane");
// The lab prices the draft by sending its stored structure inline.
const sim = await app.inject({
method: "POST",
url: "/api/tariff/simulate",
headers,
payload: {
enteredAt: "2026-07-03T21:00:00.000+02:00",
asOf: "2026-07-03T23:00:00.000+02:00",
structure: draft.structure,
currency: draft.currency,
},
});
expect(sim.statusCode).toBe(200);
expect(sim.json().pricing.amountMinor).toBe(40000); // one night package
// "Publish this draft" = the normal immutable-version path with the draft's card;
// the draft's name rides along as the version's optional label.
const publish = await app.inject({
method: "POST",
url: "/api/tariff/versions",
headers,
payload: { currency: draft.currency, structure: draft.structure, name: draft.name },
});
expect(publish.statusCode).toBe(201);
const state = await app.inject({ method: "GET", url: "/api/tariff", headers: { cookie } });
expect(state.json().active?.name).toBe("Night package");
expect(state.json().active?.structure?.windowedCards?.[0]?.packageMinor).toBe(40000);
});
});
+107 -9
View File
@@ -1,8 +1,9 @@
import { randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify";
import { and, desc, eq, isNull, ledgerEvents, siteConfig, tariffVersions, tariffs, type Db } from "@parking/db";
import { and, desc, eq, isNull, ledgerEvents, siteConfig, tariffDrafts, tariffVersions, tariffs, type Db } from "@parking/db";
import {
computeFee,
explainFee,
isTariffV2,
priceSession,
validateTariffStructure,
@@ -25,10 +26,19 @@ interface PublishBody {
structure: TariffStructure;
/** When this version takes effect (ISO-8601). Defaults to now. */
effectiveFrom?: string;
/** Optional human label (e.g. carried from the lab draft being published). */
name?: string;
}
const SITE_TARIFF_NAME = "Site tariff";
/** Body for saving a lab draft (create + update share the shape). */
interface DraftBody {
name: string;
currency: string;
structure: TariffStructure;
}
/** Body for POST /api/tariff/simulate — price a hypothetical session, no ledger write.
* Provide a structure source (one of): `tariffVersionId`, inline `structure`, or
* neither (uses the active version). */
@@ -80,19 +90,14 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
"/api/tariff/versions",
{ preHandler: writeGuard },
async (req, reply) => {
const { currency, structure, effectiveFrom } = req.body ?? ({} as PublishBody);
const { currency, structure, effectiveFrom, name } = req.body ?? ({} as PublishBody);
if (!currency || typeof currency !== "string" || currency.length < 3) {
return reply.code(400).send({ error: "currency (ISO 4217) required" });
}
// 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 toStore = stampSiteTz(structure);
const problems = validateTariffStructure(toStore);
if (problems.length) {
@@ -128,6 +133,7 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
const row = {
id,
tariffId,
name: typeof name === "string" && name.trim() ? name.trim() : null,
effectiveFrom: effective,
currency,
structure: toStore as unknown as Record<string, unknown>,
@@ -183,6 +189,12 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
const payments = Array.isArray(b.payments) ? b.payments : [];
const pricing = priceSession(b.enteredAt, b.asOf, structure, payments, b.category);
// HOW the amount is produced — the same engine walk with a trace collector
// (Σ lines ≡ amountMinor by construction). Null when settled (nothing billed).
const breakdown = pricing.withinGrace
? null
: explainFee(pricing.periodStart, b.asOf, structure, b.category);
// A duration curve from entry: handy to SEE where the cap flattens / windows shift.
const SAMPLES_MIN = [30, 60, 120, 180, 360, 720, 1440, 2880, 4320];
const enteredMs = Date.parse(b.enteredAt);
@@ -191,7 +203,7 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
amountMinor: computeFee(b.enteredAt, new Date(enteredMs + min * 60_000).toISOString(), structure!, b.category),
}));
return { currency, pricing, curve, gracePeriodExitMin: structure.gracePeriodExitMin };
return { currency, pricing, breakdown, curve, gracePeriodExitMin: structure.gracePeriodExitMin };
});
// Prefill the lab from a REAL session: fold its ledger into entry + payments so the
@@ -230,6 +242,92 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
},
);
// --- Lab drafts ---------------------------------------------------------------
// The lab's scratchpad: MUTABLE experimental rate cards (see tariff_drafts in the
// schema for why mutability is safe here — a draft prices nothing and signs
// nothing). Saved drafts are validated + tz-stamped exactly like a publish, so the
// simulator can always price them and "publish this draft" can never surprise the
// admin with a card that saved fine but won't go live. Publishing a draft is just
// POST /api/tariff/versions with the draft's structure — same guard, same
// validation, same immutability.
app.get("/api/tariff/drafts", { preHandler: readGuard }, async () => {
const drafts = db.select().from(tariffDrafts).orderBy(desc(tariffDrafts.updatedAt)).all();
return { drafts };
});
app.post<{ Body: DraftBody }>("/api/tariff/drafts", { preHandler: writeGuard }, async (req, reply) => {
const parsed = parseDraftBody(req.body);
if ("error" in parsed) return reply.code(400).send(parsed);
const now = new Date().toISOString();
const row = {
id: randomUUID(),
name: parsed.name,
currency: parsed.currency,
structure: parsed.structure as unknown as Record<string, unknown>,
createdBy: req.user?.username ?? null,
createdAt: now,
updatedAt: now,
};
db.insert(tariffDrafts).values(row).run();
return reply.code(201).send(row);
});
app.put<{ Params: { id: string }; Body: DraftBody }>(
"/api/tariff/drafts/:id",
{ preHandler: writeGuard },
async (req, reply) => {
const existing = db.select().from(tariffDrafts).where(eq(tariffDrafts.id, req.params.id)).get();
if (!existing) return reply.code(404).send({ error: "draft not found" });
const parsed = parseDraftBody(req.body);
if ("error" in parsed) return reply.code(400).send(parsed);
const patch = {
name: parsed.name,
currency: parsed.currency,
structure: parsed.structure as unknown as Record<string, unknown>,
updatedAt: new Date().toISOString(),
};
db.update(tariffDrafts).set(patch).where(eq(tariffDrafts.id, existing.id)).run();
return { ...existing, ...patch };
},
);
app.delete<{ Params: { id: string } }>(
"/api/tariff/drafts/:id",
{ preHandler: writeGuard },
async (req, reply) => {
const existing = db.select().from(tariffDrafts).where(eq(tariffDrafts.id, req.params.id)).get();
if (!existing) return reply.code(404).send({ error: "draft not found" });
db.delete(tariffDrafts).where(eq(tariffDrafts.id, existing.id)).run();
return reply.code(204).send();
},
);
/** Validate + normalise a draft save body; tz-stamps V2 structures like a publish. */
function parseDraftBody(
body: DraftBody | undefined,
): { name: string; currency: string; structure: TariffStructure } | { error: string; problems?: string[] } {
const b = body ?? ({} as DraftBody);
const name = (b.name ?? "").trim();
if (!name) return { error: "name required" };
const currency = (b.currency ?? "").trim().toUpperCase();
if (currency.length < 3) return { error: "currency (ISO 4217) required" };
const structure = stampSiteTz(b.structure);
const problems = validateTariffStructure(structure);
if (problems.length) return { error: "invalid tariff structure", problems };
return { name, currency, structure };
}
/** Stamp a V2 structure's frozen wall-clock timezone from SITE config (never the
* client); a V1 (bare) structure passes through untouched. */
function stampSiteTz(structure: TariffStructure): TariffStructure {
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;
return { ...structure, tz };
}
return structure;
}
/** The tariff version in force at a given instant (latest effectiveFrom ≤ when). */
function tariffVersionIdFor(whenIso: string): string | null {
const tariffId = ensureSiteTariff();
+272
View File
@@ -0,0 +1,272 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { eq, ledgerEvents, users, type Db } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import type { FastifyInstance } from "fastify";
import { buildServer } from "../server.js";
import { login, makeLog, minutesAgo, seedTariff, seedUser } from "../test-helpers.js";
import type { EventLog } from "../event-log.js";
// Merchant validations (bar/lavazh): the merchant user scans a ticket and applies
// their program (a SIGNED, attributed ledger event); the booth settlement quotes NET
// and the payment CONSUMES the validation ids. These tests pin the route guards
// (binding, caps, session state), the signed apply/void events, and the money cycle
// through /api/pay/quote + /api/pay. See wiki/concepts/validation-discounts.md.
let db: Db;
let close: () => void;
let app: FastifyInstance;
beforeEach(async () => {
const t = createTestDb();
db = t.db;
close = t.close;
app = await buildServer({ db });
await app.ready();
});
afterEach(async () => {
await app.close();
close();
});
type Auth = { cookie: string; csrf: string };
const hdrs = (a: Auth) => ({ cookie: a.cookie, "x-csrf-token": a.csrf });
async function seedMerchant(username = "bari"): Promise<{ auth: Auth; userId: string }> {
await seedUser(db, { username, password: "pw123456", roleId: "validues", permissions: ["validation:create"] });
const auth = await login(app, username, "pw123456");
const row = db.select().from(users).where(eq(users.username, username)).get()!;
return { auth, userId: row.id };
}
async function seedAdmin(): Promise<Auth> {
await seedUser(db, { username: "admin", password: "pw123456" });
return login(app, "admin", "pw123456");
}
/** Admin-upserts the "bar" program bound to the given user. */
async function putProgram(auth: Auth, body: Record<string, unknown>, id = "bar") {
return app.inject({ method: "PUT", url: `/api/validation/programs/${id}`, headers: hdrs(auth), payload: body });
}
const fixedProgram = (userId: string, over: Record<string, unknown> = {}) => ({
name: "Bar",
mode: "fixed",
maxAmountMinor: 100000,
active: true,
userIds: [userId],
...over,
});
describe("merchant validations", () => {
let log: EventLog;
beforeEach(() => {
log = makeLog(db);
});
const mint = (identity: string, minAgo: number, payload: Record<string, unknown> | null = null) =>
log.append({ type: "vehicle_entry", direction: "entry", identity, occurredAt: minutesAgo(minAgo), payload });
it("program upsert is admin-gated and signs a config_change; a no-op save signs nothing", async () => {
const admin = await seedAdmin();
const { auth: merchant, userId } = await seedMerchant();
expect((await putProgram(merchant, fixedProgram(userId))).statusCode).toBe(403);
const res = await putProgram(admin, fixedProgram(userId));
expect(res.statusCode).toBe(200);
expect(res.json()).toMatchObject({ id: "bar", mode: "fixed", active: true, userIds: [userId] });
const changes = () => db.select().from(ledgerEvents).all().filter((r) => r.type === "config_change");
expect(changes()).toHaveLength(1);
expect(changes()[0].payload).toMatchObject({ setting: "validationProgram.bar", operator: "admin" });
// Identical second save → no second config_change.
await putProgram(admin, fixedProgram(userId));
expect(changes()).toHaveLength(1);
});
it("per-mode validation: timeCredit needs minutes, percent needs percent, fixed needs a cap", async () => {
const admin = await seedAdmin();
expect((await putProgram(admin, { name: "X", mode: "timeCredit", active: true })).statusCode).toBe(400);
expect((await putProgram(admin, { name: "X", mode: "percent", active: true })).statusCode).toBe(400);
expect((await putProgram(admin, { name: "X", mode: "fixed", active: true })).statusCode).toBe(400);
expect((await putProgram(admin, { name: "X", mode: "timeCredit", minutes: 60, active: true })).statusCode).toBe(200);
});
it("GET /mine returns only MY bound, active programs", async () => {
const admin = await seedAdmin();
const { auth: merchant, userId } = await seedMerchant();
await putProgram(admin, fixedProgram(userId));
await putProgram(admin, { name: "Lavazh", mode: "comp", active: true, userIds: [] }, "lavazh");
const res = await app.inject({ method: "GET", url: "/api/validation/mine", headers: hdrs(merchant) });
expect(res.statusCode).toBe(200);
const programs = res.json().programs as { id: string }[];
expect(programs.map((p) => p.id)).toEqual(["bar"]);
});
it("apply: binding, session-state, duplicate and amount guards", async () => {
const admin = await seedAdmin();
const { auth: merchant, userId } = await seedMerchant();
const { auth: other } = await seedMerchant("tjetri");
await putProgram(admin, fixedProgram(userId));
seedTariff(db);
await mint("T1", 120);
const apply = (auth: Auth, payload: Record<string, unknown>) =>
app.inject({ method: "POST", url: "/api/validation/apply", headers: hdrs(auth), payload });
// Unbound merchant → 403; unknown ticket → 404; missing amount (fixed) → 400;
// amount above the cap → 400.
expect((await apply(other, { identity: "T1", programId: "bar", amountMinor: 5000 })).statusCode).toBe(403);
expect((await apply(merchant, { identity: "NOPE", programId: "bar", amountMinor: 5000 })).statusCode).toBe(404);
expect((await apply(merchant, { identity: "T1", programId: "bar" })).statusCode).toBe(400);
expect((await apply(merchant, { identity: "T1", programId: "bar", amountMinor: 999999 })).statusCode).toBe(400);
// Subscriber sessions are never validated (prepaid).
await mint("SUB1", 60, { permit: true, permitId: "s-1" });
expect((await apply(merchant, { identity: "SUB1", programId: "bar", amountMinor: 5000 })).statusCode).toBe(409);
// Success → a SIGNED validation event with resolved values + the merchant username.
const ok = await apply(merchant, { identity: "T1", programId: "bar", amountMinor: 5000 });
expect(ok.statusCode).toBe(201);
const ev = db.select().from(ledgerEvents).all().find((r) => r.type === "validation")!;
expect(ev.payload).toMatchObject({
programId: "bar",
programLabel: "Bar",
mode: "fixed",
amountMinor: 5000,
operator: "bari",
});
// Same program twice on one ticket → 409.
expect((await apply(merchant, { identity: "T1", programId: "bar", amountMinor: 1000 })).statusCode).toBe(409);
});
it("the money cycle: quote nets the validation, pay records gross/discount and CONSUMES it", async () => {
const admin = await seedAdmin();
const { auth: merchant, userId } = await seedMerchant();
await putProgram(admin, fixedProgram(userId));
// 100/h flat; 2h → gross 20000.
seedTariff(db, { pricePerIncrementMinor: 10000, incrementMin: 60 });
await mint("T1", 119);
await app.inject({
method: "POST",
url: "/api/validation/apply",
headers: hdrs(merchant),
payload: { identity: "T1", programId: "bar", amountMinor: 5000 },
});
const q1 = await app.inject({ method: "GET", url: "/api/pay/quote?identity=T1", headers: hdrs(admin) });
expect(q1.json()).toMatchObject({
grossMinor: 20000,
discountMinor: 5000,
amountMinor: 15000,
});
expect(q1.json().validationLines).toEqual([
{ programId: "bar", label: "Bar", mode: "fixed", discountMinor: 5000 },
]);
// Pay (needs an open shift) → the payment carries the split + consumed ids.
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(admin) });
const pay = await app.inject({ method: "POST", url: "/api/pay", headers: hdrs(admin), payload: { identity: "T1", tender: "cash" } });
expect(pay.statusCode).toBe(201);
expect(pay.json().amountMinor).toBe(15000);
const payment = db.select().from(ledgerEvents).all().find((r) => r.type === "payment")!;
expect(payment.payload).toMatchObject({ amountMinor: 15000, grossMinor: 20000, discountMinor: 5000 });
expect((payment.payload as { validationIds?: string[] }).validationIds).toHaveLength(1);
// Settled: the follow-up quote owes 0 and applies nothing further.
const q2 = await app.inject({ method: "GET", url: "/api/pay/quote?identity=T1", headers: hdrs(admin) });
expect(q2.json().amountMinor).toBe(0);
expect(q2.json().validationLines).toEqual([]);
});
it("a full comp settles at 0 through the normal pay path (grace starts, chain verifies)", async () => {
const admin = await seedAdmin();
const { auth: merchant, userId } = await seedMerchant();
await putProgram(admin, { name: "Lavazh falas", mode: "comp", active: true, userIds: [userId] }, "lavazh");
seedTariff(db, { pricePerIncrementMinor: 10000 });
await mint("T1", 90);
await app.inject({
method: "POST",
url: "/api/validation/apply",
headers: hdrs(merchant),
payload: { identity: "T1", programId: "lavazh" },
});
const q = await app.inject({ method: "GET", url: "/api/pay/quote?identity=T1", headers: hdrs(admin) });
expect(q.json().amountMinor).toBe(0);
expect(q.json().grossMinor).toBeGreaterThan(0);
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(admin) });
const pay = await app.inject({ method: "POST", url: "/api/pay", headers: hdrs(admin), payload: { identity: "T1", tender: "cash" } });
expect(pay.statusCode).toBe(201);
expect(pay.json().amountMinor).toBe(0);
// The 0-net settlement still grants walk-back grace (the session reads settled).
const view = await app.inject({ method: "GET", url: "/api/session/T1", headers: hdrs(admin) });
expect(view.json()).toMatchObject({ withinGrace: true, amountMinor: 0 });
});
it("void: own unused only; a consumed validation is locked", async () => {
const admin = await seedAdmin();
const { auth: merchant, userId } = await seedMerchant();
const { auth: other, userId: otherId } = await seedMerchant("tjetri");
await putProgram(admin, fixedProgram(userId, { userIds: [userId, otherId] }));
seedTariff(db, { pricePerIncrementMinor: 10000 });
await mint("T1", 90);
const applied = await app.inject({
method: "POST",
url: "/api/validation/apply",
headers: hdrs(merchant),
payload: { identity: "T1", programId: "bar", amountMinor: 5000 },
});
const eventId = applied.json().eventId as string;
const voidReq = (auth: Auth) =>
app.inject({ method: "POST", url: "/api/validation/void", headers: hdrs(auth), payload: { eventId, identity: "T1" } });
// Someone else's validation → 403. Own → ok, and the quote returns to gross.
expect((await voidReq(other)).statusCode).toBe(403);
expect((await voidReq(merchant)).statusCode).toBe(200);
const q = await app.inject({ method: "GET", url: "/api/pay/quote?identity=T1", headers: hdrs(admin) });
expect(q.json().discountMinor).toBe(0);
// Re-apply (the void freed the per-session slot), consume it with a payment, then
// a void must refuse — the settlement already happened.
const re = await app.inject({
method: "POST",
url: "/api/validation/apply",
headers: hdrs(merchant),
payload: { identity: "T1", programId: "bar", amountMinor: 5000 },
});
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(admin) });
await app.inject({ method: "POST", url: "/api/pay", headers: hdrs(admin), payload: { identity: "T1", tender: "cash" } });
const locked = await app.inject({
method: "POST",
url: "/api/validation/void",
headers: hdrs(merchant),
payload: { eventId: re.json().eventId, identity: "T1" },
});
expect(locked.statusCode).toBe(409);
});
it("maxPerDay caps applications across tickets", async () => {
const admin = await seedAdmin();
const { auth: merchant, userId } = await seedMerchant();
await putProgram(admin, { name: "Lavazh", mode: "comp", maxPerDay: 1, active: true, userIds: [userId] }, "lavazh");
seedTariff(db);
await mint("T1", 60);
await mint("T2", 30);
const apply = (identity: string) =>
app.inject({ method: "POST", url: "/api/validation/apply", headers: hdrs(merchant), payload: { identity, programId: "lavazh" } });
expect((await apply("T1")).statusCode).toBe(201);
expect((await apply("T2")).statusCode).toBe(409);
});
});
+360
View File
@@ -0,0 +1,360 @@
import type { FastifyInstance } from "fastify";
import {
and,
eq,
isNull,
inArray,
ledgerEvents,
users,
validationProgramUsers,
validationPrograms,
type Db,
} from "@parking/db";
import { VALIDATION_MODES, type ValidationMode } from "@parking/shared";
import { requirePermission } from "../auth.js";
import type { EventLog } from "../event-log.js";
import { liveValidations, sessionValidations } from "../validations.js";
// Merchant validations (bar / lavazh). The merchant is VALIDATION-ONLY: they scan the
// customer's ticket on their own device and apply their program — all money and paper
// stay at the booth, which settles net of these events. Program config is admin-composed
// on /setup/site (site:read/update — no dedicated permission); applying is the merchant
// user's `validation:create`, guarded FURTHER by the program↔user binding so a bar user
// can never apply the lavazh program. Every apply/void is a signed, attributed ledger
// event. See wiki/concepts/validation-discounts.md.
// - GET /api/validation/programs : all programs + bound users. (site:read)
// - PUT /api/validation/programs/:id : upsert config + bindings; (site:update)
// signs a config_change.
// - GET /api/validation/mine : my bound ACTIVE programs. (validation:create)
// - GET /api/validation/session/:identity : minimal session view for (validation:create)
// the merchant screen (no money data).
// - POST /api/validation/apply : apply my program (signed). (validation:create)
// - POST /api/validation/void : void my OWN unused apply. (validation:create)
/** Well-formed program ids: kebab slugs ("bar", "lavazh", a future "hotel-2"). */
const ID_RE = /^[a-z][a-z0-9-]{1,31}$/;
interface ProgramBody {
name?: string;
mode?: ValidationMode;
minutes?: number | null;
percent?: number | null;
maxAmountMinor?: number | null;
maxPerDay?: number | null;
active?: boolean;
/** Full replacement set of bound user ids. */
userIds?: string[];
}
interface ApplyBody {
identity: string;
programId: string;
/** fixed mode only: the discount the merchant grants (minor units, ≤ maxAmountMinor). */
amountMinor?: number;
}
interface VoidBody {
eventId: string;
identity: string;
}
/** null when valid, else the 400 message. Checks the per-mode parameter. */
function validateProgram(b: ProgramBody): string | null {
if (!b.name || !String(b.name).trim()) return "name is required";
if (!VALIDATION_MODES.includes(b.mode as ValidationMode)) return "mode must be comp|timeCredit|fixed|percent";
const intOrNull = (v: unknown) => v == null || (Number.isInteger(v) && (v as number) > 0);
if (!intOrNull(b.minutes)) return "minutes must be a positive integer";
if (!intOrNull(b.maxAmountMinor)) return "maxAmountMinor must be a positive integer";
if (!intOrNull(b.maxPerDay)) return "maxPerDay must be a positive integer";
if (b.percent != null && (!Number.isInteger(b.percent) || b.percent < 1 || b.percent > 100))
return "percent must be 1..100";
if (b.mode === "timeCredit" && b.minutes == null) return "timeCredit needs minutes";
if (b.mode === "percent" && b.percent == null) return "percent mode needs percent";
if (b.mode === "fixed" && b.maxAmountMinor == null) return "fixed mode needs maxAmountMinor";
return null;
}
export async function validationRoutes(app: FastifyInstance, db: Db, eventLog: EventLog): Promise<void> {
const siteRead = requirePermission("site:read");
const siteWrite = requirePermission("site:update");
const applyGuard = requirePermission("validation:create");
const liveProgram = (id: string) =>
db
.select()
.from(validationPrograms)
.where(and(eq(validationPrograms.id, id), isNull(validationPrograms.deletedAt)))
.get();
const boundUserIds = (programId: string): string[] =>
db
.select({ userId: validationProgramUsers.userId })
.from(validationProgramUsers)
.where(eq(validationProgramUsers.programId, programId))
.all()
.map((r) => r.userId);
// The setup panel's read: every live program with its bound users.
app.get("/api/validation/programs", { preHandler: siteRead }, async () => {
const programs = db.select().from(validationPrograms).where(isNull(validationPrograms.deletedAt)).all();
return {
programs: programs.map((p) => ({ ...p, userIds: boundUserIds(p.id) })),
};
});
// Upsert a program (the /setup/site checkbox + panel). Creates the well-known row on
// first enable; replaces the binding set; signs an attributed config_change when
// anything actually changed (the entry-presence-bypass precedent — enabling a discount
// program is fraud-relevant config).
app.put<{ Params: { id: string }; Body: ProgramBody }>(
"/api/validation/programs/:id",
{ preHandler: siteWrite },
async (req, reply) => {
const id = (req.params.id ?? "").trim();
if (!ID_RE.test(id)) return reply.code(400).send({ error: "invalid program id" });
const b = req.body ?? ({} as ProgramBody);
const bad = validateProgram(b);
if (bad) return reply.code(400).send({ error: bad });
const userIds = Array.isArray(b.userIds) ? [...new Set(b.userIds)] : [];
if (userIds.length) {
const found = db
.select({ id: users.id })
.from(users)
.where(and(inArray(users.id, userIds), isNull(users.deletedAt)))
.all();
if (found.length !== userIds.length) return reply.code(400).send({ error: "unknown user in userIds" });
}
const prev = liveProgram(id);
const prevUserIds = prev ? boundUserIds(id).sort() : [];
const next = {
name: String(b.name).trim(),
mode: b.mode as ValidationMode,
minutes: b.minutes ?? null,
percent: b.percent ?? null,
maxAmountMinor: b.maxAmountMinor ?? null,
maxPerDay: b.maxPerDay ?? null,
active: b.active === true,
};
if (prev) {
db.update(validationPrograms).set(next).where(eq(validationPrograms.id, id)).run();
} else {
db.insert(validationPrograms).values({ id, ...next }).run();
}
db.delete(validationProgramUsers).where(eq(validationProgramUsers.programId, id)).run();
for (const userId of userIds) {
db.insert(validationProgramUsers).values({ programId: id, userId }).run();
}
// Sign the change (attributed) — enabling/reshaping a discount program is
// fraud-relevant config. Compare against the previous row + binding set so a
// no-op save signs nothing.
const summary = (row: typeof next, ids: string[]) => JSON.stringify({ ...row, userIds: [...ids].sort() });
const prevSummary = prev
? summary(
{ name: prev.name, mode: prev.mode, minutes: prev.minutes, percent: prev.percent,
maxAmountMinor: prev.maxAmountMinor, maxPerDay: prev.maxPerDay, active: prev.active },
prevUserIds,
)
: null;
if (prevSummary !== summary(next, userIds)) {
await eventLog.append({
type: "config_change",
source: "manual",
identity: `validation-program:${id}`,
payload: {
setting: `validationProgram.${id}`,
value: { ...next, userCount: userIds.length },
prev: prev
? { name: prev.name, mode: prev.mode, minutes: prev.minutes, percent: prev.percent,
maxAmountMinor: prev.maxAmountMinor, maxPerDay: prev.maxPerDay, active: prev.active }
: null,
operator: req.user?.username ?? "unknown",
},
});
}
const row = liveProgram(id);
return { ...row, userIds: boundUserIds(id) };
},
);
// The merchant screen's program list: MY bound, active programs.
app.get("/api/validation/mine", { preHandler: applyGuard }, async (req) => {
const rows = db
.select()
.from(validationPrograms)
.innerJoin(validationProgramUsers, eq(validationProgramUsers.programId, validationPrograms.id))
.where(
and(
eq(validationProgramUsers.userId, req.user.sub),
eq(validationPrograms.active, true),
isNull(validationPrograms.deletedAt),
),
)
.all();
return { programs: rows.map((r) => r.validation_programs) };
});
// Minimal session view for the merchant screen — deliberately NO money data (the
// merchant validates; the booth settles): found/open/entry time + the validations
// already on the session (so the UI can show "already validated" and offer void).
app.get<{ Params: { identity: string } }>(
"/api/validation/session/:identity",
{ preHandler: applyGuard },
async (req, reply) => {
const identity = (req.params.identity ?? "").trim();
if (!identity) return reply.code(400).send({ error: "identity required" });
const rows = db
.select({ type: ledgerEvents.type, occurredAt: ledgerEvents.occurredAt, payload: ledgerEvents.payload })
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, identity))
.orderBy(ledgerEvents.index)
.all();
const entry = rows.find((r) => r.type === "vehicle_entry");
if (!entry) return { identity, found: false, open: false, enteredAt: null, subscription: false, validations: [] };
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
const subscription = entryPl.permit === true || entryPl.permitId != null;
const open = !rows.some((r) => r.type === "vehicle_exit" || r.type === "void");
return {
identity,
found: true,
open,
enteredAt: entry.occurredAt,
subscription,
validations: sessionValidations(db, identity),
};
},
);
// APPLY: the merchant's one action. Guards, in order: program live+active → the
// user is BOUND to it → the session is an OPEN TRANSIENT → not already carrying a
// live application of this program → per-day cap → fixed-amount bounds. Appends the
// signed validation event with the RESOLVED values.
app.post<{ Body: ApplyBody }>("/api/validation/apply", { preHandler: applyGuard }, async (req, reply) => {
const identity = (req.body?.identity ?? "").trim();
const programId = (req.body?.programId ?? "").trim();
if (!identity || !programId) return reply.code(400).send({ error: "identity and programId required" });
const program = liveProgram(programId);
if (!program || !program.active) return reply.code(404).send({ error: "program not found or inactive" });
if (!boundUserIds(programId).includes(req.user.sub)) {
return reply.code(403).send({ error: "you are not bound to this program" });
}
// Session state — an open transient (subscriptions are prepaid; nothing to discount).
const rows = db
.select({ type: ledgerEvents.type, payload: ledgerEvents.payload })
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, identity))
.orderBy(ledgerEvents.index)
.all();
const entry = rows.find((r) => r.type === "vehicle_entry");
if (!entry) return reply.code(404).send({ error: "no session for ticket" });
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
if (entryPl.permit === true || entryPl.permitId != null) {
return reply.code(409).send({ error: "subscription sessions cannot be validated" });
}
if (rows.some((r) => r.type === "vehicle_exit" || r.type === "void")) {
return reply.code(409).send({ error: "session is closed" });
}
if (liveValidations(db, identity).some((v) => v.programId === programId)) {
return reply.code(409).send({ error: "this program is already applied to the ticket" });
}
// Per-day cap: unvoided applications of this program since LOCAL midnight (the
// appliance runs in site time).
if (program.maxPerDay != null) {
const midnight = new Date();
midnight.setHours(0, 0, 0, 0);
const todays = db
.select({ id: ledgerEvents.id, occurredAt: ledgerEvents.occurredAt, payload: ledgerEvents.payload, type: ledgerEvents.type })
.from(ledgerEvents)
.where(eq(ledgerEvents.type, "validation"))
.all()
.filter((r) => Date.parse(r.occurredAt) >= midnight.getTime());
const voidedIds = new Set(
todays.map((r) => (r.payload as { refId?: string } | null)?.refId).filter(Boolean) as string[],
);
const count = todays.filter((r) => {
const p = (r.payload ?? {}) as { programId?: string; refId?: string };
return p.programId === programId && !p.refId && !voidedIds.has(r.id);
}).length;
if (count >= program.maxPerDay) {
return reply.code(409).send({ error: "daily cap reached for this program" });
}
}
// Resolve the values off the program row (frozen into the signed event).
let amountMinor: number | undefined;
if (program.mode === "fixed") {
const a = req.body?.amountMinor;
if (a == null || !Number.isInteger(a) || a <= 0) {
return reply.code(400).send({ error: "amountMinor (positive integer) required for this program" });
}
if (program.maxAmountMinor != null && a > program.maxAmountMinor) {
return reply.code(400).send({ error: `amount exceeds the program cap (${program.maxAmountMinor})` });
}
amountMinor = a;
}
const ev = await eventLog.append({
type: "validation",
source: "manual",
identity,
payload: {
sessionRef: identity,
programId,
programLabel: program.name,
mode: program.mode,
...(program.mode === "timeCredit" && program.minutes != null ? { minutes: program.minutes } : {}),
...(program.mode === "percent" && program.percent != null ? { percent: program.percent } : {}),
...(amountMinor != null ? { amountMinor } : {}),
operator: req.user.username,
},
});
return reply.code(201).send({
ok: true,
eventId: ev.id,
programId,
label: program.name,
mode: program.mode,
minutes: program.mode === "timeCredit" ? program.minutes : undefined,
percent: program.mode === "percent" ? program.percent : undefined,
amountMinor,
});
});
// VOID my own UNUSED validation (fat-fingered amount / wrong ticket). Append-only:
// a validation event with refId, never a delete. Refused once a payment consumed it
// (the settlement already happened — that dispute goes to the booth/admin).
app.post<{ Body: VoidBody }>("/api/validation/void", { preHandler: applyGuard }, async (req, reply) => {
const eventId = (req.body?.eventId ?? "").trim();
const identity = (req.body?.identity ?? "").trim();
if (!eventId || !identity) return reply.code(400).send({ error: "eventId and identity required" });
const target = sessionValidations(db, identity).find((v) => v.eventId === eventId);
if (!target) return reply.code(404).send({ error: "validation not found" });
if (target.operator !== req.user.username) {
return reply.code(403).send({ error: "you may only void your own validation" });
}
if (target.voided) return reply.code(409).send({ error: "already voided" });
if (target.consumedBy != null) {
return reply.code(409).send({ error: "already used in a payment — ask the booth/admin" });
}
await eventLog.append({
type: "validation",
source: "manual",
identity,
payload: {
sessionRef: identity,
refId: eventId,
programId: target.programId,
programLabel: target.label,
operator: req.user.username,
},
});
return { ok: true };
});
}
+20 -6
View File
@@ -45,6 +45,7 @@ import { shiftRoutes } from "./routes/shift.js";
import { drawerRoutes } from "./routes/drawer.js";
import { entryRoutes } from "./routes/entry.js";
import { siteRoutes } from "./routes/site.js";
import { validationRoutes } from "./routes/validations.js";
import { snapshotRoutes } from "./routes/snapshots.js";
import { tariffRoutes } from "./routes/tariffs.js";
import { printerRoutes } from "./routes/printers.js";
@@ -292,6 +293,11 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// at capacity) is in the entry flow. See wiki/concepts/capacity-occupancy.md.
await siteRoutes(app, db, eventLog);
// Merchant validations (bar / lavazh): setup panel config + the merchant user's
// scan-and-apply. The booth settlement folds the applied validations into its
// quote (pay-station.ts). See wiki/concepts/validation-discounts.md.
await validationRoutes(app, db, eventLog);
// Application logs: ingest frontend errors (POST /api/logs, any signed-in user) +
// read the store (GET /api/logs, log:read). See wiki/concepts/app-logs.md.
await logRoutes(app, logService);
@@ -330,12 +336,20 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
void runSnapPrune(); // once at startup
app.addHook("onClose", async () => clearInterval(snapPruneTimer));
// Scheduled encrypted backup — daily, unref'd. A no-op (silent) until BACKUP_TARGET_DIR +
// BACKUP_KEY are configured; tolerates an unreachable/unmounted target by recording the
// error and trying again next run. NOT run once at startup (a just-booted appliance after a
// power cut shouldn't immediately write to a possibly-not-yet-mounted disk; the daily cadence
// and the manual button cover it). See wiki/concepts/backup-recovery.md.
const backupTimer = setInterval(() => void backupService.runScheduled(), 24 * 60 * 60 * 1000);
// Scheduled encrypted backup — checked every 15 min, unref'd; `runScheduled()` itself is a
// no-op unless a full 24h has actually elapsed since the last PERSISTED success (isDue(), in
// backup-service.ts), so this frequent poll does not cause frequent backups. Deliberately
// NOT a `setInterval(..., 24h)` measured from process start: that design silently reset its
// own countdown on every restart (deploy/crash/OOM/reboot, all routine under `restart:
// always`), which could push a day's backup out arbitrarily far AND — before last-success was
// persisted — made the admin UI show "Never" despite valid backups already on disk
// (2026-08-30 field incident, park-buzi). A short poll against a persisted, wall-clock
// timestamp is immune to both restart timing and to any single restart cadence. A no-op
// (silent) until BACKUP_TARGET_DIR + BACKUP_KEY are configured; tolerates an
// unreachable/unmounted target by recording the error and trying again next check. NOT run
// once at startup (a just-booted appliance after a power cut shouldn't immediately write to a
// possibly-not-yet-mounted disk). See wiki/concepts/backup-recovery.md.
const backupTimer = setInterval(() => void backupService.runScheduled(), 15 * 60 * 1000);
backupTimer.unref();
app.addHook("onClose", async () => clearInterval(backupTimer));
if (backupService.configured) {
+7
View File
@@ -234,4 +234,11 @@ describe("close signs a Z-report; listShifts reads it back", () => {
await shift.open("bob"); await shift.close("bob");
expect(shift.listShifts({ operator: "alice" }).map((s) => s.operator)).toEqual(["alice"]);
});
it("listOperators: distinct + sorted, includes the OPEN shift's operator", async () => {
await shift.open("bob"); await shift.close("bob");
await shift.open("bob"); await shift.close("bob"); // twice — must stay distinct
await shift.open("alice"); // open, no z-report yet
expect(shift.listOperators()).toEqual(["alice", "bob"]);
});
});
+46 -7
View File
@@ -55,6 +55,7 @@ export interface ShiftSummary {
readonly subscriptionTotalMinor: number;
readonly subscriptionSalesMinor: number;
readonly subscriptionWindowMinor: number;
readonly discountTotalMinor: number;
readonly openingFloatMinor: number;
readonly cashAddedMinor: number;
readonly cashRemovedMinor: number;
@@ -78,6 +79,9 @@ export interface ShiftReport {
readonly subscriptionSalesMinor: number;
/** Subscriber OUT-OF-WINDOW transient-tariff charges only. */
readonly subscriptionWindowMinor: number;
/** Merchant-validation DISCOUNT total given away in the window (leakage — the
* cash/card figures above are already NET of it). See validation-discounts.md. */
readonly discountTotalMinor: number;
// --- Drawer (physical cash till; carries across shifts) ---
/** Cash in the drawer at shift start = prior shift's expected closing drawer. */
readonly openingFloatMinor: number;
@@ -177,6 +181,28 @@ export class ShiftService {
* The open shift (no z_report yet) is intentionally excluded — it's not a
* completed accountability period. Use `currentOpenShift()` for the live one.
*/
/**
* Every operator that HAS a shift (closed z_reports + the open one, if any),
* distinct + sorted — feeds the admin filter dropdown so it can only ever ask
* for an operator that exists (the filter is an exact username match).
*/
listOperators(): string[] {
const rows = this.#db
.select()
.from(ledgerEvents)
.where(eq(ledgerEvents.type, "shift_z_report"))
.all();
const names = new Set<string>();
for (const r of rows) {
const op = ((r.payload ?? {}) as { operator?: string }).operator ?? r.identity;
if (op) names.add(op);
}
const open = this.currentOpenShift();
const openOp = open ? (((open.payload ?? {}) as { operator?: string }).operator ?? open.identity) : null;
if (openOp) names.add(openOp);
return [...names].sort((a, b) => a.localeCompare(b));
}
listShifts(opts: { operator?: string; from?: string; to?: string } = {}): ShiftSummary[] {
const rows = this.#db
.select()
@@ -198,6 +224,7 @@ export class ShiftService {
subscriptionTotalMinor?: number;
subscriptionSalesMinor?: number;
subscriptionWindowMinor?: number;
discountTotalMinor?: number;
openingFloatMinor?: number;
cashAddedMinor?: number;
cashRemovedMinor?: number;
@@ -228,6 +255,8 @@ export class ShiftService {
ticketTotalMinor:
pl.ticketTotalMinor ??
(pl.cashTotalMinor ?? 0) + (pl.cardTotalMinor ?? 0) - (pl.subscriptionTotalMinor ?? 0),
// Merchant-validation leakage (added 2026-07-13). Old reports lack it → 0.
discountTotalMinor: pl.discountTotalMinor ?? 0,
openingFloatMinor: pl.openingFloatMinor ?? 0,
cashAddedMinor: pl.cashAddedMinor ?? 0,
cashRemovedMinor: pl.cashRemovedMinor ?? 0,
@@ -500,6 +529,9 @@ export class ShiftService {
// the subscription sale path).
let subscriptionSalesMinor = 0;
let subscriptionWindowMinor = 0;
// Merchant-validation leakage: Σ discountMinor across the window's payments. The
// tender totals are already NET; this is the "given away" figure beside them.
let discountTotalMinor = 0;
let currency: string | null = null;
for (const p of payments) {
const pl = (p.payload ?? {}) as LedgerPayload & {
@@ -512,6 +544,7 @@ export class ShiftService {
if (pl.subscriptionSale === true) subscriptionSalesMinor += amt;
else if (pl.subscriptionWindowCharge === true) subscriptionWindowMinor += amt;
// (else → transient ticket; derived below as total − subscription)
if (typeof pl.discountMinor === "number") discountTotalMinor += pl.discountMinor;
if (pl.currency) currency = pl.currency;
}
const subscriptionTotalMinor = subscriptionSalesMinor + subscriptionWindowMinor;
@@ -567,6 +600,7 @@ export class ShiftService {
subscriptionTotalMinor,
subscriptionSalesMinor,
subscriptionWindowMinor,
discountTotalMinor,
openingFloatMinor,
cashAddedMinor,
cashRemovedMinor,
@@ -605,6 +639,7 @@ export class ShiftService {
subscriptionTotalMinor,
subscriptionSalesMinor,
subscriptionWindowMinor,
discountTotalMinor,
openingFloatMinor,
cashAddedMinor,
cashRemovedMinor,
@@ -627,6 +662,7 @@ export class ShiftService {
subscriptionTotalMinor,
subscriptionSalesMinor,
subscriptionWindowMinor,
discountTotalMinor,
openingFloatMinor,
cashAddedMinor,
cashRemovedMinor,
@@ -668,15 +704,18 @@ export class ShiftService {
`Bileta: ${money(r.ticketTotalMinor)} ${cur}`,
// Abonime is the subscription TOTAL; only the out-of-window part is broken out.
// (subscriptionSalesMinor stays in the signed payload — it's just not printed.)
`Abonime: ${money(r.subscriptionTotalMinor)} ${cur}`,
` jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`,
`Abonime: ${money(r.subscriptionTotalMinor)} ${cur}`,
`Jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`,
// Merchant-validation leakage — printed only when the shift actually gave any
// (older slips stay byte-identical). The takings above are already NET of it.
...(r.discountTotalMinor > 0 ? [`Zbritje (validime): ${money(r.discountTotalMinor)} ${cur}`] : []),
"",
"-- Arka --",
`Fillimi (kusur): ${money(r.openingFloatMinor)} ${cur}`,
`Para të marra: ${money(r.cashTotalMinor)} ${cur}`,
`Para të shtuara: ${money(r.cashAddedMinor)} ${cur}`,
`Para të hequra: ${money(r.cashRemovedMinor)} ${cur}`,
`Arka e pritur: ${money(r.expectedDrawerMinor)} ${cur}`,
`Gjëndje fillestare: ${money(r.openingFloatMinor)} ${cur}`,
`Para të grumbulluara: ${money(r.cashTotalMinor)} ${cur}`,
`Arkëtime: ${money(r.cashAddedMinor)} ${cur}`,
`Pagesa: ${money(r.cashRemovedMinor)} ${cur}`,
`Gjëndje aktuale: ${money(r.expectedDrawerMinor)} ${cur}`,
];
try {
await printer.printReport({ title: "RAPORT TURNI", lines });
+88
View File
@@ -0,0 +1,88 @@
import { eq, ledgerEvents, type Db } from "@parking/db";
import type { SessionValidation, ValidationMode } from "@parking/shared";
// Merchant-validation ledger folds. A validation is a SIGNED, appended event on the
// session (never a mutable flag): payload carries the RESOLVED values (programId,
// label, mode, minutes/amountMinor/percent) + the merchant username. A validation
// event with `refId` set VOIDS the referenced one; a payment's `validationIds` marks
// which validations it CONSUMED (so an overstay's fresh period never re-applies
// them). See wiki/concepts/validation-discounts.md.
/** A validation event folded with its lifecycle state. */
export interface AppliedValidation extends SessionValidation {
readonly eventId: string;
readonly occurredAt: string;
/** The merchant username who applied it. */
readonly operator: string | null;
/** Voided by a later validation event referencing it. */
readonly voided: boolean;
/** The payment event id that consumed it, if settled. */
readonly consumedBy: string | null;
}
/** All validations ever applied to a session (newest last), with voided/consumed
* state folded from the chain. One identity-scoped ledger scan. */
export function sessionValidations(db: Db, identity: string): AppliedValidation[] {
const rows = db
.select({
id: ledgerEvents.id,
type: ledgerEvents.type,
occurredAt: ledgerEvents.occurredAt,
payload: ledgerEvents.payload,
})
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, identity))
.orderBy(ledgerEvents.index)
.all();
const voided = new Set<string>();
const consumedBy = new Map<string, string>();
const applies: AppliedValidation[] = [];
for (const r of rows) {
const p = (r.payload ?? {}) as {
refId?: string;
programId?: string;
programLabel?: string;
mode?: ValidationMode;
minutes?: number;
amountMinor?: number;
percent?: number;
operator?: string;
validationIds?: string[];
};
if (r.type === "validation") {
if (p.refId) {
voided.add(p.refId);
} else if (p.programId && p.mode) {
applies.push({
eventId: r.id,
occurredAt: r.occurredAt,
programId: p.programId,
label: p.programLabel ?? p.programId,
mode: p.mode,
...(typeof p.minutes === "number" ? { minutes: p.minutes } : {}),
...(typeof p.amountMinor === "number" ? { amountMinor: p.amountMinor } : {}),
...(typeof p.percent === "number" ? { percent: p.percent } : {}),
operator: p.operator ?? null,
voided: false,
consumedBy: null,
});
}
} else if (r.type === "payment" && Array.isArray(p.validationIds)) {
for (const vid of p.validationIds) consumedBy.set(vid, r.id);
}
}
return applies.map((a) => ({
...a,
voided: voided.has(a.eventId),
consumedBy: consumedBy.get(a.eventId) ?? null,
}));
}
/** The LIVE validations for pricing: applied, not voided, not consumed by a prior
* payment. This is exactly what `priceSession(..., validations)` expects. */
export function liveValidations(db: Db, identity: string): AppliedValidation[] {
return sessionValidations(db, identity).filter((v) => !v.voided && v.consumedBy == null);
}
+4
View File
@@ -4,6 +4,10 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:," />
<!-- Self-hosted primary face (offline appliance — no webfont CDN). Preload the
two weights on every screen so first paint doesn't flash the fallback. -->
<link rel="preload" href="/fonts/chakra-petch/chakra-petch-latin-400.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/fonts/chakra-petch/chakra-petch-latin-600.woff2" as="font" type="font/woff2" crossorigin />
<title>Parking System</title>
</head>
<body>
+2
View File
@@ -18,8 +18,10 @@
"@radix-ui/react-tabs": "^1.1.15",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-router": "^1.170.16",
"@tauri-apps/plugin-http": "^2.5.2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"@tauri-apps/plugin-websocket": "^2.3.0",
"i18next": "^26.3.1",
"react": "19.2.7",
"react-dom": "19.2.7",
@@ -0,0 +1,93 @@
Copyright 2018 The Chakra Petch Project Authors (https://github.com/m4rc1e/Chakra-Petch.git)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+42 -7
View File
@@ -18,9 +18,10 @@ import {
import { rootRoute } from "./router.js";
import { qk } from "./lib/query.js";
import { useShift } from "./lib/use-shift.js";
import { formatDuration, formatMoney, formatTime, formatRelativeDateTime } from "./lib/format.js";
import { formatDuration, formatMoney, formatRelativeDateTime } from "./lib/format.js";
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
import { SnapshotStrip } from "./ui/SnapshotStrip.js";
import { Spinner } from "./ui/Spinner.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
@@ -281,7 +282,13 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
disabled={openingShift}
className="btn btn-go btn-sm mt-2"
>
{openingShift ? t("shift.opening") : t("shift.openNow")}
{openingShift ? (
<span className="inline-flex items-center gap-1.5">
<Spinner /> {t("shift.opening")}
</span>
) : (
t("shift.openNow")
)}
</button>
</>
)}
@@ -303,12 +310,12 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
// figures, and the snapshot strip read-only. No tender / voucher / open here.
<>
<div className="rounded-term border border-term-amber px-3 py-2 text-term-amber">
{t("pay.alreadyClosed", { time: formatTime(s.exitedAt) })}
{t("pay.alreadyClosed", { time: formatRelativeDateTime(s.exitedAt, t, { seconds: true }) })}
</div>
<div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums">
<Row label={t("pay.entry")} value={formatRelativeDateTime(s.enteredAt, t)} />
<Row label={t("pay.exit")} value={formatTime(s.exitedAt)} />
<Row label={t("pay.entry")} value={formatRelativeDateTime(s.enteredAt, t, { seconds: true })} />
<Row label={t("pay.exit")} value={formatRelativeDateTime(s.exitedAt, t, { seconds: true })} />
<Row
label={t("pay.duration")}
value={
@@ -328,11 +335,15 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
<>
{/* Session figures */}
<div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums">
<Row label={t("pay.entry")} value={formatRelativeDateTime(s.enteredAt, t)} />
<Row label={t("pay.entry")} value={formatRelativeDateTime(s.enteredAt, t, { seconds: true })} />
{/* Closed-within-grace shows the recorded EXIT; an open session shows now. */}
<Row
label={closedWithinGrace ? t("pay.exit") : t("pay.now")}
value={closedWithinGrace ? formatTime(s.exitedAt) : formatTime(new Date().toISOString())}
value={formatRelativeDateTime(
closedWithinGrace ? s.exitedAt : new Date().toISOString(),
t,
{ seconds: true },
)}
/>
<Row
label={t("pay.duration")}
@@ -372,6 +383,30 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
/>
</div>
{/* Merchant validations (bar/lavazh): the gross fee + one line per
discount — the Total below is the NET the customer pays. The lines
ride the quote (SessionLookup.validationLines) and reprint on the
receipt. See wiki/concepts/validation-discounts.md. */}
{!isSubscription &&
(s.validationLines ?? []).length > 0 &&
s.currency != null &&
s.amountMinor != null && (
<div className="rounded-term bg-term-panel-2 px-3 py-2 text-[0.75rem]">
<div className="flex justify-between text-term-text">
<span>{t("val.gross")}</span>
<span className="tabular-nums">
{formatMoney(s.grossMinor ?? s.amountMinor, s.currency)}
</span>
</div>
{(s.validationLines ?? []).map((v, i) => (
<div key={i} className="flex justify-between text-term-green">
<span>{v.label}</span>
<span className="tabular-nums">−{formatMoney(v.discountMinor, s.currency!)}</span>
</div>
))}
</div>
)}
{/* Total — a subscription is prepaid (no amount) UNLESS it owes an
out-of-window window charge; then show that amount. For an overstay the
amount is the TOP-UP delta, not the whole stay. */}
+329 -61
View File
@@ -2,23 +2,39 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
fetchDrawerBalance,
fetchDrawerMovements,
fetchEvents,
fetchShift,
fetchShiftReport,
fetchShifts,
recordDrawerMovement,
reviewDrawerMovement,
type DrawerMovement,
type MovementStatus,
type ShiftSummary,
} from "./api.js";
import { formatMoney, formatRelativeDateTime } from "./lib/format.js";
import { formatClock, formatMoney, formatRelativeDateTime } from "./lib/format.js";
import { Panel } from "./ui/Panel.js";
import type { LedgerEvent } from "@parking/shared";
// Drawer cash movements. Operators RECORD receipts (Mandat Arkëtimi / cash_in) and
// disbursements (Mandat Pagese / cash_out) freely; admins REVIEW them after the fact
// (authorize/deny — a flag, never a cash reversal). A denial is a judgment about the
// operator, settled outside the app: the drawer balance is untouched. See
// The DRAWER HUB (redesigned 2026-07-05 — was only record + review). One screen
// answers "what's in the till and why": the CURRENT drawer balance with the open
// shift's running breakdown (float + takings + vouchers = expected), TODAY's cash
// activity (every cash payment and voucher, live), the movement record/review flow
// (unchanged), and the closed-shift drawer history. All figures come from the signed
// chain — the drawer is a single site-wide till that carries across shifts. See
// wiki/concepts/shift.md.
const money = (m: number, cur: string | null) => formatMoney(m, cur ?? "");
/** Local midnight, ISO — the "today" window for the activity feed. */
function startOfToday(): string {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d.toISOString();
}
function StatusBadge({ status }: { status: MovementStatus }) {
const { t } = useTranslation();
const cls =
@@ -37,6 +53,198 @@ function StatusBadge({ status }: { status: MovementStatus }) {
export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; canReview: boolean }) {
const { t } = useTranslation();
const qc = useQueryClient();
const refresh = () => {
void qc.invalidateQueries({ queryKey: ["drawer"] });
// A voucher moves the open shift's added/removed figures too (the X-report).
void qc.invalidateQueries({ queryKey: ["shift"] });
};
return (
<div className="flex h-full min-h-0 flex-col gap-3 overflow-y-auto p-3 lg:overflow-hidden">
{/* Row 1: the till NOW + the record form. */}
<div className="grid shrink-0 gap-3 lg:grid-cols-[1.3fr_1fr]">
<StatePanel />
{canCreate && <RecordPanel onDone={refresh} />}
</div>
{/* Row 2: today's cash feed · the movement review queue · closed shifts. */}
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-3">
<TodayPanel />
<MovementsPanel canReview={canReview} onChanged={refresh} />
<ShiftHistoryPanel />
</div>
</div>
);
}
// --- The drawer NOW ---------------------------------------------------------
// Balance from the chain + the open shift's running X-report breakdown, so the big
// number is always explainable: float + cash takings + in − out = expected = balance.
function StatePanel() {
const { t } = useTranslation();
const balance = useQuery({ queryKey: ["drawer", "balance"], queryFn: fetchDrawerBalance, refetchInterval: 10_000 });
const status = useQuery({ queryKey: ["shift", "current"], queryFn: fetchShift });
const report = useQuery({
queryKey: ["shift", "xreport"],
queryFn: fetchShiftReport,
enabled: status.data?.open != null,
refetchInterval: 10_000,
});
const x = status.data?.open ? report.data : null;
const cur = balance.data?.currency ?? x?.currency ?? null;
// The current SHIFT's own balance: what this shift changed in the till
// (takings + vouchers), i.e. everything above the inherited opening float.
const shiftDelta = x ? x.expectedDrawerMinor - x.openingFloatMinor : null;
return (
<Panel title={t("drawer.stateTitle")}>
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<div className="text-3xl font-bold text-term-cyan tabular-nums">
{balance.data ? money(balance.data.balanceMinor, cur) : "…"}
</div>
{shiftDelta != null && (
<div className="mt-0.5 text-[0.8125rem] tabular-nums">
<span className="text-term-muted">{t("drawer.thisShift")} </span>
<span className={shiftDelta < 0 ? "font-semibold text-term-red" : "font-semibold text-term-green"}>
{shiftDelta >= 0 ? "+" : ""}
{money(shiftDelta, cur)}
</span>
</div>
)}
<div className="mt-0.5 text-[0.6875rem] text-term-muted">
{status.data?.open
? t("drawer.openShift", { operator: status.data.open.operator }) +
" · " +
formatRelativeDateTime(status.data.open.startedAt, t)
: t("drawer.noShiftOpen")}
</div>
</div>
{/* The running breakdown, only while a shift is open (it's the X-report). */}
{x && (
<dl className="grid grid-cols-[max-content_max-content] gap-x-4 gap-y-0.5 text-[0.75rem] tabular-nums">
<dt className="text-term-muted">{t("shifts.openingFloat")}</dt>
<dd className="text-right text-term-text">{money(x.openingFloatMinor, cur)}</dd>
<dt className="text-term-muted">
{t("shifts.cashTaken")} · {t("shifts.payments")} {x.paymentCount}
</dt>
<dd className="text-right text-term-green">{money(x.cashTotalMinor, cur)}</dd>
<dt className="text-term-muted">{t("shifts.cashAdded")}</dt>
<dd className="text-right text-term-text">{money(x.cashAddedMinor, cur)}</dd>
<dt className="text-term-muted">{t("shifts.cashRemoved")}</dt>
<dd className="text-right text-term-red">{money(-x.cashRemovedMinor, cur)}</dd>
<dt className="border-t border-term-border pt-0.5 font-semibold text-term-muted">{t("shifts.expectedDrawer")}</dt>
<dd className="border-t border-term-border pt-0.5 text-right font-semibold text-term-text">
{money(x.expectedDrawerMinor, cur)}
</dd>
</dl>
)}
</div>
</Panel>
);
}
// --- Today's cash activity ---------------------------------------------------
// Every drawer-touching event since local midnight: cash payments (the current
// shift's incomings, live) + vouchers. Card payments never enter the till.
function TodayPanel() {
const { t } = useTranslation();
const q = useQuery({
queryKey: ["drawer", "today"],
queryFn: () => fetchEvents(1000, startOfToday()),
refetchInterval: 15_000,
});
const rows = (q.data?.events ?? []).filter((e) => {
if (e.type === "cash_in" || e.type === "cash_out") return true;
if (e.type !== "payment") return false;
return (e.payload as { tender?: string } | null)?.tender !== "card";
});
let cashIn = 0;
let vouchersNet = 0;
let payments = 0;
let cur: string | null = null;
for (const e of rows) {
const pl = (e.payload ?? {}) as { amountMinor?: number; currency?: string };
const amt = pl.amountMinor ?? 0;
if (pl.currency) cur = pl.currency;
if (e.type === "payment") {
cashIn += amt;
payments++;
} else {
vouchersNet += e.type === "cash_in" ? Math.abs(amt) : -Math.abs(amt);
}
}
return (
<Panel
title={t("drawer.todayTitle")}
right={
rows.length > 0 ? (
<span className="text-[0.6875rem] tabular-nums text-term-muted">
{t("drawer.todayPayments", { count: payments })} · <span className="text-term-green">{money(cashIn, cur)}</span>
{vouchersNet !== 0 && (
<>
{" "}
· <span className={vouchersNet < 0 ? "text-term-red" : "text-term-green"}>{money(vouchersNet, cur)}</span>
</>
)}
</span>
) : null
}
className="min-h-0"
>
<div className="h-full min-h-0 overflow-y-auto pr-1">
{q.isError ? (
<div className="text-[0.75rem] text-term-red">{(q.error as Error).message}</div>
) : q.isLoading ? (
<div className="text-term-muted">{t("common.loading")}</div>
) : rows.length === 0 ? (
<div className="text-term-muted">{t("drawer.noActivity")}</div>
) : (
<table className="w-full text-[0.75rem] tabular-nums">
<tbody>
{rows.map((e) => (
<TodayRow key={e.id} e={e} />
))}
</tbody>
</table>
)}
</div>
</Panel>
);
}
function TodayRow({ e }: { e: LedgerEvent }) {
const { t } = useTranslation();
const pl = (e.payload ?? {}) as { amountMinor?: number; currency?: string; voucherNo?: string; reason?: string };
const amt = pl.amountMinor ?? 0;
const signed = e.type === "cash_out" ? -Math.abs(amt) : Math.abs(amt);
const time = formatClock(e.occurredAt);
const label =
e.type === "payment"
? `${t("drawer.payment")}${e.identity ? ` · ${e.identity}` : ""}`
: `${e.type === "cash_in" ? t("drawer.mandatArketimi") : t("drawer.mandatPagese")}${pl.voucherNo ? ` ${pl.voucherNo}` : ""}`;
return (
<tr className="border-t border-term-border/40">
<td className="whitespace-nowrap py-1 pr-2 text-term-muted">{time}</td>
<td className="max-w-0 truncate py-1 pr-2 text-term-text" title={pl.reason || undefined}>
{label}
</td>
<td className={`whitespace-nowrap py-1 text-right ${signed < 0 ? "text-term-red" : "text-term-green"}`}>
{money(signed, pl.currency ?? null)}
</td>
</tr>
);
}
// --- Movements (record + review) — the pre-redesign feature, unchanged ------
function MovementsPanel({ canReview, onChanged }: { canReview: boolean; onChanged: () => void }) {
const { t } = useTranslation();
// Reviewers can filter the list (the pending queue); operators always see their own, all.
const [statusFilter, setStatusFilter] = useState<MovementStatus | "">("");
const q = useQuery({
@@ -47,68 +255,128 @@ export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; ca
const pendingCount = movements.filter((m) => m.status === "pending").length;
return (
<div className="flex h-full flex-col gap-3 p-3">
{canCreate && <RecordPanel onDone={() => void qc.invalidateQueries({ queryKey: ["drawer"] })} />}
<Panel
title={canReview ? t("drawer.allTitle") : t("drawer.myTitle")}
right={
canReview && pendingCount > 0 ? (
<span className="rounded border border-term-amber/60 px-1.5 text-[0.625rem] uppercase tracking-wider text-term-amber">
{t("drawer.pendingCount", { count: pendingCount })}
</span>
) : null
}
className="min-h-0 flex-1"
>
<div className="flex h-full flex-col">
{canReview && (
<div className="mb-2 flex items-center gap-1.5">
{(["", "pending", "authorized", "denied"] as const).map((s) => (
<button
key={s || "all"}
type="button"
onClick={() => setStatusFilter(s)}
className={statusFilter === s ? "btn btn-primary btn-sm" : "btn btn-sm"}
>
{s === "" ? t("drawer.filterAll") : t(`drawer.status.${s}`)}
</button>
))}
</div>
)}
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
{q.isLoading ? (
<div className="text-term-muted">{t("common.loading")}</div>
) : movements.length === 0 ? (
<div className="text-term-muted">{t("drawer.empty")}</div>
) : (
<table className="w-full text-[0.75rem] tabular-nums">
<thead className="sticky top-0 bg-term-panel-2 text-[0.6875rem] uppercase tracking-wider text-term-muted">
<tr>
<th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colWhen")}</th>
<th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colType")}</th>
<th className="px-2 py-1.5 text-right font-semibold">{t("drawer.colAmount")}</th>
<th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colReason")}</th>
{canReview && <th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colOperator")}</th>}
<th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colStatus")}</th>
{canReview && <th className="px-2 py-1.5" />}
</tr>
</thead>
<tbody>
{movements.map((m) => (
<MovementRow key={m.id} m={m} canReview={canReview} onReviewed={() => void qc.invalidateQueries({ queryKey: ["drawer"] })} />
))}
</tbody>
</table>
)}
<Panel
title={canReview ? t("drawer.allTitle") : t("drawer.myTitle")}
right={
canReview && pendingCount > 0 ? (
<span className="rounded border border-term-amber/60 px-1.5 text-[0.625rem] uppercase tracking-wider text-term-amber">
{t("drawer.pendingCount", { count: pendingCount })}
</span>
) : null
}
className="min-h-0"
>
<div className="flex h-full min-h-0 flex-col">
{canReview && (
<div className="mb-2 flex items-center gap-1.5">
{(["", "pending", "authorized", "denied"] as const).map((s) => (
<button
key={s || "all"}
type="button"
onClick={() => setStatusFilter(s)}
className={statusFilter === s ? "btn btn-primary btn-sm" : "btn btn-sm"}
>
{s === "" ? t("drawer.filterAll") : t(`drawer.status.${s}`)}
</button>
))}
</div>
)}
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
{q.isLoading ? (
<div className="text-term-muted">{t("common.loading")}</div>
) : movements.length === 0 ? (
<div className="text-term-muted">{t("drawer.empty")}</div>
) : (
<table className="w-full text-[0.75rem] tabular-nums">
<thead className="sticky top-0 bg-term-panel-2 text-[0.6875rem] uppercase tracking-wider text-term-muted">
<tr>
<th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colWhen")}</th>
<th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colType")}</th>
<th className="px-2 py-1.5 text-right font-semibold">{t("drawer.colAmount")}</th>
<th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colReason")}</th>
{canReview && <th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colOperator")}</th>}
<th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colStatus")}</th>
{canReview && <th className="px-2 py-1.5" />}
</tr>
</thead>
<tbody>
{movements.map((m) => (
<MovementRow key={m.id} m={m} canReview={canReview} onReviewed={onChanged} />
))}
</tbody>
</table>
)}
</div>
</Panel>
</div>
</Panel>
);
}
// --- Closed shifts, drawer-focused -------------------------------------------
// Scope follows /api/shifts: operators see their own, admins all.
function ShiftHistoryPanel() {
const { t } = useTranslation();
const q = useQuery({ queryKey: ["shifts", "drawer-history"], queryFn: () => fetchShifts() });
const shifts = (q.data?.shifts ?? []).slice(0, 50);
const showOperator = q.data?.scope === "all";
return (
<Panel title={t("drawer.historyTitle")} className="min-h-0">
<div className="h-full min-h-0 overflow-y-auto pr-1">
{q.isLoading ? (
<div className="text-term-muted">{t("common.loading")}</div>
) : shifts.length === 0 ? (
<div className="text-term-muted">{t("drawer.noShifts")}</div>
) : (
<div className="flex flex-col gap-1.5">
{shifts.map((s) => (
<ShiftDrawerCard key={s.id} s={s} showOperator={showOperator} />
))}
</div>
)}
</div>
</Panel>
);
}
function ShiftDrawerCard({ s, showOperator }: { s: ShiftSummary; showOperator: boolean }) {
const { t } = useTranslation();
const cur = s.currency;
return (
<div className="card p-2.5 text-[0.75rem]">
<div className="flex items-center justify-between gap-2">
<span className="font-semibold text-term-text">
{showOperator ? `${s.operator} · ` : ""}
{formatRelativeDateTime(s.startedAt, t)}
</span>
<span className="font-semibold text-term-text tabular-nums" title={t("shifts.expectedDrawer")}>
{money(s.expectedDrawerMinor, cur)}
</span>
</div>
<div className="mt-0.5 flex flex-wrap gap-x-3 text-term-muted tabular-nums">
<span title={t("shifts.openingFloat")}>{money(s.openingFloatMinor, cur)} →</span>
<span className="text-term-green" title={t("shifts.cashTaken")}>
+{money(s.cashTotalMinor, cur)}
</span>
{s.cashAddedMinor > 0 && (
<span className="text-term-green" title={t("shifts.cashAdded")}>
+{money(s.cashAddedMinor, cur)}
</span>
)}
{s.cashRemovedMinor > 0 && (
<span className="text-term-red" title={t("shifts.cashRemoved")}>
−{money(s.cashRemovedMinor, cur)}
</span>
)}
</div>
</div>
);
}
// --- Record form (unchanged from the pre-redesign feature) ------------------
function RecordPanel({ onDone }: { onDone: () => void }) {
const { t } = useTranslation();
const [amount, setAmount] = useState("");
+18 -1
View File
@@ -25,6 +25,10 @@ function LogRow({ log }: { log: AppLogRecord }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const hasDetail = (log.context && Object.keys(log.context).length > 0) || log.stack;
// Storm-coalesced row: the server folds repeated identical lines into one row and
// counts them in context._repeat (first occurrence kept in _firstAt).
const repeat = typeof log.context?._repeat === "number" ? (log.context?._repeat as number) : null;
const firstAt = typeof log.context?._firstAt === "string" ? (log.context?._firstAt as string) : null;
return (
<div className={`border-b border-term-border/50 ${log.level === "error" || log.level === "fatal" ? "bg-term-red/5" : ""}`}>
@@ -38,7 +42,20 @@ function LogRow({ log }: { log: AppLogRecord }) {
<span className="text-term-muted tabular-nums">{formatRelativeDateTime(log.createdAt, t)}</span>
<span className={`font-semibold uppercase ${LEVEL_COLOR[log.level]}`}>{log.level}</span>
<span className="text-term-muted">{t(log.source === "frontend" ? "logs.frontend" : "logs.backend")}</span>
<span className="truncate text-term-text">{log.message}</span>
<span className="truncate text-term-text">
{repeat != null && repeat > 1 && (
<span
className="mr-1.5 rounded-term border border-term-amber/50 px-1 text-[0.625rem] font-semibold text-term-amber"
title={t("logs.repeated", {
count: repeat,
firstAt: firstAt ? formatRelativeDateTime(firstAt, t) : "—",
})}
>
×{repeat}
</span>
)}
{log.message}
</span>
<span className="text-term-muted tabular-nums">{log.httpStatus ?? ""}</span>
</button>
{open && hasDetail && (
+95 -12
View File
@@ -1,8 +1,10 @@
import { useMemo, useState } from "react";
import { Fragment, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { useQuery } from "@tanstack/react-query";
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
@@ -12,6 +14,7 @@ import {
LineChart,
Pie,
PieChart,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
@@ -37,6 +40,7 @@ const C = {
border: "#2a2f38",
text: "#f2f2ee",
panel: "#14171c",
panel2: "#1e222a",
};
type PresetKey = "today" | "7d" | "30d" | "90d";
@@ -140,27 +144,37 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
...p,
label: data.bucket === "hour" ? p.bucket.slice(11) + "h" : p.bucket,
revenue: p.revenueMinor / 100,
cash: p.cashMinor / 100,
card: p.cardMinor / 100,
}));
const hours = data.entriesByHour.map((entries, h) => ({ hour: `${h}`, entries }));
const mix = [
{ name: t("reports.mix.ticket"), value: tot.ticketMinor, color: C.amber },
{ name: t("reports.mix.subSales"), value: tot.subscriptionSalesMinor, color: C.cyan },
{ name: t("reports.mix.subWindow"), value: tot.subscriptionWindowMinor, color: C.green },
].filter((s) => s.value > 0);
const peakOcc = Math.max(data.occupancyStart, ...data.series.map((p) => p.occupancyEnd));
// Stay-duration bars: "≤30m … ≤24h" + the open-ended tail.
const stay = data.stayHistogram.map((b) => ({
label: b.uptoMin == null ? `>24${t("reports.stay.h")}` : b.uptoMin < 60 ? `≤${b.uptoMin}${t("reports.stay.m")}` : `≤${b.uptoMin / 60}${t("reports.stay.h")}`,
count: b.count,
}));
return (
<div className="space-y-4">
{/* KPI cards. */}
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4 xl:grid-cols-8">
<Kpi label={t("reports.kpi.entries")} value={String(tot.entries)} accent="green" />
<Kpi label={t("reports.kpi.exits")} value={String(tot.exits)} accent="red" />
<Kpi label={t("reports.kpi.revenue")} value={money(tot.revenueMinor)} accent="amber" />
<Kpi label={t("reports.kpi.payments")} value={String(tot.payments)} accent="cyan" />
<Kpi label={t("reports.kpi.avgStay")} value={formatMinutes(tot.avgParkedMinutes)} />
<Kpi
label={t("reports.kpi.subscribers")}
value={String(data.subscriptions.currentlyValid)}
label={t("reports.kpi.peakOcc")}
value={data.capacity ? `${peakOcc} / ${data.capacity}` : String(peakOcc)}
/>
{/* The "look closer" counters — a spike here is what the signed chain is FOR. */}
<Kpi label={t("reports.kpi.voids")} value={String(tot.voids)} accent={tot.voids > 0 ? "amber" : undefined} />
<Kpi label={t("reports.kpi.anomalies")} value={String(tot.anomalies)} accent={tot.anomalies > 0 ? "red" : undefined} />
</div>
{/* Entry / exit over time. */}
@@ -192,8 +206,38 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
</ResponsiveContainer>
</Panel>
{/* Occupancy over time — THE parking curve: cars inside vs capacity. Step-shaped
(occupancy only moves at entries/exits); the red line is the configured cap. */}
<Panel title={t("reports.chart.occupancy")}>
<ResponsiveContainer width="100%" height={220}>
<AreaChart data={series} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
<CartesianGrid stroke={C.border} strokeDasharray="3 3" />
<XAxis dataKey="label" stroke={C.muted} fontSize={11} />
<YAxis stroke={C.muted} fontSize={11} allowDecimals={false} />
<Tooltip contentStyle={tooltipStyle} />
{data.capacity != null && (
<ReferenceLine
y={data.capacity}
stroke={C.red}
strokeDasharray="4 4"
label={{ value: t("reports.capacityLine"), fill: C.red, fontSize: 11, position: "insideTopRight" }}
/>
)}
<Area
type="stepAfter"
dataKey="occupancyEnd"
name={t("reports.chart.occupancySeries")}
stroke={C.cyan}
fill={C.cyan}
fillOpacity={0.15}
strokeWidth={2}
/>
</AreaChart>
</ResponsiveContainer>
</Panel>
<div className="grid gap-4 lg:grid-cols-2">
{/* Revenue per bucket. */}
{/* Revenue per bucket, stacked by tender — the drawer's cash vs the bank's card. */}
<Panel title={t("reports.chart.revenue", { currency: cur })}>
<ResponsiveContainer width="100%" height={240}>
<BarChart data={series} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
@@ -201,7 +245,9 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
<XAxis dataKey="label" stroke={C.muted} fontSize={11} />
<YAxis stroke={C.muted} fontSize={11} />
<Tooltip contentStyle={tooltipStyle} formatter={(v) => money(Math.round(Number(v) * 100))} />
<Bar dataKey="revenue" name={t("reports.kpi.revenue")} fill={C.amber} />
<Legend wrapperStyle={{ fontSize: 12 }} />
<Bar dataKey="cash" stackId="tender" name={t("reports.row.cash")} fill={C.amber} />
<Bar dataKey="card" stackId="tender" name={t("reports.row.card")} fill={C.cyan} />
</BarChart>
</ResponsiveContainer>
</Panel>
@@ -232,15 +278,15 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
)}
</Panel>
{/* Peak hours (entries by hour-of-day). */}
<Panel title={t("reports.chart.peakHours")}>
{/* Stay-duration histogram — where the ladder/up-to breakpoints should sit. */}
<Panel title={t("reports.chart.stay")}>
<ResponsiveContainer width="100%" height={240}>
<BarChart data={hours} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
<BarChart data={stay} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
<CartesianGrid stroke={C.border} strokeDasharray="3 3" />
<XAxis dataKey="hour" stroke={C.muted} fontSize={11} interval={1} />
<XAxis dataKey="label" stroke={C.muted} fontSize={11} />
<YAxis stroke={C.muted} fontSize={11} allowDecimals={false} />
<Tooltip contentStyle={tooltipStyle} />
<Bar dataKey="entries" name={t("reports.kpi.entries")} fill={C.cyan} />
<Bar dataKey="count" name={t("reports.row.closed")} fill={C.green} />
</BarChart>
</ResponsiveContainer>
</Panel>
@@ -262,6 +308,12 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
</Panel>
</div>
{/* Entries heatmap: hour × day-of-week. Weekday-vs-weekend patterns at a glance —
the direct input for tariff windows (night rates, weekend cards, early bird). */}
<Panel title={t("reports.chart.heatmap")}>
<Heatmap matrix={data.entriesByDowHour} dows={t("reports.dowShort", { returnObjects: true }) as string[]} />
</Panel>
<p className="text-[0.6875rem] text-term-muted">
{t("reports.footnote", { tz: data.tz })}
</p>
@@ -269,6 +321,37 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
);
}
/** Hour-of-day × day-of-week entries heatmap: pure CSS grid, amber intensity scaled to
* the busiest cell. Row 0 = Monday (server contract). Cell tooltip = exact count. */
function Heatmap({ matrix, dows }: { matrix: number[][]; dows: string[] }) {
const max = Math.max(1, ...matrix.flat());
return (
<div className="overflow-x-auto">
<div className="grid min-w-[560px] grid-cols-[max-content_repeat(24,1fr)] gap-px text-[0.625rem]">
<span />
{Array.from({ length: 24 }, (_, h) => (
<span key={h} className="pb-0.5 text-center text-term-muted">
{h % 3 === 0 ? h : ""}
</span>
))}
{matrix.map((row, d) => (
<Fragment key={d}>
<span className="pr-1.5 leading-4 text-term-muted">{dows[d]}</span>
{row.map((v, h) => (
<span
key={h}
title={`${dows[d]} ${String(h).padStart(2, "0")}:00 — ${v}`}
className="h-4 rounded-[1px]"
style={{ background: v === 0 ? C.panel2 : C.amber, opacity: v === 0 ? 1 : 0.25 + 0.75 * (v / max) }}
/>
))}
</Fragment>
))}
</div>
</div>
);
}
const tooltipStyle = {
background: C.panel,
border: `1px solid ${C.border}`,
+77 -7
View File
@@ -29,6 +29,7 @@ import {
type RelayEvent,
type RelaySpec,
type TestResult,
fetchUsbPrinters,
} from "./api.js";
import { Modal } from "./ui/Modal.js";
@@ -257,8 +258,10 @@ function CategorySection({
const [formFor, setFormFor] = useState<Assignment | "new" | null>(null);
const [warnings, setWarnings] = useState<string[]>([]);
// Binding categories need a controller to point at first.
const isBound = category !== "access";
// Binding categories need a controller to point at first. Printers do NOT bind
// (role + failoverRank route print jobs — see printer-routing.ts), so they are
// addable on a controller-less box (e.g. the lab bench testing a USB printer).
const isBound = category !== "access" && category !== "printer";
const blockedNoController = isBound && controllers.length === 0;
const editing = formFor && formFor !== "new" ? formFor : undefined;
@@ -463,6 +466,16 @@ function DeviceSummary({ assignment, controllers }: { assignment: Assignment; co
</span>
);
}
// Printers don't bind to a barrier (routing is role + failoverRank) — show the
// role instead of a bogus "unbound" warning.
if (assignment.category === "printer") {
const role = typeof cfg.role === "string" ? cfg.role : null;
return role ? (
<span className="text-term-muted">
{t(role === "booth-receipt" ? "devices.role.booth" : "devices.role.lane")}
</span>
) : null;
}
// Bound device: show controller + relay it points at, with inherited direction.
const controllerId = typeof cfg.controllerId === "string" ? cfg.controllerId : null;
const relay = typeof cfg.relay === "number" ? cfg.relay : null;
@@ -534,6 +547,28 @@ function DeviceForm({
}
return out;
});
// USB printers PRESENT on the box (/dev/usb/lpN + sysfs model) — fetched when a
// printer form is on the USB transport, so devicePath becomes a SELECT of real
// devices instead of a guessed path (the kernel may pick lp1 — park-buzi did).
const [usbPrinters, setUsbPrinters] = useState<{ path: string; description: string | null }[] | null>(null);
const usbTransport = isPrinter && String(config.transport ?? "tcp-ip") === "usb";
useEffect(() => {
if (!usbTransport) return;
let alive = true;
fetchUsbPrinters()
.then((r) => {
if (!alive) return;
setUsbPrinters(r.printers);
// Fresh form with no explicit path yet → preselect the first REAL device.
if (r.printers.length > 0) {
setConfig((c) => (c.devicePath == null ? { ...c, devicePath: r.printers[0]!.path } : c));
}
})
.catch(() => alive && setUsbPrinters([]));
return () => {
alive = false;
};
}, [usbTransport]);
// Controllers: the unified relay map. Each relay reacts to an EVENT — entry/exit/both
// (pulse a barrier) or radarAlert (drive an alert lamp). Alert relays carry a trigger
// input + blink cadence; barriers carry no input wiring (that lives in `inputs` below).
@@ -686,7 +721,11 @@ function DeviceForm({
...(i.role === "presence" && i.activeLow ? { activeLow: true } : {}),
...(i.role === "button" && i.cooldownSec ? { cooldownSec: i.cooldownSec } : {}),
}));
} else if (controllerId && boundRelay !== "") {
} else if (!isPrinter && controllerId && boundRelay !== "") {
// Readers/cameras bind to a controller relay (which barrier a scan opens +
// inherited direction). Printers do NOT — routing is role+failoverRank only,
// so no binding is emitted (and a stale one saved before 2026-07-06 drops
// off on the next edit).
out.controllerId = controllerId;
out.relay = boundRelay;
}
@@ -759,7 +798,9 @@ function DeviceForm({
if (!selected) return;
// Bound devices must point at a controller relay (binding is optional in the
// model with a fallback, but the wizard guides the admin to bind explicitly).
if (!isController && (!controllerId || boundRelay === "")) {
// Printers are exempt: nothing consumes a printer's binding — their routing is
// role + failoverRank (see printer-routing.ts).
if (!isController && !isPrinter && (!controllerId || boundRelay === "")) {
setSaveError("Pick the controller and relay this device sits at.");
return;
}
@@ -872,7 +913,32 @@ function DeviceForm({
{f.label}
{f.required ? " *" : ""}
</label>
{f.type === "select" ? (
{f.key === "devicePath" && usbPrinters != null && usbPrinters.length > 0 ? (
// Real devices found → a select (path + self-reported model). A saved
// path that is NOT currently present stays selectable, flagged.
<select
className="select"
value={String(config.devicePath ?? (f.default as string | undefined) ?? "")}
onChange={(e) => {
const v = e.target.value;
setConfig((c) => ({ ...c, devicePath: v }));
resetStatus();
}}
>
{(() => {
const cur = String(config.devicePath ?? (f.default as string | undefined) ?? "");
const missing = cur && !usbPrinters.some((u) => u.path === cur);
return [
...(missing ? [{ path: cur, description: t("setup.usbSavedMissing") }] : []),
...usbPrinters,
].map((u) => (
<option key={u.path} value={u.path}>
{u.description ? `${u.path} — ${u.description}` : u.path}
</option>
));
})()}
</select>
) : f.type === "select" ? (
<select
className="select"
value={String(config[f.key] ?? (f.default as string | number | undefined) ?? "")}
@@ -927,6 +993,9 @@ function DeviceForm({
}}
/>
)}
{f.key === "devicePath" && usbPrinters != null && usbPrinters.length === 0 && (
<p className="hint mt-1">{t("setup.usbNoneFound")}</p>
)}
</div>
),
)}
@@ -959,8 +1028,9 @@ function DeviceForm({
/>
)}
{/* BOUND device: which controller + relay it sits at. */}
{!isController && (
{/* BOUND device: which controller + relay it sits at. Not printers —
nothing consumes a printer binding (role+rank routes print jobs). */}
{!isController && !isPrinter && (
<BindingPicker
controllers={controllers}
controllerId={controllerId}
+29 -7
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import {
closeShift,
fetchEvents,
@@ -12,9 +12,10 @@ import {
type ShiftSummary,
type SessionUser,
} from "./api.js";
import { formatMoney, formatDuration, formatRelativeDateTime } from "./lib/format.js";
import { formatMoney, formatDuration, formatDateTime, formatRelativeDateTime } from "./lib/format.js";
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
import { Modal } from "./ui/Modal.js";
import { Spinner } from "./ui/Spinner.js";
import { EventDetailModal, EventRow } from "./ui/event-detail.js";
import type { LedgerEvent } from "@parking/shared";
@@ -105,9 +106,17 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
to: range?.to ? new Date(`${range.to}T23:59:59`).toISOString() : undefined,
};
const q = useQuery({ queryKey: ["shifts", applied], queryFn: () => fetchShifts(applied) });
// keepPreviousData: every filter change makes a NEW query key; without it the
// data (and with it `scope`) goes undefined for the fetch round-trip, which
// unmounted the admin filter controls mid-interaction and blanked the list.
const q = useQuery({
queryKey: ["shifts", applied],
queryFn: () => fetchShifts(applied),
placeholderData: keepPreviousData,
});
const isAdmin = q.data?.scope === "all";
const closed = q.data?.shifts ?? [];
const operators = q.data?.operators ?? [];
// The current/open shift sits at the TOP of the list (when present + visible to me).
const list: (ShiftSummary & { open?: boolean })[] = current && (isMine || isAdmin) ? [current, ...closed] : closed;
@@ -168,8 +177,15 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
)}
{isAdmin && (
<div className="field">
<span className="label">{t("shifts.operator")}</span>
<input className="input w-44" value={operator} onChange={(e) => setOperator(e.target.value)} placeholder={t("shifts.allOperators")} />
{/* <span className="label">{t("shifts.operator")}</span> */}
{/* A select over operators that HAVE shifts — the server filter is an
exact username match, so free text could only miss. */}
<select className="input w-44" value={operator} onChange={(e) => setOperator(e.target.value)}>
<option value="">{t("shifts.allOperators")}</option>
{operators.map((op) => (
<option key={op} value={op}>{op}</option>
))}
</select>
</div>
)}
</div>
@@ -229,7 +245,13 @@ function StartShiftButton({ onDone }: { onDone: () => void }) {
<span className="flex items-center gap-2">
{err && <span className="text-[0.75rem] text-term-red">{err}</span>}
<button type="button" className="btn btn-go btn-sm" onClick={start} disabled={busy}>
{busy ? t("shift.starting") : t("shift.startShift")}
{busy ? (
<span className="inline-flex items-center gap-1.5">
<Spinner /> {t("shift.starting")}
</span>
) : (
t("shift.startShift")
)}
</button>
</span>
);
@@ -435,7 +457,7 @@ function TakingsModal({ onClose }: { onClose: () => void }) {
<p className="text-[0.75rem] text-term-muted">{t("common.loading")}</p>
) : (
<div className="text-[0.8125rem] tabular-nums">
<div className="text-term-muted">{t("shift.asOf")} {new Date(x.asOf).toLocaleString()}</div>
<div className="text-term-muted">{t("shift.asOf")} {formatDateTime(x.asOf, t)}</div>
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5">
<Figure label={t("shift.payments")} value={String(x.paymentCount)} />
<span />
+63 -3
View File
@@ -1,6 +1,16 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { fetchOccupancy, fetchSiteConfig, saveSiteConfig, type Occupancy, type SiteConfig } from "./api.js";
import {
fetchOccupancy,
fetchSiteConfig,
fetchValidationPrograms,
saveSiteConfig,
saveValidationProgram,
type Occupancy,
type SiteConfig,
type ValidationProgramView,
} from "./api.js";
import { STATIONS, ValidationStationsPanel, defaultProgram, type StationId } from "./ValidationSetup.js";
// Live occupancy + capacity + park metadata. Occupancy is shown to everyone (it's a
// fold over the signed ledger); capacity and the metadata fields are admin-editable.
@@ -28,12 +38,21 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
const [reserveSubs, setReserveSubs] = useState(false);
const [anprEntry, setAnprEntry] = useState(true);
const [msg, setMsg] = useState<string | null>(null);
// Merchant-validation programs (bar / lavazh). The checkboxes below toggle a
// station's `active` (persisted at once — each flip signs a config_change); the
// right-column panel edits the enabled stations. See validation-discounts.md.
const [programs, setPrograms] = useState<ValidationProgramView[]>([]);
function reload() {
fetchOccupancy().then(setOcc).catch(() => {});
}
useEffect(() => {
reload();
if (canEdit) {
fetchValidationPrograms()
.then((r) => setPrograms(r.programs))
.catch(() => {});
}
fetchSiteConfig()
.then((c) => {
setCapInput(c.capacity == null ? "" : String(c.capacity));
@@ -45,7 +64,23 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
setMeta(m);
})
.catch(() => {});
}, []);
}, [canEdit]);
/** Flip a merchant station's checkbox: persist `active` at once (a signed
* config_change server-side), creating the well-known row with comp defaults on
* the first enable. Config details are edited in the right-column panel. */
async function toggleStation(id: StationId, active: boolean) {
const existing = programs.find((p) => p.id === id);
const body = existing
? { ...existing, active }
: { ...defaultProgram(id, t(id === "bar" ? "val.enableBar" : "val.enableLavazh")), active };
try {
const saved = await saveValidationProgram(id, body);
setPrograms((ps) => [...ps.filter((p) => p.id !== id), saved]);
} catch (e) {
setMsg((e as Error).message);
}
}
async function save() {
setMsg(null);
@@ -68,7 +103,8 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
}
return (
<section className="card mt-6 max-w-md p-4">
<div className="mt-6 flex flex-wrap items-start gap-6">
<section className="card w-full max-w-md p-4">
<div className="flex flex-wrap items-center gap-1.5 text-[0.8125rem]">
<strong className="uppercase tracking-wider text-term-muted">{t("site.occupancy")}</strong>
{occ == null ? (
@@ -127,6 +163,23 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
<span className="hint block">{t("site.anprEntryHint")}</span>
</span>
</label>
<div className="border-t border-term-border pt-3 text-[0.6875rem] uppercase tracking-wider text-term-muted">
{t("val.sectionTitle")}
</div>
<span className="hint -mt-2">{t("val.sectionHint")}</span>
<div className="flex gap-6">
{STATIONS.map((id) => (
<label key={id} className="flex items-center gap-2 text-[0.75rem] text-term-text">
<input
type="checkbox"
className="accent-term-amber"
checked={programs.find((p) => p.id === id)?.active ?? false}
onChange={(e) => toggleStation(id, e.target.checked)}
/>
{t(id === "bar" ? "val.enableBar" : "val.enableLavazh")}
</label>
))}
</div>
<div className="border-t border-term-border pt-3 text-[0.6875rem] uppercase tracking-wider text-term-muted">
{t("site.parkDetails")}
</div>
@@ -158,5 +211,12 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
</div>
)}
</section>
{canEdit && (
<ValidationStationsPanel
programs={programs}
onSaved={(p) => setPrograms((ps) => [...ps.filter((x) => x.id !== p.id), p])}
/>
)}
</div>
);
}
+3 -2
View File
@@ -27,6 +27,7 @@ import {
} from "./api.js";
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
import { Modal } from "./ui/Modal.js";
import { formatDateTime, type TFn } from "./lib/format.js";
// Subscription admin. Create/edit/revoke/delete subscriptions + their credentials
// (card/QR) and bound plates. A SALE is priced by selecting an admin-defined PLAN over
@@ -179,9 +180,9 @@ function daysLabel(days: number[] | undefined, t: (k: string) => string): string
/** A one-line label for a plan VERSION in the correction picker: effective date + its
* timeframe summary (or "24/7" when the version has no window). */
function versionLabel(v: SubscriptionPlan, t: (k: string) => string): string {
function versionLabel(v: SubscriptionPlan, t: TFn): string {
const eff = new Date(v.effectiveFrom);
const date = Number.isNaN(eff.getTime()) ? v.effectiveFrom : eff.toLocaleString();
const date = Number.isNaN(eff.getTime()) ? v.effectiveFrom : formatDateTime(v.effectiveFrom, t);
const tf = v.timeframes;
const rules = tf ? `${daysLabel(tf.days, t)} ${hhmm(tf.fromMin)}–${hhmm(tf.toMin)}` : t("subs.allDay");
return `${date} · ${rules}`;
+8 -2
View File
@@ -14,6 +14,8 @@ import {
type SubscriptionPlan,
} from "./api.js";
import { Modal } from "./ui/Modal.js";
import { formatDate } from "./lib/format.js";
import { currencyOptions } from "./lib/currencies.js";
// Admin-only subscription PLAN catalog. Plans are admin-composed, versioned config the
// operator sells from (so the operator never types a price). Editing a plan PUBLISHES A
@@ -276,7 +278,7 @@ export function SubscriptionPlansManager() {
{(p.pricePerPeriodMinor / 100).toLocaleString()} {p.currency} / {t(PERIOD_KEY[p.period])}
</span>
<span>{timeframesSummary(p.timeframes, t)}</span>
<span>{t("plans.colEffective")}: {new Date(p.effectiveFrom).toLocaleDateString()}</span>
<span>{t("plans.colEffective")}: {formatDate(p.effectiveFrom, t)}</span>
</div>
{/* Used by */}
@@ -348,7 +350,11 @@ export function SubscriptionPlansManager() {
<label className="label">{t("plans.pricePer")}</label>
<span className="flex items-center gap-2">
<input className="input w-28" value={form.priceMajor} inputMode="decimal" onChange={(e) => setForm((f) => f && { ...f, priceMajor: e.target.value })} placeholder="e.g. 800" />
<input className="input w-16" value={form.currency} onChange={(e) => setForm((f) => f && { ...f, currency: e.target.value })} />
<select className="input w-auto" value={form.currency} onChange={(e) => setForm((f) => f && { ...f, currency: e.target.value })}>
{currencyOptions(form.currency).map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
<span className="text-[0.75rem] text-term-muted">/ {t(PERIOD_KEY[form.period])}</span>
</span>
</div>
+88 -559
View File
@@ -1,267 +1,28 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ApiError,
fetchTariff,
isTariffV2,
publishTariffVersion,
type TariffBlock,
type TariffCard,
type TariffStep,
type TariffStructure,
type TariffState,
} from "./api.js";
import { ApiError, fetchTariff, publishTariffVersion, type TariffState, type TariffVersion } from "./api.js";
import { TariffEditorForm, emptyForm, formFromActive, formFromVersion, toStructure, type FormState } from "./TariffEditorForm.js";
import { formatDateTime } from "./lib/format.js";
// Tariff composer — the admin builds + edits the rate card at runtime. Publishing
// Tariff composer — the admin edits + publishes the LIVE rate card. Publishing
// creates a new IMMUTABLE version (the active card); old versions are kept so past
// sessions reprice correctly. Amounts are entered in major units (e.g. euros) for
// 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.
// 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 {
hours: string; // duration of THIS band, in hours (ignored for the last block)
price: string; // major units, e.g. "2.00"
}
// One STEPPED ("up-to") row: "a stay up to N hours costs TOTAL". The owner enters the
// matrix verbatim (totals, not marginal rates). See wiki/concepts/tariff.md.
interface StepForm {
hours: string; // inclusive upper bound of this tier, in hours (e.g. "3")
total: string; // TOTAL major units for a stay within this tier (e.g. "5.00")
}
// A pricing body the form edits: a flat rate, a marginal block ladder, or a stepped
// (up-to) total-by-duration table.
interface PricingForm {
mode: "ladder" | "flat" | "stepped";
flat: string; // major units (used when mode==="flat")
blocks: BlockForm[]; // hours-based ladder (used when mode==="ladder")
steps: StepForm[]; // up-to tiers (used when mode==="stepped")
dailyCap: string; // "" = no cap (ladder only)
}
// An optional time/category TIER (a V2 windowed card). Absent windows = unconstrained.
interface TierForm {
name: string;
priority: string;
category: string; // "" = applies to all categories
dow: number[]; // selected days 0..6; empty = every day
fromHour: string; // "" = all day
toHour: string;
dateFrom: string; // "" = unbounded
dateTo: string;
pricing: PricingForm;
}
interface FormState {
currency: string;
gracePeriodEntryMin: string;
incrementMin: string;
lostTicket: string;
gracePeriodExitMin: string;
// The default (always-active) card — its own flat/ladder body + daily cap.
base: PricingForm;
// Optional time/category tiers. Empty ⇒ a bare V1 structure is published.
tiers: TierForm[];
}
const toMinor = (major: string): number => Math.round(parseFloat(major || "0") * 100);
const toMajor = (minor: number): string => (minor / 100).toFixed(2);
function emptySteps(): StepForm[] {
return [
{ hours: "1", total: "2.00" },
{ hours: "3", total: "5.00" },
];
}
function emptyLadder(): PricingForm {
return {
mode: "ladder",
flat: "0.00",
dailyCap: "",
blocks: [{ hours: "1", price: "2.00" }, { hours: "", price: "1.00" }],
steps: emptySteps(),
};
}
function emptyTier(): TierForm {
return {
name: "",
priority: "10",
category: "",
dow: [],
fromHour: "",
toHour: "",
dateFrom: "",
dateTo: "",
pricing: { ...emptyLadder(), blocks: [{ hours: "", price: "1.00" }] },
};
}
function emptyForm(): FormState {
return {
currency: "EUR",
gracePeriodEntryMin: "15",
incrementMin: "60",
lostTicket: "20.00",
gracePeriodExitMin: "15",
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 stepped table's `uptoMin` (minutes) → the per-tier hours the form edits.
function stepsToForm(steps: TariffStep[]): StepForm[] {
return steps.map((s) => ({ hours: String(s.uptoMin / 60), total: toMajor(s.totalMinor) }));
}
// A stored card (V2) or bare-V1 body → the form's PricingForm (flat, ladder, or stepped).
function pricingFromCard(c: {
flatMinor?: number;
blocks?: TariffBlock[];
steps?: TariffStep[];
dailyCapMinor?: number | null;
}): PricingForm {
if (c.steps != null && c.steps.length > 0) {
return { mode: "stepped", flat: "0.00", dailyCap: "", blocks: emptyLadder().blocks, steps: stepsToForm(c.steps) };
}
if (c.flatMinor != null) {
return { mode: "flat", flat: toMajor(c.flatMinor), dailyCap: "", blocks: emptyLadder().blocks, steps: emptySteps() };
}
return {
mode: "ladder",
flat: "0.00",
dailyCap: c.dailyCapMinor == null ? "" : toMajor(c.dailyCapMinor),
blocks: blocksToForm(c.blocks ?? []),
steps: emptySteps(),
};
}
function tierFromCard(c: TariffCard): TierForm {
const w = c.window ?? {};
return {
name: c.name,
priority: String(c.priority),
category: c.category ?? "",
dow: w.dow ? [...w.dow] : [],
fromHour: w.fromHour ?? "",
toHour: w.toHour ?? "",
dateFrom: w.dateFrom ?? "",
dateTo: w.dateTo ?? "",
pricing: pricingFromCard(c),
};
}
function formFromActive(s: TariffState): FormState {
const v = s.active;
if (!v) return emptyForm();
const st = v.structure;
const common = {
currency: v.currency,
gracePeriodEntryMin: String(st.gracePeriodEntryMin),
incrementMin: String(st.incrementMin),
lostTicket: toMajor(st.lostTicketMinor),
gracePeriodExitMin: String(st.gracePeriodExitMin),
};
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 XOR stepped) from a PricingForm.
function pricingToCardBody(p: PricingForm): Pick<TariffCard, "flatMinor" | "blocks" | "steps" | "dailyCapMinor"> {
if (p.mode === "flat") return { flatMinor: toMinor(p.flat) };
if (p.mode === "stepped") {
// Each row's `hours` IS the inclusive threshold (the matrix "up to N hours").
const steps: TariffStep[] = p.steps.map((s) => ({
uptoMin: Math.round(Number(s.hours || "0") * 60),
totalMinor: toMinor(s.total),
}));
return { steps };
}
// 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 {
const common = {
gracePeriodEntryMin: Math.round(Number(f.gracePeriodEntryMin)),
incrementMin: Math.round(Number(f.incrementMin)),
lostTicketMinor: toMinor(f.lostTicket),
gracePeriodExitMin: Math.round(Number(f.gracePeriodExitMin)),
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 === "stepped") {
// A stepped V1: the up-to table replaces the ladder (blocks empty, no cap).
return { ...common, blocks: [], steps: baseBody.steps ?? [], dailyCapMinor: null };
}
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),
};
}
// sessions reprice correctly. A right sidebar lists the published history (named
// since 2026-07-05); clicking a version loads it into the editor as the STARTING
// POINT — publishing always creates a new version effective now, it never edits the
// clicked one. The form machinery is shared with the Tariff Lab's draft modal — see
// TariffEditorForm.tsx. To experiment without publishing, use the lab. See
// wiki/concepts/tariff.md.
export function TariffComposer() {
const { t } = useTranslation();
const [state, setState] = useState<TariffState | null>(null);
const [form, setForm] = useState<FormState>(emptyForm);
// Which published version the editor was last loaded from (sidebar highlight).
const [loadedId, setLoadedId] = useState<string | null>(null);
// Optional label for the version about to be published. Deliberately NOT prefilled
// from the active version — a tweaked card republished under last season's name
// would mislabel the history.
const [versionName, setVersionName] = useState("");
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
@@ -270,74 +31,30 @@ export function TariffComposer() {
.then((s) => {
setState(s);
setForm(formFromActive(s));
setLoadedId(s.active?.id ?? null);
})
.catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
}, []);
function set<K extends keyof FormState>(key: K, value: FormState[K]) {
setForm((f) => ({ ...f, [key]: value }));
}
// --- 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 setBlock(target: "base" | number, i: number, patch: Partial<BlockForm>) {
updatePricing(target, (p) => ({ ...p, blocks: p.blocks.map((b, j) => (j === i ? { ...b, ...patch } : b)) }));
}
// Insert a bounded band just BEFORE the open-ended tail, so the last block stays open-ended.
function addBlock(target: "base" | number) {
updatePricing(target, (p) => {
const next = [...p.blocks];
next.splice(p.blocks.length - 1, 0, { hours: "1", price: "0.00" });
return { ...p, blocks: next };
});
}
function removeBlock(target: "base" | number, i: number) {
updatePricing(target, (p) => (i === p.blocks.length - 1 || p.blocks.length <= 1 ? p : { ...p, blocks: p.blocks.filter((_, j) => j !== i) }));
}
// --- stepped (up-to) editing (base card only) ---
function setStep(i: number, patch: Partial<StepForm>) {
updatePricing("base", (p) => ({ ...p, steps: p.steps.map((s, j) => (j === i ? { ...s, ...patch } : s)) }));
}
function addStep() {
updatePricing("base", (p) => ({ ...p, steps: [...p.steps, { hours: "", total: "0.00" }] }));
}
function removeStep(i: number) {
updatePricing("base", (p) => (p.steps.length <= 1 ? p : { ...p, steps: p.steps.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,
),
}));
function loadVersion(v: TariffVersion) {
setForm(formFromVersion(v.currency, v.structure));
setLoadedId(v.id);
setMsg(null);
}
async function publish() {
setSaving(true);
setMsg(null);
try {
await publishTariffVersion({ currency: form.currency.trim().toUpperCase(), structure: toStructure(form) });
await publishTariffVersion({
currency: form.currency.trim().toUpperCase(),
structure: toStructure(form),
...(versionName.trim() ? { name: versionName.trim() } : {}),
});
const fresh = await fetchTariff();
setState(fresh);
setLoadedId(fresh.active?.id ?? null);
setVersionName("");
setMsg({ kind: "ok", text: t("tariff.publishedOk") });
} catch (e) {
const text =
@@ -359,264 +76,76 @@ export function TariffComposer() {
</p>
) : (
<p className="mb-4 text-[0.75rem] text-term-muted">
{state.active.name ? `${state.active.name} — ` : ""}
{t("tariff.activeSince", {
date: new Date(state.active.effectiveFrom).toLocaleString(),
date: formatDateTime(state.active.effectiveFrom, t),
count: state.versions.length,
})}
</p>
)}
<div className="card card-body grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
<label className="label">{t("tariff.currency")}</label>
<input className="input w-24" value={form.currency} onChange={(e) => set("currency", e.target.value)} maxLength={3} />
<label className="label">{t("tariff.freeEntryGrace")}</label>
<input className="input w-32" value={form.gracePeriodEntryMin} onChange={(e) => set("gracePeriodEntryMin", e.target.value)} />
<label className="label">{t("tariff.billingIncrement")}</label>
<input className="input w-32" value={form.incrementMin} onChange={(e) => set("incrementMin", e.target.value)} />
<label className="label">{t("tariff.lostTicketFee")}</label>
<input className="input w-32" value={form.lostTicket} onChange={(e) => set("lostTicket", e.target.value)} />
<label className="label">{t("tariff.exitGrace")}</label>
<input className="input w-32" value={form.gracePeriodExitMin} onChange={(e) => set("gracePeriodExitMin", e.target.value)} />
</div>
<div className="flex flex-col gap-4 lg:flex-row">
<div className="min-w-0 flex-1">
<TariffEditorForm form={form} onChange={setForm} />
{/* The DEFAULT card — always-active rate. Front-and-centre; a site that never
wants tiers just edits this and publishes a bare V1 structure. */}
<h3 className="mt-6 mb-0.5 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("tariff.defaultCard")}</h3>
<p className="hint mb-2">{t("tariff.defaultCardHint")}</p>
<div className="card card-body">
<PricingEditor
t={t}
pricing={form.base}
allowStepped
onMode={(mode) => updatePricing("base", (p) => ({ ...p, mode }))}
onFlat={(flat) => updatePricing("base", (p) => ({ ...p, flat }))}
onCap={(dailyCap) => updatePricing("base", (p) => ({ ...p, dailyCap }))}
onBlock={(i, patch) => setBlock("base", i, patch)}
onAddBlock={() => addBlock("base")}
onRemoveBlock={(i) => removeBlock("base", i)}
onStep={setStep}
onAddStep={addStep}
onRemoveStep={removeStep}
/>
</div>
<div className="mt-6 flex flex-wrap items-center gap-3">
<input
className="input w-64"
value={versionName}
onChange={(e) => setVersionName(e.target.value)}
placeholder={t("tariff.versionNamePh")}
/>
<button type="button" className="btn btn-primary btn-lg" onClick={publish} disabled={saving}>
{saving ? t("tariff.publishing") : t("tariff.publishNewVersion")}
</button>
{msg && (
<span className={msg.kind === "ok" ? "text-[0.75rem] text-term-green" : "text-[0.75rem] text-term-red"}>{msg.text}</span>
)}
</div>
</div>
{/* Advanced: time & seasonal/category TIERS (opt-in). Empty ⇒ V1 is published. */}
<details className="mt-6" open={form.tiers.length > 0}>
<summary className="cursor-pointer text-h6 font-semibold uppercase tracking-wider text-term-text">{t("tariff.tiersAdvanced")}</summary>
<p className="hint mt-1.5 mb-2">{t("tariff.tiersHint")}</p>
{/* A stepped ("up-to") base rate cannot be combined with time tiers — the
engine would ignore them. Warn up-front; publishing is also blocked server-side. */}
{form.base.mode === "stepped" && form.tiers.length > 0 && (
<p className="mb-3 rounded-term border border-term-red/50 bg-term-red/10 px-3 py-2 text-[0.75rem] text-term-red">
{t("tariff.steppedTiersConflict")}
</p>
)}
{form.tiers.map((tr, i) => (
<fieldset key={i} className="card mb-3 p-4">
<legend className="flex items-center gap-2 px-1">
<input
className="input w-40"
value={tr.name}
onChange={(e) => setTier(i, { name: e.target.value })}
placeholder={t("tariff.tierName")}
/>
<button type="button" className="btn btn-danger btn-sm" onClick={() => removeTier(i)}>
{t("tariff.remove")}
</button>
</legend>
<div className="grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
<label className="label">{t("tariff.tierPriority")}</label>
<input className="input w-20" value={tr.priority} onChange={(e) => setTier(i, { priority: e.target.value })} />
<label className="label">{t("tariff.tierCategory")}</label>
<input className="input w-40" value={tr.category} onChange={(e) => setTier(i, { category: e.target.value })} placeholder={t("tariff.tierCategoryPh")} />
<label className="label">{t("tariff.tierDays")}</label>
<span className="flex flex-wrap gap-2">
{[1, 2, 3, 4, 5, 6, 0].map((d) => (
<label key={d} className="inline-flex items-center gap-1 text-[0.75rem] text-term-text">
<input type="checkbox" className="accent-term-amber" checked={tr.dow.includes(d)} onChange={() => toggleDow(i, d)} />
{t(`tariff.dow${d}`)}
</label>
))}
</span>
<label className="label">{t("tariff.tierHours")}</label>
<span className="inline-flex items-center gap-2">
<input className="input w-20" value={tr.fromHour} onChange={(e) => setTier(i, { fromHour: e.target.value })} placeholder="22:00" />
<span className="text-term-muted">–</span>
<input className="input w-20" value={tr.toHour} onChange={(e) => setTier(i, { toHour: e.target.value })} placeholder="06:00" />
{tr.fromHour && tr.toHour && tr.toHour <= tr.fromHour && (
<span className="text-[0.6875rem] text-term-muted">{t("tariff.tierOvernight")}</span>
)}
</span>
<label className="label">{t("tariff.tierDates")}</label>
<span className="inline-flex items-center gap-2">
<input type="date" className="input w-40" value={tr.dateFrom} onChange={(e) => setTier(i, { dateFrom: e.target.value })} />
<span className="text-term-muted">–</span>
<input type="date" className="input w-40" value={tr.dateTo} onChange={(e) => setTier(i, { dateTo: e.target.value })} />
</span>
</div>
<div className="mt-3 border-t border-term-border pt-3">
<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" className="btn btn-sm" onClick={addTier}>
{t("tariff.addTier")}
</button>
</details>
<div className="mt-6 flex items-center gap-3">
<button type="button" className="btn btn-primary btn-lg" onClick={publish} disabled={saving}>
{saving ? t("tariff.publishing") : t("tariff.publishNewVersion")}
</button>
{msg && (
<span className={msg.kind === "ok" ? "text-[0.75rem] text-term-green" : "text-[0.75rem] text-term-red"}>{msg.text}</span>
{/* Published history — click a version to load it into the editor. Same list
the lab's sidebar shows; here it seeds the next publish. */}
{state && state.versions.length > 0 && (
<aside className="w-full shrink-0 lg:w-72">
<h3 className="mb-1 text-h6 font-semibold uppercase tracking-wider text-term-text">
{t("tariff.versionsTitle")}
</h3>
<p className="hint mb-2">{t("tariff.versionsHint")}</p>
<ul className="flex flex-col gap-1">
{state.versions.map((v) => {
const isActive = v.id === state.active?.id;
return (
<li key={v.id}>
<button
type="button"
onClick={() => loadVersion(v)}
className={`w-full rounded-term border px-3 py-2 text-left text-[0.8125rem] ${
loadedId === v.id
? "border-term-amber bg-term-amber/10 text-term-text"
: "border-term-border text-term-muted hover:text-term-text"
}`}
>
<span className="flex items-center gap-2 font-semibold">
{v.name ?? formatDateTime(v.effectiveFrom, t)}
{isActive && (
<span className="rounded border border-term-green px-1 text-[0.625rem] uppercase text-term-green">
{t("tariff.activeBadge")}
</span>
)}
</span>
<span className="block text-[0.6875rem] text-term-muted">
{v.name ? `${formatDateTime(v.effectiveFrom, t)} · ` : ""}
{v.currency}
</span>
</button>
</li>
);
})}
</ul>
</aside>
)}
</div>
</section>
);
}
// A reusable pricing-body editor — flat / marginal ladder / stepped (up-to). The
// stepped mode is offered only where `allowStepped` (the default card, not tiers).
function PricingEditor(props: {
t: (k: string) => string;
pricing: PricingForm;
allowStepped?: boolean;
onMode: (m: "ladder" | "flat" | "stepped") => void;
onFlat: (v: string) => void;
onCap: (v: string) => void;
onBlock: (i: number, patch: Partial<BlockForm>) => void;
onAddBlock: () => void;
onRemoveBlock: (i: number) => void;
onStep?: (i: number, patch: Partial<StepForm>) => void;
onAddStep?: () => void;
onRemoveStep?: (i: number) => void;
}) {
const { t, pricing: p } = props;
return (
<div>
<div className="mb-3 flex gap-4 text-[0.75rem]">
<label className="inline-flex items-center gap-1.5 text-term-text">
<input type="radio" className="accent-term-amber" checked={p.mode === "ladder"} onChange={() => props.onMode("ladder")} />
{t("tariff.modeLadder")}
</label>
<label className="inline-flex items-center gap-1.5 text-term-text">
<input type="radio" className="accent-term-amber" checked={p.mode === "flat"} onChange={() => props.onMode("flat")} />
{t("tariff.modeFlat")}
</label>
{props.allowStepped && (
<label className="inline-flex items-center gap-1.5 text-term-text">
<input type="radio" className="accent-term-amber" checked={p.mode === "stepped"} onChange={() => props.onMode("stepped")} />
{t("tariff.modeStepped")}
</label>
)}
</div>
{p.mode === "stepped" ? (
<>
<p className="hint mb-2">{t("tariff.steppedHint")}</p>
<table className="w-full border-collapse">
<thead>
<tr className="text-left">
<th className="label px-2 pb-1 font-normal">{t("tariff.stepUpTo")}</th>
<th className="label px-2 pb-1 font-normal">{t("tariff.stepTotal")}</th>
<th />
</tr>
</thead>
<tbody>
{p.steps.map((s, i) => (
<tr key={i}>
<td className="px-2 py-1">
<span className="inline-flex items-center gap-2">
<input className="input w-20" value={s.hours} onChange={(e) => props.onStep?.(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} />
<span className="text-[0.6875rem] text-term-muted">{t("tariff.hoursUnit")}</span>
</span>
</td>
<td className="px-2 py-1">
<input className="input w-28" value={s.total} onChange={(e) => props.onStep?.(i, { total: e.target.value })} />
</td>
<td className="px-2">
{p.steps.length > 1 && (
<button type="button" className="btn btn-ghost btn-sm" onClick={() => props.onRemoveStep?.(i)}>
{t("tariff.remove")}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
<div className="mt-3">
<button type="button" className="btn btn-sm" onClick={props.onAddStep}>
{t("tariff.addStep")}
</button>
</div>
</>
) : p.mode === "flat" ? (
<div className="inline-flex items-center gap-2">
<span className="label">{t("tariff.pricePerIncrement")}</span>
<input className="input w-28" value={p.flat} onChange={(e) => props.onFlat(e.target.value)} />
</div>
) : (
<>
<table className="w-full border-collapse">
<thead>
<tr className="text-left">
<th className="label px-2 pb-1 font-normal">{t("tariff.bandDuration")}</th>
<th className="label px-2 pb-1 font-normal">{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 className="px-2 py-1">
{isTail ? (
<span className="italic text-term-muted">{t("tariff.thereafter")}</span>
) : (
<span className="inline-flex items-center gap-2">
<input className="input w-20" value={b.hours} onChange={(e) => props.onBlock(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} />
<span className="text-[0.6875rem] text-term-muted">{t("tariff.hoursUnit")}</span>
</span>
)}
</td>
<td className="px-2 py-1">
<input className="input w-28" value={b.price} onChange={(e) => props.onBlock(i, { price: e.target.value })} />
</td>
<td className="px-2">
{!isTail && (
<button type="button" className="btn btn-ghost btn-sm" onClick={() => props.onRemoveBlock(i)}>
{t("tariff.remove")}
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
<div className="mt-3 flex items-center gap-4">
<button type="button" className="btn btn-sm" onClick={props.onAddBlock}>
{t("tariff.addBlock")}
</button>
<span className="inline-flex items-center gap-2">
<span className="label">{t("tariff.dailyCap")}</span>
<input className="input w-28" value={p.dailyCap} onChange={(e) => props.onCap(e.target.value)} placeholder={t("tariff.dailyCapPh")} />
</span>
</div>
</>
)}
</div>
);
}
+661
View File
@@ -0,0 +1,661 @@
import { useTranslation } from "react-i18next";
import { currencyOptions } from "./lib/currencies.js";
import {
isTariffV2,
type TariffBlock,
type TariffCard,
type TariffStep,
type TariffStructure,
type TariffState,
} from "./api.js";
// The tariff EDITOR FORM — the rate-card composer's form machinery (state shape,
// structure↔form converters, and the editing UI), extracted so two hosts can share
// it: the /setup/tariff page (edits + publishes the live card) and the Tariff Lab's
// draft modal (edits an experimental card). The host owns the FormState and the
// submit action; this module owns everything between. Amounts are entered in major
// units (e.g. euros) and converted to integer minor units on submit.
// See wiki/concepts/tariff.md.
// 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.
export interface BlockForm {
hours: string; // duration of THIS band, in hours (ignored for the last block)
price: string; // major units, e.g. "2.00"
}
// One STEPPED ("up-to") row: "a stay up to N hours costs TOTAL". The owner enters the
// matrix verbatim (totals, not marginal rates). See wiki/concepts/tariff.md.
export interface StepForm {
hours: string; // inclusive upper bound of this tier, in hours (e.g. "3")
total: string; // TOTAL major units for a stay within this tier (e.g. "5.00")
}
// A pricing body the form edits: a per-increment flat rate, a marginal block ladder,
// a stepped (up-to) total-by-duration table, or a whole-window package (tiers only).
export interface PricingForm {
mode: "ladder" | "flat" | "stepped" | "package";
flat: string; // major units PER INCREMENT (used when mode==="flat")
packageTotal: string; // major units for the WHOLE window occurrence (mode==="package")
blocks: BlockForm[]; // hours-based ladder (used when mode==="ladder")
steps: StepForm[]; // up-to tiers (used when mode==="stepped")
dailyCap: string; // "" = no cap (ladder only)
}
// An optional time/category TIER (a V2 windowed card). Absent windows = unconstrained.
export 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;
}
export interface FormState {
currency: string;
gracePeriodEntryMin: string;
incrementMin: string;
lostTicket: string;
gracePeriodExitMin: string;
// The default (always-active) card — its own flat/ladder body + daily cap.
base: PricingForm;
// Optional time/category tiers. Empty ⇒ a bare V1 structure is published.
tiers: TierForm[];
}
const toMinor = (major: string): number => Math.round(parseFloat(major || "0") * 100);
const toMajor = (minor: number): string => (minor / 100).toFixed(2);
/** Currency-plausible EXAMPLE amounts for fresh forms/rows. The old hardcoded
* "2.00 / 1.00" examples were euro-scaled — displayed under ALL they read as
* 2 lekë/hour, i.e. nonsense (operator feedback 2026-07-06). Lek amounts are
* ~100× the euro ones; USD rides with EUR. */
function examples(currency: string): { hi: string; lo: string; stepSmall: string; stepBig: string; lost: string } {
return currency.trim().toUpperCase() === "ALL"
? { hi: "200.00", lo: "100.00", stepSmall: "200.00", stepBig: "500.00", lost: "2000.00" }
: { hi: "2.00", lo: "1.00", stepSmall: "2.00", stepBig: "5.00", lost: "20.00" };
}
function emptySteps(currency: string): StepForm[] {
const ex = examples(currency);
return [
{ hours: "1", total: ex.stepSmall },
{ hours: "3", total: ex.stepBig },
];
}
function emptyLadder(currency: string): PricingForm {
const ex = examples(currency);
return {
mode: "ladder",
flat: "0.00",
packageTotal: "0.00",
dailyCap: "",
blocks: [{ hours: "1", price: ex.hi }, { hours: "", price: ex.lo }],
steps: emptySteps(currency),
};
}
function emptyTier(currency: string): TierForm {
return {
name: "",
priority: "10",
category: "",
dow: [],
fromHour: "",
toHour: "",
dateFrom: "",
dateTo: "",
pricing: { ...emptyLadder(currency), blocks: [{ hours: "", price: examples(currency).lo }] },
};
}
export function emptyForm(): FormState {
const currency = "ALL"; // the site's currency — examples scale with it
return {
currency,
gracePeriodEntryMin: "15",
incrementMin: "60",
lostTicket: examples(currency).lost,
gracePeriodExitMin: "15",
base: emptyLadder(currency),
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 stepped table's `uptoMin` (minutes) → the per-tier hours the form edits.
function stepsToForm(steps: TariffStep[]): StepForm[] {
return steps.map((s) => ({ hours: String(s.uptoMin / 60), total: toMajor(s.totalMinor) }));
}
// A stored card (V2) or bare-V1 body → the form's PricingForm (flat, ladder, stepped,
// or window package).
function pricingFromCard(
c: {
flatMinor?: number;
blocks?: TariffBlock[];
steps?: TariffStep[];
packageMinor?: number;
dailyCapMinor?: number | null;
},
currency: string,
): PricingForm {
if (c.steps != null && c.steps.length > 0) {
return { ...emptyLadder(currency), mode: "stepped", steps: stepsToForm(c.steps) };
}
if (c.packageMinor != null) {
return { ...emptyLadder(currency), mode: "package", packageTotal: toMajor(c.packageMinor) };
}
if (c.flatMinor != null) {
return { ...emptyLadder(currency), mode: "flat", flat: toMajor(c.flatMinor) };
}
return {
...emptyLadder(currency),
mode: "ladder",
dailyCap: c.dailyCapMinor == null ? "" : toMajor(c.dailyCapMinor),
blocks: blocksToForm(c.blocks ?? []),
};
}
function tierFromCard(c: TariffCard, currency: string): 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, currency),
};
}
/** A stored (currency, structure) pair → the editable form. Used to load the active
* version into the composer page and a saved draft into the lab modal. */
export function formFromVersion(currency: string, st: TariffStructure): FormState {
const common = {
currency,
gracePeriodEntryMin: String(st.gracePeriodEntryMin),
incrementMin: String(st.incrementMin),
lostTicket: toMajor(st.lostTicketMinor),
gracePeriodExitMin: String(st.gracePeriodExitMin),
};
if (isTariffV2(st)) {
return {
...common,
base: pricingFromCard(st.defaultCard, currency),
tiers: (st.windowedCards ?? []).map((c) => tierFromCard(c, currency)),
};
}
// V1: the bare ladder becomes the default card body; no tiers.
return { ...common, base: pricingFromCard(st, currency), tiers: [] };
}
export function formFromActive(s: TariffState): FormState {
return s.active ? formFromVersion(s.active.currency, s.active.structure) : emptyForm();
}
// Build a tariff card's pricing body (flat XOR ladder XOR stepped XOR package) from a PricingForm.
function pricingToCardBody(p: PricingForm): Pick<TariffCard, "flatMinor" | "blocks" | "steps" | "packageMinor" | "dailyCapMinor"> {
if (p.mode === "flat") return { flatMinor: toMinor(p.flat) };
if (p.mode === "package") return { packageMinor: toMinor(p.packageTotal) };
if (p.mode === "stepped") {
// Each row's `hours` IS the inclusive threshold (the matrix "up to N hours").
const steps: TariffStep[] = p.steps.map((s) => ({
uptoMin: Math.round(Number(s.hours || "0") * 60),
totalMinor: toMinor(s.total),
}));
return { steps };
}
// 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;
}
export function toStructure(f: FormState): TariffStructure {
const common = {
gracePeriodEntryMin: Math.round(Number(f.gracePeriodEntryMin)),
incrementMin: Math.round(Number(f.incrementMin)),
lostTicketMinor: toMinor(f.lostTicket),
gracePeriodExitMin: Math.round(Number(f.gracePeriodExitMin)),
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 === "stepped") {
// A stepped V1: the up-to table replaces the ladder (blocks empty, no cap).
return { ...common, blocks: [], steps: baseBody.steps ?? [], dailyCapMinor: null };
}
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),
};
}
/** The full rate-card editing UI (shared settings + default card + tiers). The host
* owns the FormState; every edit flows through `onChange` as a functional update. */
export function TariffEditorForm({
form,
onChange,
}: {
form: FormState;
onChange: (update: (f: FormState) => FormState) => void;
}) {
const { t } = useTranslation();
// The billing unit all flat/ladder prices are entered in (labels reflect it live).
const inc = Math.max(1, Math.round(Number(form.incrementMin)) || 60);
function set<K extends keyof FormState>(key: K, value: FormState[K]) {
onChange((f) => ({ ...f, [key]: value }));
}
// --- 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) {
onChange((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 setBlock(target: "base" | number, i: number, patch: Partial<BlockForm>) {
updatePricing(target, (p) => ({ ...p, blocks: p.blocks.map((b, j) => (j === i ? { ...b, ...patch } : b)) }));
}
// Insert a bounded band just BEFORE the open-ended tail, so the last block stays open-ended.
function addBlock(target: "base" | number) {
updatePricing(target, (p) => {
const next = [...p.blocks];
next.splice(p.blocks.length - 1, 0, { hours: "1", price: "0.00" });
return { ...p, blocks: next };
});
}
function removeBlock(target: "base" | number, i: number) {
updatePricing(target, (p) => (i === p.blocks.length - 1 || p.blocks.length <= 1 ? p : { ...p, blocks: p.blocks.filter((_, j) => j !== i) }));
}
// --- stepped (up-to) editing (base card only) ---
function setStep(i: number, patch: Partial<StepForm>) {
updatePricing("base", (p) => ({ ...p, steps: p.steps.map((s, j) => (j === i ? { ...s, ...patch } : s)) }));
}
function addStep() {
updatePricing("base", (p) => ({ ...p, steps: [...p.steps, { hours: "", total: "0.00" }] }));
}
function removeStep(i: number) {
updatePricing("base", (p) => (p.steps.length <= 1 ? p : { ...p, steps: p.steps.filter((_, j) => j !== i) }));
}
// --- tier editing ---
function setTier(i: number, patch: Partial<TierForm>) {
onChange((f) => ({ ...f, tiers: f.tiers.map((tr, j) => (j === i ? { ...tr, ...patch } : tr)) }));
}
function addTier() {
onChange((f) => ({ ...f, tiers: [...f.tiers, emptyTier(f.currency)] }));
}
function removeTier(i: number) {
onChange((f) => ({ ...f, tiers: f.tiers.filter((_, j) => j !== i) }));
}
function toggleDow(i: number, d: number) {
onChange((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,
),
}));
}
return (
<div>
<div className="card card-body grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
<label className="label">{t("tariff.currency")}</label>
<select className="input w-24" value={form.currency} onChange={(e) => set("currency", e.target.value)}>
{currencyOptions(form.currency).map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
<label className="label">{t("tariff.freeEntryGrace")}</label>
<input className="input w-32" value={form.gracePeriodEntryMin} onChange={(e) => set("gracePeriodEntryMin", e.target.value)} />
<label className="label">{t("tariff.billingIncrement")}</label>
<input className="input w-32" value={form.incrementMin} onChange={(e) => set("incrementMin", e.target.value)} />
<label className="label">{t("tariff.lostTicketFee")}</label>
<input className="input w-32" value={form.lostTicket} onChange={(e) => set("lostTicket", e.target.value)} />
<label className="label">{t("tariff.exitGrace")}</label>
<input className="input w-32" value={form.gracePeriodExitMin} onChange={(e) => set("gracePeriodExitMin", e.target.value)} />
</div>
{/* The increment is the UNIT every flat/ladder price is charged in. At 60 the
form reads naturally as per-hour; any other value silently redefines every
price below, so shout it (the 60→10 "six charges per hour" trap). */}
{inc !== 60 && (
<p className="mt-2 rounded-term border border-term-amber/50 bg-term-amber/10 px-3 py-2 text-[0.75rem] text-term-amber">
{t("tariff.incrementWarning", { min: inc })}
</p>
)}
{/* The DEFAULT card — always-active rate. Front-and-centre; a site that never
wants tiers just edits this and publishes a bare V1 structure. */}
<h3 className="mt-6 mb-0.5 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("tariff.defaultCard")}</h3>
<p className="hint mb-2">{t("tariff.defaultCardHint")}</p>
<div className="card card-body">
<PricingEditor
t={t}
pricing={form.base}
incrementMin={inc}
allowStepped
onMode={(mode) => updatePricing("base", (p) => ({ ...p, mode }))}
onFlat={(flat) => updatePricing("base", (p) => ({ ...p, flat }))}
onCap={(dailyCap) => updatePricing("base", (p) => ({ ...p, dailyCap }))}
onBlock={(i, patch) => setBlock("base", i, patch)}
onAddBlock={() => addBlock("base")}
onRemoveBlock={(i) => removeBlock("base", i)}
onStep={setStep}
onAddStep={addStep}
onRemoveStep={removeStep}
/>
</div>
{/* Advanced: time & seasonal/category TIERS (opt-in). Empty ⇒ V1 is published. */}
<details className="mt-6" open={form.tiers.length > 0}>
<summary className="cursor-pointer text-h6 font-semibold uppercase tracking-wider text-term-text">{t("tariff.tiersAdvanced")}</summary>
<p className="hint mt-1.5 mb-2">{t("tariff.tiersHint")}</p>
{/* A stepped ("up-to") base rate cannot be combined with time tiers — the
engine would ignore them. Warn up-front; publishing is also blocked server-side. */}
{form.base.mode === "stepped" && form.tiers.length > 0 && (
<p className="mb-3 rounded-term border border-term-red/50 bg-term-red/10 px-3 py-2 text-[0.75rem] text-term-red">
{t("tariff.steppedTiersConflict")}
</p>
)}
{form.tiers.map((tr, i) => (
<fieldset key={i} className="card mb-3 p-4">
<legend className="flex items-center gap-2 px-1">
<input
className="input w-40"
value={tr.name}
onChange={(e) => setTier(i, { name: e.target.value })}
placeholder={t("tariff.tierName")}
/>
<button type="button" className="btn btn-danger btn-sm" onClick={() => removeTier(i)}>
{t("tariff.remove")}
</button>
</legend>
<div className="grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
<label className="label">{t("tariff.tierPriority")}</label>
<input className="input w-20" value={tr.priority} onChange={(e) => setTier(i, { priority: e.target.value })} />
<label className="label">{t("tariff.tierCategory")}</label>
<input className="input w-40" value={tr.category} onChange={(e) => setTier(i, { category: e.target.value })} placeholder={t("tariff.tierCategoryPh")} />
<label className="label">{t("tariff.tierDays")}</label>
<span className="flex flex-wrap gap-2">
{[1, 2, 3, 4, 5, 6, 0].map((d) => (
<label key={d} className="inline-flex items-center gap-1 text-[0.75rem] text-term-text">
<input type="checkbox" className="accent-term-amber" checked={tr.dow.includes(d)} onChange={() => toggleDow(i, d)} />
{t(`tariff.dow${d}`)}
</label>
))}
</span>
<label className="label">{t("tariff.tierHours")}</label>
<span className="inline-flex items-center gap-2">
<input className="input w-20" value={tr.fromHour} onChange={(e) => setTier(i, { fromHour: e.target.value })} placeholder="22:00" />
<span className="text-term-muted">–</span>
<input className="input w-20" value={tr.toHour} onChange={(e) => setTier(i, { toHour: e.target.value })} placeholder="06:00" />
{tr.fromHour && tr.toHour && tr.toHour <= tr.fromHour && (
<span className="text-[0.6875rem] text-term-muted">{t("tariff.tierOvernight")}</span>
)}
</span>
<label className="label">{t("tariff.tierDates")}</label>
<span className="inline-flex items-center gap-2">
<input type="date" className="input w-40" value={tr.dateFrom} onChange={(e) => setTier(i, { dateFrom: e.target.value })} />
<span className="text-term-muted">–</span>
<input type="date" className="input w-40" value={tr.dateTo} onChange={(e) => setTier(i, { dateTo: e.target.value })} />
</span>
</div>
<div className="mt-3 border-t border-term-border pt-3">
<PricingEditor
t={t}
pricing={tr.pricing}
incrementMin={inc}
allowPackage
onMode={(mode) => updatePricing(i, (p) => ({ ...p, mode }))}
onFlat={(flat) => updatePricing(i, (p) => ({ ...p, flat }))}
onPackage={(packageTotal) => updatePricing(i, (p) => ({ ...p, packageTotal }))}
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" className="btn btn-sm" onClick={addTier}>
{t("tariff.addTier")}
</button>
</details>
</div>
);
}
// A reusable pricing-body editor — flat (per increment) / marginal ladder / stepped
// (up-to) / window package. The stepped mode is offered only where `allowStepped`
// (the default card); the package mode only where `allowPackage` (tier cards — the
// engine needs a window to be an occurrence of).
function PricingEditor(props: {
t: (k: string, opts?: Record<string, unknown>) => string;
pricing: PricingForm;
/** Current billing increment (minutes) — every flat/ladder price is PER this unit,
* so the price labels state it explicitly instead of a vague "per increment". */
incrementMin: number;
allowStepped?: boolean;
allowPackage?: boolean;
onMode: (m: "ladder" | "flat" | "stepped" | "package") => void;
onFlat: (v: string) => void;
onPackage?: (v: string) => void;
onCap: (v: string) => void;
onBlock: (i: number, patch: Partial<BlockForm>) => void;
onAddBlock: () => void;
onRemoveBlock: (i: number) => void;
onStep?: (i: number, patch: Partial<StepForm>) => void;
onAddStep?: () => void;
onRemoveStep?: (i: number) => void;
}) {
const { t, pricing: p } = props;
/** "= N / orë" equivalence for a per-increment price (only shown when the tick
* isn't an hour — at 60 the price already IS the hourly price). */
const perHour = (major: string): string | null => {
if (props.incrementMin === 60) return null;
const v = Number(major);
if (!Number.isFinite(v) || v <= 0) return null;
return t("tariff.perHourEquiv", { amount: ((v * 60) / props.incrementMin).toFixed(2) });
};
const unitLabel =
props.incrementMin === 60 ? t("tariff.pricePerHour") : t("tariff.pricePerN", { min: props.incrementMin });
const flatLabel =
props.incrementMin === 60 ? t("tariff.modeFlat") : t("tariff.modeFlatN", { min: props.incrementMin });
return (
<div>
<div className="mb-3 flex flex-wrap gap-4 text-[0.75rem]">
<label className="inline-flex items-center gap-1.5 text-term-text">
<input type="radio" className="accent-term-amber" checked={p.mode === "ladder"} onChange={() => props.onMode("ladder")} />
{t("tariff.modeLadder")}
</label>
<label className="inline-flex items-center gap-1.5 text-term-text">
<input type="radio" className="accent-term-amber" checked={p.mode === "flat"} onChange={() => props.onMode("flat")} />
{flatLabel}
</label>
{props.allowStepped && (
<label className="inline-flex items-center gap-1.5 text-term-text">
<input type="radio" className="accent-term-amber" checked={p.mode === "stepped"} onChange={() => props.onMode("stepped")} />
{t("tariff.modeStepped")}
</label>
)}
{props.allowPackage && (
<label className="inline-flex items-center gap-1.5 text-term-text">
<input type="radio" className="accent-term-amber" checked={p.mode === "package"} onChange={() => props.onMode("package")} />
{t("tariff.modePackage")}
</label>
)}
</div>
{p.mode === "package" ? (
<div>
<p className="hint mb-2">{t("tariff.packageHint")}</p>
<div className="inline-flex items-center gap-2">
<span className="label">{t("tariff.packageTotal")}</span>
<input className="input w-28" value={p.packageTotal} onChange={(e) => props.onPackage?.(e.target.value)} />
</div>
</div>
) : p.mode === "stepped" ? (
<>
<p className="hint mb-2">{t("tariff.steppedHint")}</p>
<table className="w-full border-collapse">
<thead>
<tr className="text-left">
<th className="label px-2 pb-1 font-normal">{t("tariff.stepUpTo")}</th>
<th className="label px-2 pb-1 font-normal">{t("tariff.stepTotal")}</th>
<th />
</tr>
</thead>
<tbody>
{p.steps.map((s, i) => (
<tr key={i}>
<td className="px-2 py-1">
<span className="inline-flex items-center gap-2">
<input className="input w-20" value={s.hours} onChange={(e) => props.onStep?.(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} />
<span className="text-[0.6875rem] text-term-muted">{t("tariff.hoursUnit")}</span>
</span>
</td>
<td className="px-2 py-1">
<input className="input w-28" value={s.total} onChange={(e) => props.onStep?.(i, { total: e.target.value })} />
</td>
<td className="px-2">
{p.steps.length > 1 && (
<button type="button" className="btn btn-ghost btn-sm" onClick={() => props.onRemoveStep?.(i)}>
{t("tariff.remove")}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
<div className="mt-3">
<button type="button" className="btn btn-sm" onClick={props.onAddStep}>
{t("tariff.addStep")}
</button>
</div>
</>
) : p.mode === "flat" ? (
<div className="inline-flex items-center gap-2">
<span className="label">{unitLabel}</span>
<input className="input w-28" value={p.flat} onChange={(e) => props.onFlat(e.target.value)} />
{perHour(p.flat) && <span className="text-[0.6875rem] text-term-muted">{perHour(p.flat)}</span>}
</div>
) : (
<>
<table className="w-full border-collapse">
<thead>
<tr className="text-left">
<th className="label px-2 pb-1 font-normal">{t("tariff.bandDuration")}</th>
<th className="label px-2 pb-1 font-normal">{unitLabel}</th>
<th />
</tr>
</thead>
<tbody>
{p.blocks.map((b, i) => {
const isTail = i === p.blocks.length - 1;
return (
<tr key={i}>
<td className="px-2 py-1">
{isTail ? (
<span className="italic text-term-muted">{t("tariff.thereafter")}</span>
) : (
<span className="inline-flex items-center gap-2">
<input className="input w-20" value={b.hours} onChange={(e) => props.onBlock(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} />
<span className="text-[0.6875rem] text-term-muted">{t("tariff.hoursUnit")}</span>
</span>
)}
</td>
<td className="px-2 py-1">
<span className="inline-flex items-center gap-2">
<input className="input w-28" value={b.price} onChange={(e) => props.onBlock(i, { price: e.target.value })} />
{perHour(b.price) && <span className="text-[0.6875rem] text-term-muted">{perHour(b.price)}</span>}
</span>
</td>
<td className="px-2">
{!isTail && (
<button type="button" className="btn btn-ghost btn-sm" onClick={() => props.onRemoveBlock(i)}>
{t("tariff.remove")}
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
<div className="mt-3 flex items-center gap-4">
<button type="button" className="btn btn-sm" onClick={props.onAddBlock}>
{t("tariff.addBlock")}
</button>
<span className="inline-flex items-center gap-2">
<span className="label">{t("tariff.dailyCap")}</span>
<input className="input w-28" value={p.dailyCap} onChange={(e) => props.onCap(e.target.value)} placeholder={t("tariff.dailyCapPh")} />
</span>
</div>
</>
)}
</div>
);
}
+416 -166
View File
@@ -1,21 +1,32 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ApiError,
createTariffDraft,
deleteTariffDraft,
fetchTariff,
loadSimSession,
fetchTariffDrafts,
publishTariffVersion,
simulateTariff,
updateTariffDraft,
type SimulateResult,
type SimPayment,
type TariffDraft,
type TariffState,
} from "./api.js";
import { formatMoney, formatDuration } from "./lib/format.js";
import { TariffEditorForm, emptyForm, formFromActive, formFromVersion, toStructure, type FormState } from "./TariffEditorForm.js";
import { Modal } from "./ui/Modal.js";
import { formatClock, formatDateTime, formatMoney, formatDuration } from "./lib/format.js";
import type { FeeBreakdown } from "@parking/shared";
import type { TFunction } from "i18next";
// The TARIFF LAB — a pure session-pricing simulator. Test rates "in time" (overnight
// windows, daily caps, overstay) in seconds instead of waiting hours, against ANY
// published tariff version, with no real ledger writes. Build a hypothetical session
// (entry, optional payment, "now") OR load a real ticket and re-evaluate it at any
// instant. Prices via the SAME `priceSession` the booth uses (server), so the lab and
// the live booth can never diverge. See wiki/concepts/tariff.md, booth-exit-flow.md.
// The TARIFF LAB — a sandbox for composing + pricing EXPERIMENTAL rate cards. Drafts
// live in their own mutable table (tariff_drafts), so experimenting never churns the
// immutable published versions or risks a half-baked card going live: the admin
// composes a draft in the modal (the same form the composer page uses), simulates
// hypothetical stays against it (entry + exit, nothing else), and only when satisfied
// PUBLISHES it through the normal immutable-version path. Pricing uses the SAME
// `priceSession` the booth uses (server-side), so the lab and the live booth can
// never diverge. No ledger writes. See wiki/concepts/tariff.md.
/** <input type="datetime-local"> wants "YYYY-MM-DDTHH:mm" in LOCAL time. */
function toLocalInput(iso: string): string {
@@ -33,50 +44,76 @@ function nowLocal(): string {
return toLocalInput(new Date().toISOString());
}
/** What the simulation runs against: the live card, a historical published
* version, or one lab draft. */
type Selection = { kind: "active" } | { kind: "version"; id: string } | { kind: "draft"; id: string };
/** Modal state: a draft being composed (id null = not yet saved). */
interface DraftEdit {
id: string | null;
name: string;
form: FormState;
}
export function TariffLab() {
const { t } = useTranslation();
const [state, setState] = useState<TariffState | null>(null);
const [drafts, setDrafts] = useState<TariffDraft[]>([]);
const [selected, setSelected] = useState<Selection>({ kind: "active" });
const [err, setErr] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
// Inputs (datetime-local strings, local wall-clock).
// The hypothetical stay: entry + exit, nothing else.
const [entered, setEntered] = useState<string>(() => {
const d = new Date();
d.setHours(d.getHours() - 3); // default: a 3h-ago entry
return toLocalInput(d.toISOString());
});
const [asOf, setAsOf] = useState<string>(nowLocal);
const [category, setCategory] = useState("");
const [versionId, setVersionId] = useState<string>(""); // "" = active
// Optional single hypothetical payment (the latest grants the walk-back grace).
const [paid, setPaid] = useState(false);
const [paidAt, setPaidAt] = useState<string>(nowLocal);
const [graceMin, setGraceMin] = useState<string>("5");
// Load-a-real-ticket.
const [ticket, setTicket] = useState("");
const [loadMsg, setLoadMsg] = useState<string | null>(null);
const [exit, setExit] = useState<string>(nowLocal);
const [result, setResult] = useState<SimulateResult | null>(null);
const [busy, setBusy] = useState(false);
// The draft-composer modal.
const [edit, setEdit] = useState<DraftEdit | null>(null);
const [saving, setSaving] = useState(false);
const [editErr, setEditErr] = useState<string | null>(null);
async function refresh() {
const [s, d] = await Promise.all([fetchTariff(), fetchTariffDrafts()]);
setState(s);
setDrafts(d.drafts);
return d.drafts;
}
useEffect(() => {
fetchTariff()
.then(setState)
.catch((e) => setErr((e as Error).message));
refresh().catch((e) => setErr((e as Error).message));
}, []);
const selectedDraft = selected.kind === "draft" ? drafts.find((d) => d.id === selected.id) ?? null : null;
const selectedVersion =
selected.kind === "version" ? state?.versions.find((v) => v.id === selected.id) ?? null : null;
function select(sel: Selection) {
setSelected(sel);
setResult(null); // a stale price against another card would mislead
setErr(null);
setNotice(null);
}
async function run() {
setErr(null);
setBusy(true);
try {
const payments: SimPayment[] = paid
? [{ paidAt: fromLocalInput(paidAt), graceExitMin: graceMin.trim() === "" ? null : Number(graceMin) }]
: [];
const r = await simulateTariff({
enteredAt: fromLocalInput(entered),
asOf: fromLocalInput(asOf),
payments,
category: category.trim() || undefined,
tariffVersionId: versionId || undefined,
asOf: fromLocalInput(exit),
// A draft carries its own structure+currency; a historical version is
// referenced by id; otherwise the ACTIVE version.
...(selectedDraft
? { structure: selectedDraft.structure, currency: selectedDraft.currency }
: selectedVersion
? { tariffVersionId: selectedVersion.id }
: {}),
});
setResult(r);
} catch (e) {
@@ -87,162 +124,296 @@ export function TariffLab() {
}
}
async function loadTicket() {
setLoadMsg(null);
// --- draft actions ---
function newDraft() {
// Start from the live card when there is one — the admin usually experiments
// with a variation of today's prices, not from a blank slate.
const form = state?.active ? formFromActive(state) : emptyForm();
setEditErr(null);
setEdit({ id: null, name: "", form });
}
function editDraft(d: TariffDraft) {
setEditErr(null);
setEdit({ id: d.id, name: d.name, form: formFromVersion(d.currency, d.structure) });
}
async function saveDraft() {
if (!edit) return;
setSaving(true);
setEditErr(null);
try {
const body = {
name: edit.name.trim(),
currency: edit.form.currency.trim().toUpperCase(),
structure: toStructure(edit.form),
};
const saved = edit.id ? await updateTariffDraft(edit.id, body) : await createTariffDraft(body);
await refresh();
setEdit(null);
select({ kind: "draft", id: saved.id });
} catch (e) {
const text =
e instanceof ApiError && e.problems?.length ? `${e.message}: ${e.problems.join("; ")}` : (e as Error).message;
setEditErr(text);
} finally {
setSaving(false);
}
}
async function removeDraft(d: TariffDraft) {
if (!confirm(t("lab.confirmDelete", { name: d.name }))) return;
setErr(null);
try {
const s = await loadSimSession(ticket.trim());
setEntered(toLocalInput(s.enteredAt));
setAsOf(s.exitedAt ? toLocalInput(s.exitedAt) : nowLocal());
setCategory(s.category ?? "");
setVersionId(s.tariffVersionId ?? "");
const last = s.payments.at(-1);
if (last) {
setPaid(true);
setPaidAt(toLocalInput(last.paidAt));
setGraceMin(last.graceExitMin != null ? String(last.graceExitMin) : "");
} else {
setPaid(false);
}
setLoadMsg(t("lab.loaded", { id: s.identity }));
await deleteTariffDraft(d.id);
await refresh();
select({ kind: "active" });
} catch (e) {
setErr((e as Error).message);
}
}
const currency = result?.currency ?? state?.active?.currency ?? "ALL";
async function publishDraft(d: TariffDraft) {
if (!confirm(t("lab.confirmPublish", { name: d.name }))) return;
setErr(null);
setNotice(null);
try {
// The draft's name rides along onto the immutable version.
await publishTariffVersion({ currency: d.currency, structure: d.structure, name: d.name });
await refresh();
setNotice(t("tariff.publishedOk"));
} catch (e) {
const text =
e instanceof ApiError && e.problems?.length ? `${e.message}: ${e.problems.join("; ")}` : (e as Error).message;
setErr(text);
}
}
const currency = result?.currency ?? selectedDraft?.currency ?? selectedVersion?.currency ?? state?.active?.currency ?? "ALL";
return (
<section className="px-4 py-6">
<h2 className="mb-1 text-h4 font-semibold text-term-text">{t("lab.title")}</h2>
<p className="hint mb-4">{t("lab.intro")}</p>
{/* Load a real ticket */}
<div className="card card-body mb-4 flex flex-wrap items-end gap-2">
<div className="flex flex-col gap-1">
<label className="label">{t("lab.loadTicket")}</label>
<input
className="input w-56"
value={ticket}
onChange={(e) => setTicket(e.target.value)}
placeholder={t("lab.loadTicketPh")}
/>
</div>
<button type="button" className="btn btn-sm" onClick={loadTicket} disabled={!ticket.trim()}>
{t("lab.load")}
</button>
{loadMsg && <span className="text-[0.75rem] text-term-green">{loadMsg}</span>}
</div>
<div className="flex flex-col gap-4 lg:flex-row">
{/* Main: the hypothetical stay + result, priced against the selection. */}
<div className="min-w-0 flex-1">
{/* What we're pricing against + draft actions. */}
<div className="mb-3 flex flex-wrap items-center gap-2">
<span className="rounded bg-term-panel-2 px-2 py-1 text-[0.75rem] text-term-cyan">
{selectedDraft
? selectedDraft.name
: selectedVersion
? selectedVersion.name ?? formatDateTime(selectedVersion.effectiveFrom, t)
: t("lab.activeTariff")}
</span>
{selectedDraft && (
<>
<button type="button" className="btn btn-sm" onClick={() => editDraft(selectedDraft)}>
{t("lab.edit")}
</button>
<button type="button" className="btn btn-sm" onClick={() => publishDraft(selectedDraft)}>
{t("lab.publish")}
</button>
<button type="button" className="btn btn-danger btn-sm" onClick={() => removeDraft(selectedDraft)}>
{t("lab.delete")}
</button>
</>
)}
{notice && <span className="text-[0.75rem] text-term-green">{notice}</span>}
</div>
{/* Hypothetical session inputs */}
<div className="card card-body grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
<label className="label">{t("lab.tariffVersion")}</label>
<select className="input w-full max-w-md" value={versionId} onChange={(e) => setVersionId(e.target.value)}>
<option value="">{t("lab.activeVersion")}</option>
{state?.versions.map((v) => (
<option key={v.id} value={v.id}>
{new Date(v.effectiveFrom).toLocaleString()} · {v.currency} · {v.id.slice(0, 8)}
</option>
))}
</select>
<div className="card card-body grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
<label className="label">{t("lab.entered")}</label>
<input type="datetime-local" className="input w-64" value={entered} onChange={(e) => setEntered(e.target.value)} />
<label className="label">{t("lab.entered")}</label>
<input type="datetime-local" className="input w-64" value={entered} onChange={(e) => setEntered(e.target.value)} />
<label className="label">{t("lab.exit")}</label>
<span className="flex items-center gap-2">
<input type="datetime-local" className="input w-64" value={exit} onChange={(e) => setExit(e.target.value)} />
<button type="button" className="btn btn-sm" onClick={() => setExit(nowLocal())}>
{t("lab.now")}
</button>
</span>
</div>
<label className="label">{t("lab.asOf")}</label>
<span className="flex items-center gap-2">
<input type="datetime-local" className="input w-64" value={asOf} onChange={(e) => setAsOf(e.target.value)} />
<button type="button" className="btn btn-sm" onClick={() => setAsOf(nowLocal())}>
{t("lab.now")}
</button>
</span>
<div className="mt-4 flex items-center gap-3">
<button type="button" className="btn btn-primary btn-lg" onClick={run} disabled={busy}>
{busy ? t("lab.pricing") : t("lab.price")}
</button>
{err && <span className="text-[0.75rem] text-term-red">{err}</span>}
</div>
<label className="label">{t("lab.category")}</label>
<input
className="input w-40"
value={category}
onChange={(e) => setCategory(e.target.value)}
placeholder={t("lab.categoryPh")}
/>
{result && (
<div className="mt-6 grid gap-4 md:grid-cols-2">
{/* Outcome */}
<div className="card card-body">
<h3 className="mb-2 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.outcome")}</h3>
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-[0.8125rem]">
<dt className="text-term-muted">{t("lab.amountDue")}</dt>
<dd className="text-2xl font-bold text-term-cyan">{formatMoney(result.pricing.amountMinor, currency)}</dd>
<dt className="text-term-muted">{t("lab.billedPeriod")}</dt>
<dd className="text-term-text">
{formatDuration(result.pricing.periodStart, fromLocalInput(exit))}
{result.pricing.overstay && (
<span className="ml-2 rounded bg-term-red/15 px-1.5 py-0.5 text-[0.625rem] uppercase text-term-red">
{t("lab.overstay")}
</span>
)}
{result.pricing.withinGrace && (
<span className="ml-2 rounded bg-term-green/15 px-1.5 py-0.5 text-[0.625rem] uppercase text-term-green">
{t("lab.settled")}
</span>
)}
</dd>
<dt className="text-term-muted">{t("lab.periodStart")}</dt>
<dd className="text-term-text">{formatDateTime(result.pricing.periodStart, t)}</dd>
{result.pricing.graceExpiresAt && (
<>
<dt className="text-term-muted">{t("lab.graceExpires")}</dt>
<dd className="text-term-text">{formatDateTime(result.pricing.graceExpiresAt, t)}</dd>
</>
)}
</dl>
{/* HOW the sum is produced — line items from the SAME engine walk
(their sum is the amount by construction). */}
{result.breakdown && (
<BreakdownTable b={result.breakdown} periodStart={result.pricing.periodStart} currency={currency} t={t} />
)}
</div>
<label className="label">{t("lab.payment")}</label>
<span className="flex flex-wrap items-center gap-2">
<label className="inline-flex items-center gap-1 text-[0.75rem] text-term-text">
<input type="checkbox" className="accent-term-amber" checked={paid} onChange={(e) => setPaid(e.target.checked)} />
{t("lab.paid")}
</label>
{paid && (
<>
<input
type="datetime-local"
className="input w-64"
value={paidAt}
onChange={(e) => setPaidAt(e.target.value)}
/>
<span className="text-term-muted">{t("lab.graceMin")}</span>
<input className="input w-20" value={graceMin} onChange={(e) => setGraceMin(e.target.value)} />
</>
{/* Duration curve from entry — see where the cap flattens / windows shift. */}
<div className="card card-body">
<h3 className="mb-2 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.curve")}</h3>
<p className="hint mb-2">{t("lab.curveHint")}</p>
<table className="w-full text-[0.75rem] tabular-nums">
<tbody>
{result.curve.map((c) => (
<tr key={c.minutes} className="border-b border-term-border/40">
<td className="py-0.5 text-term-muted">{labelMin(c.minutes)}</td>
<td className="py-0.5 text-right text-term-text">{formatMoney(c.amountMinor, currency)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</span>
</div>
<div className="mt-4 flex items-center gap-3">
<button type="button" className="btn btn-primary btn-lg" onClick={run} disabled={busy}>
{busy ? t("lab.pricing") : t("lab.price")}
</button>
{err && <span className="text-[0.75rem] text-term-red">{err}</span>}
</div>
{result && (
<div className="mt-6 grid gap-4 md:grid-cols-2">
{/* Outcome */}
<div className="card card-body">
<h3 className="mb-2 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.outcome")}</h3>
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-[0.8125rem]">
<dt className="text-term-muted">{t("lab.amountDue")}</dt>
<dd className="text-2xl font-bold text-term-cyan">{formatMoney(result.pricing.amountMinor, currency)}</dd>
<dt className="text-term-muted">{t("lab.billedPeriod")}</dt>
<dd className="text-term-text">
{formatDuration(result.pricing.periodStart, fromLocalInput(asOf))}
{result.pricing.overstay && (
<span className="ml-2 rounded bg-term-red/15 px-1.5 py-0.5 text-[0.625rem] uppercase text-term-red">
{t("lab.overstay")}
</span>
)}
{result.pricing.withinGrace && (
<span className="ml-2 rounded bg-term-green/15 px-1.5 py-0.5 text-[0.625rem] uppercase text-term-green">
{t("lab.settled")}
</span>
)}
</dd>
<dt className="text-term-muted">{t("lab.periodStart")}</dt>
<dd className="text-term-text">{new Date(result.pricing.periodStart).toLocaleString()}</dd>
{result.pricing.graceExpiresAt && (
<>
<dt className="text-term-muted">{t("lab.graceExpires")}</dt>
<dd className="text-term-text">{new Date(result.pricing.graceExpiresAt).toLocaleString()}</dd>
</>
)}
</dl>
</div>
{/* Duration curve from entry — see where the cap flattens / windows shift. */}
<div className="card card-body">
<h3 className="mb-2 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.curve")}</h3>
<p className="hint mb-2">{t("lab.curveHint")}</p>
<table className="w-full text-[0.75rem] tabular-nums">
<tbody>
{result.curve.map((c) => (
<tr key={c.minutes} className="border-b border-term-border/40">
<td className="py-0.5 text-term-muted">{labelMin(c.minutes)}</td>
<td className="py-0.5 text-right text-term-text">{formatMoney(c.amountMinor, currency)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Sidebar: lab drafts + the full published history; click any to price
against it. */}
<aside className="w-full shrink-0 lg:w-72">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.drafts")}</h3>
<button type="button" className="btn btn-sm" onClick={newDraft}>
{t("lab.newDraft")}
</button>
</div>
<ul className="flex flex-col gap-1">
{drafts.map((d) => (
<li key={d.id}>
<button
type="button"
onClick={() => select({ kind: "draft", id: d.id })}
className={`w-full rounded-term border px-3 py-2 text-left text-[0.8125rem] ${
selected.kind === "draft" && selected.id === d.id
? "border-term-amber bg-term-amber/10 text-term-text"
: "border-term-border text-term-muted hover:text-term-text"
}`}
>
<span className="block font-semibold">{d.name}</span>
<span className="block text-[0.6875rem] text-term-muted">
{d.currency} · {formatDateTime(d.updatedAt, t)}
</span>
</button>
</li>
))}
{drafts.length === 0 && <li className="hint px-1 py-2">{t("lab.noDrafts")}</li>}
</ul>
{/* Published versions: the active card first, then the immutable history
(older versions still price past sessions — see wiki/concepts/tariff.md). */}
<h3 className="mb-2 mt-5 text-h6 font-semibold uppercase tracking-wider text-term-text">
{t("lab.published")}
</h3>
<ul className="flex flex-col gap-1">
<li>
<button
type="button"
onClick={() => select({ kind: "active" })}
className={`w-full rounded-term border px-3 py-2 text-left text-[0.8125rem] ${
selected.kind === "active"
? "border-term-amber bg-term-amber/10 text-term-text"
: "border-term-border text-term-muted hover:text-term-text"
}`}
>
<span className="block font-semibold">
{t("lab.activeTariff")}
{state?.active?.name ? ` — ${state.active.name}` : ""}
</span>
<span className="block text-[0.6875rem] text-term-muted">
{state?.active ? formatDateTime(state.active.effectiveFrom, t) : t("tariff.noRateCard")}
</span>
</button>
</li>
{state?.versions
.filter((v) => v.id !== state.active?.id)
.map((v) => (
<li key={v.id}>
<button
type="button"
onClick={() => select({ kind: "version", id: v.id })}
className={`w-full rounded-term border px-3 py-2 text-left text-[0.8125rem] ${
selected.kind === "version" && selected.id === v.id
? "border-term-amber bg-term-amber/10 text-term-text"
: "border-term-border text-term-muted hover:text-term-text"
}`}
>
<span className="block font-semibold">
{v.name ?? formatDateTime(v.effectiveFrom, t)}
</span>
<span className="block text-[0.6875rem] text-term-muted">
{v.name ? `${formatDateTime(v.effectiveFrom, t)} · ` : ""}
{v.currency}
</span>
</button>
</li>
))}
</ul>
</aside>
</div>
{/* The draft composer — the SAME form the /setup/tariff page uses, in a modal. */}
<Modal
open={edit != null}
onClose={() => setEdit(null)}
title={edit?.id ? t("lab.editDraftTitle") : t("lab.newDraftTitle")}
width="max-w-3xl"
>
{edit && (
<div>
<div className="mb-4 flex items-center gap-2">
<label className="label">{t("lab.draftName")}</label>
<input
className="input w-72"
value={edit.name}
onChange={(e) => setEdit((d) => (d ? { ...d, name: e.target.value } : d))}
placeholder={t("lab.draftNamePh")}
/>
</div>
<TariffEditorForm
form={edit.form}
onChange={(update) => setEdit((d) => (d ? { ...d, form: update(d.form) } : d))}
/>
<div className="mt-6 flex items-center gap-3">
<button type="button" className="btn btn-primary" onClick={saveDraft} disabled={saving || !edit.name.trim()}>
{saving ? t("lab.savingDraft") : t("lab.saveDraft")}
</button>
{editErr && <span className="text-[0.75rem] text-term-red">{editErr}</span>}
</div>
</div>
)}
</Modal>
</section>
);
}
@@ -252,3 +423,82 @@ function labelMin(min: number): string {
if (min < 1440) return `${min / 60}h`;
return `${min / 1440}d`;
}
/** The fee's line items — every row states its time window / rule and its amount, so
* the operator can retrace the exact sum (caps show as negative adjustments). */
function BreakdownTable({
b,
periodStart,
currency,
t,
}: {
b: FeeBreakdown;
periodStart: string;
currency: string;
t: TFunction;
}) {
const startMs = Date.parse(periodStart);
const multiDay = b.billedMinutes > 1440;
const at = (min: number) => {
const iso = new Date(startMs + min * 60_000).toISOString();
return multiDay ? formatDateTime(iso, t) : formatClock(iso);
};
const money = (m: number) => formatMoney(m, currency);
const hours = (min: number) => (min % 60 === 0 ? `${min / 60}` : (min / 60).toFixed(1));
return (
<div className="mt-3 border-t border-term-border pt-2">
<div className="mb-1 text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("lab.bd.title")}</div>
{b.billedMinutes > 0 && (
<p className="hint mb-1.5">
{t("lab.bd.rounding", { raw: b.rawMinutes, billed: b.billedMinutes, inc: b.incrementMin })}
</p>
)}
<table className="w-full text-[0.75rem] tabular-nums">
<tbody>
{b.items.map((it, i) => {
let label: string;
let amount: number;
let cls = "text-term-text";
switch (it.kind) {
case "grace":
label = t("lab.bd.grace", { min: it.minutes });
amount = 0;
cls = "text-term-green";
break;
case "band":
label = `${at(it.fromMin)}–${at(it.toMin)} · ${it.increments} × ${money(it.unitMinor)}${it.card ? ` · ${it.card}` : ""}`;
amount = it.amountMinor;
break;
case "package":
label = `${at(it.fromMin)} · ${it.card} — ${t("lab.bd.package")}`;
amount = it.amountMinor;
break;
case "step":
label = it.repeated
? t("lab.bd.stepRepeated", { day: it.day })
: t("lab.bd.step", { day: it.day, hours: hours(it.uptoMin) });
amount = it.amountMinor;
break;
case "cap":
label = t("lab.bd.cap", { day: it.day, cap: money(it.capMinor) });
amount = it.amountMinor;
cls = "text-term-red";
break;
}
return (
<tr key={i} className="border-b border-term-border/40">
<td className="py-0.5 pr-2 text-term-muted">{label}</td>
<td className={`whitespace-nowrap py-0.5 text-right ${cls}`}>{money(amount)}</td>
</tr>
);
})}
<tr>
<td className="py-1 pr-2 font-semibold text-term-text">{t("lab.bd.total")}</td>
<td className="whitespace-nowrap py-1 text-right font-semibold text-term-cyan">{money(b.totalMinor)}</td>
</tr>
</tbody>
</table>
</div>
);
}
+252
View File
@@ -0,0 +1,252 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
applyValidation,
fetchMyValidationPrograms,
fetchValidationSession,
voidValidation,
type SessionUser,
type ValidationProgramView,
type ValidationSessionView,
} from "./api.js";
import { formatDuration, formatMoney, formatRelativeDateTime } from "./lib/format.js";
// The MERCHANT screen (/validate): the bar/lavazh user's ENTIRE surface. Scan or key
// the customer's ticket → see the session (deliberately NO money data — the booth
// settles) → apply the bound program → done. Mobile-friendly: a phone/tablet on the
// site LAN, or a booth-style USB HID scanner (it types digits + Enter into the
// focused input). A mistake can be voided while UNUSED (append-only, signed).
// Gated by validation:create + the server-side program↔user binding.
// See wiki/concepts/validation-discounts.md.
type Program = Omit<ValidationProgramView, "userIds">;
/** Human line for what a program grants (the params live on the program row). */
function programSummary(p: Program, t: (k: string, o?: Record<string, unknown>) => string): string {
if (p.mode === "comp") return t("val.modeComp");
if (p.mode === "timeCredit") return `${t("val.modeTimeCredit")}: ${p.minutes ?? 0} min`;
if (p.mode === "percent") return `${t("val.modePercent")}: ${p.percent ?? 0}%`;
return t("val.modeFixed");
}
export function ValidateScreen({ user }: { user: SessionUser }) {
const { t } = useTranslation();
const [programs, setPrograms] = useState<Program[] | null>(null);
const [programId, setProgramId] = useState<string | null>(null);
const [ticket, setTicket] = useState("");
const [view, setView] = useState<ValidationSessionView | null>(null);
const [amount, setAmount] = useState("");
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
const [busy, setBusy] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
fetchMyValidationPrograms()
.then((r) => {
setPrograms(r.programs);
if (r.programs.length === 1) setProgramId(r.programs[0]!.id);
})
.catch(() => setPrograms([]));
inputRef.current?.focus();
}, []);
const program = programs?.find((p) => p.id === programId) ?? null;
async function lookup(id?: string) {
const identity = (id ?? ticket).trim();
if (!identity) return;
setMsg(null);
try {
setView(await fetchValidationSession(identity));
} catch (e) {
setMsg({ kind: "err", text: (e as Error).message });
}
}
async function apply() {
if (!view || !program) return;
setBusy(true);
setMsg(null);
try {
const body: { identity: string; programId: string; amountMinor?: number } = {
identity: view.identity,
programId: program.id,
};
if (program.mode === "fixed") {
const n = Number(amount);
body.amountMinor = Number.isFinite(n) ? Math.round(n * 100) : 0;
}
await applyValidation(body);
setMsg({ kind: "ok", text: t("val.applied") });
setAmount("");
await lookup(view.identity);
} catch (e) {
setMsg({ kind: "err", text: (e as Error).message });
} finally {
setBusy(false);
}
}
async function voidOne(eventId: string) {
if (!view) return;
if (!window.confirm(t("val.confirmVoid"))) return;
setMsg(null);
try {
await voidValidation({ eventId, identity: view.identity });
await lookup(view.identity);
} catch (e) {
setMsg({ kind: "err", text: (e as Error).message });
}
}
// The session's blocking condition, if any (not found / closed / subscriber).
const blocked =
view == null
? null
: !view.found
? t("val.notFound")
: view.subscription
? t("val.subscription")
: !view.open
? t("val.closed")
: null;
const alreadyApplied =
view != null &&
program != null &&
view.validations.some((v) => v.programId === program.id && !v.voided && v.consumedBy == null);
const fixedAmountOk =
program?.mode !== "fixed" ||
(Number(amount) > 0 &&
(program.maxAmountMinor == null || Math.round(Number(amount) * 100) <= program.maxAmountMinor));
return (
<div className="mx-auto mt-6 w-full max-w-md">
<section className="card p-4">
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("val.title")}</div>
{programs != null && programs.length === 0 && (
<p className="mt-3 text-[0.8125rem] text-term-red">{t("val.noPrograms")}</p>
)}
{programs != null && programs.length > 1 && (
<div className="mt-3 flex gap-1">
{programs.map((p) => (
<button
key={p.id}
type="button"
className={`btn btn-sm ${p.id === programId ? "btn-primary" : "btn-ghost"}`}
onClick={() => setProgramId(p.id)}
>
{p.name}
</button>
))}
</div>
)}
{program && <p className="mt-1 text-[0.75rem] text-term-muted">{program.name} — {programSummary(program, t)}</p>}
<form
className="mt-3 flex gap-2"
onSubmit={(e) => {
e.preventDefault();
void lookup();
}}
>
<input
ref={inputRef}
className="input flex-1 tabular-nums"
inputMode="numeric"
value={ticket}
onChange={(e) => setTicket(e.target.value)}
placeholder={t("val.scanPrompt")}
/>
<button type="submit" className="btn btn-primary btn-sm">{t("val.lookup")}</button>
</form>
{msg && (
<p className={`mt-2 text-[0.8125rem] ${msg.kind === "ok" ? "text-term-green" : "text-term-red"}`}>
{msg.text}
</p>
)}
{view && (
<div className="mt-3 border-t border-term-border pt-3">
{blocked ? (
<p className="text-[0.8125rem] text-term-red">{blocked}</p>
) : (
<>
<div className="flex items-baseline justify-between text-[0.8125rem]">
<span className="font-semibold tabular-nums text-term-text">{view.identity}</span>
<span className="text-term-muted">
{t("val.entry")} {formatRelativeDateTime(view.enteredAt, t)}
{view.enteredAt && <> · {formatDuration(view.enteredAt, new Date().toISOString())}</>}
</span>
</div>
{program && !alreadyApplied && (
<div className="mt-3 grid gap-2">
{program.mode === "fixed" && (
<div className="field">
<span className="label">
{t("val.amountLabel")}
{program.maxAmountMinor != null && (
<span className="hint ml-2">
{t("val.amountHint", { max: formatMoney(program.maxAmountMinor, "") })}
</span>
)}
</span>
<input
className="input w-40 tabular-nums"
inputMode="decimal"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="300"
/>
</div>
)}
<button
type="button"
className="btn btn-primary"
disabled={busy || !fixedAmountOk}
onClick={apply}
>
{t("val.apply")}
</button>
</div>
)}
{view.validations.length > 0 && (
<div className="mt-3">
<div className="label">{t("val.existing")}</div>
<ul className="mt-1 grid gap-1">
{view.validations.map((v) => (
<li key={v.eventId} className="flex items-center gap-2 text-[0.75rem] text-term-text">
<span>{v.label}</span>
{v.amountMinor != null && <span className="tabular-nums">−{formatMoney(v.amountMinor, "")}</span>}
{v.minutes != null && <span>{v.minutes} min</span>}
{v.percent != null && <span>{v.percent}%</span>}
{v.voided ? (
<span className="text-term-muted">({t("val.voided")})</span>
) : v.consumedBy != null ? (
<span className="text-term-muted">({t("val.used")})</span>
) : (
v.operator === user.username && (
<button type="button" className="btn btn-ghost btn-sm ml-auto" onClick={() => voidOne(v.eventId)}>
{t("val.void")}
</button>
)
)}
</li>
))}
</ul>
</div>
)}
</>
)}
</div>
)}
</section>
</div>
);
}
+231
View File
@@ -0,0 +1,231 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
fetchUsers,
saveValidationProgram,
type ManagedUser,
type ValidationMode,
type ValidationProgramView,
} from "./api.js";
// The /setup/site RIGHT panel: per-station merchant-validation config (Bar / Lavazh).
// The checkboxes on the left card toggle a station's `active`; this panel edits the
// enabled stations' programs — one panel, tabs when both are on. Storage is generic
// (validation_programs rows keyed "bar"/"lavazh"); the UI is deliberately these two
// fixed stations. Amounts are entered in MAJOR units and stored in integer minor
// units (the tariff-composer convention). See wiki/concepts/validation-discounts.md.
/** The two well-known stations the checkboxes toggle. */
export const STATIONS = ["bar", "lavazh"] as const;
export type StationId = (typeof STATIONS)[number];
/** A blank program draft for a station enabled for the first time. */
export function defaultProgram(id: StationId, label: string): Omit<ValidationProgramView, "id"> {
return {
name: label,
mode: "comp",
minutes: null,
percent: null,
maxAmountMinor: null,
maxPerDay: null,
active: true,
userIds: [],
};
}
const toMinor = (s: string): number | null => {
const v = s.trim();
if (v === "") return null;
const n = Number(v);
return Number.isFinite(n) && n > 0 ? Math.round(n * 100) : null;
};
const fromMinor = (m: number | null): string => (m == null ? "" : String(m / 100));
const toInt = (s: string): number | null => {
const v = s.trim();
if (v === "") return null;
const n = Number(v);
return Number.isInteger(n) && n > 0 ? n : null;
};
function StationForm({
program,
onSaved,
}: {
program: ValidationProgramView;
onSaved: (p: ValidationProgramView) => void;
}) {
const { t } = useTranslation();
const [name, setName] = useState(program.name);
const [mode, setMode] = useState<ValidationMode>(program.mode);
const [minutes, setMinutes] = useState(program.minutes == null ? "" : String(program.minutes));
const [percent, setPercent] = useState(program.percent == null ? "" : String(program.percent));
const [maxAmount, setMaxAmount] = useState(fromMinor(program.maxAmountMinor));
const [maxPerDay, setMaxPerDay] = useState(program.maxPerDay == null ? "" : String(program.maxPerDay));
const [userIds, setUserIds] = useState<Set<string>>(new Set(program.userIds));
const [users, setUsers] = useState<ManagedUser[] | null>(null);
const [msg, setMsg] = useState<string | null>(null);
// Reset the form when the tab switches to another station.
useEffect(() => {
setName(program.name);
setMode(program.mode);
setMinutes(program.minutes == null ? "" : String(program.minutes));
setPercent(program.percent == null ? "" : String(program.percent));
setMaxAmount(fromMinor(program.maxAmountMinor));
setMaxPerDay(program.maxPerDay == null ? "" : String(program.maxPerDay));
setUserIds(new Set(program.userIds));
setMsg(null);
}, [program.id]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
fetchUsers()
.then((r) => setUsers(r.users))
.catch(() => setUsers([]));
}, []);
const valid = useMemo(() => {
if (!name.trim()) return false;
if (mode === "timeCredit") return toInt(minutes) != null;
if (mode === "percent") {
const p = toInt(percent);
return p != null && p <= 100;
}
if (mode === "fixed") return toMinor(maxAmount) != null;
return true;
}, [name, mode, minutes, percent, maxAmount]);
async function save() {
setMsg(null);
try {
const saved = await saveValidationProgram(program.id, {
name: name.trim(),
mode,
minutes: mode === "timeCredit" ? toInt(minutes) : null,
percent: mode === "percent" ? toInt(percent) : null,
maxAmountMinor: mode === "fixed" ? toMinor(maxAmount) : null,
maxPerDay: toInt(maxPerDay),
active: program.active,
userIds: [...userIds],
});
onSaved(saved);
setMsg(t("val.saved"));
} catch (e) {
setMsg((e as Error).message);
}
}
const toggleUser = (id: string) =>
setUserIds((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
return (
<div className="mt-3 grid gap-3">
<div className="field">
<span className="label">{t("val.labelName")}</span>
<input className="input" value={name} onChange={(e) => setName(e.target.value)} placeholder={t("val.labelNamePh")} />
</div>
<div className="field">
<span className="label">{t("val.mode")}</span>
<select className="input w-fit" value={mode} onChange={(e) => setMode(e.target.value as ValidationMode)}>
<option value="comp">{t("val.modeComp")}</option>
<option value="timeCredit">{t("val.modeTimeCredit")}</option>
<option value="fixed">{t("val.modeFixed")}</option>
<option value="percent">{t("val.modePercent")}</option>
</select>
</div>
{mode === "timeCredit" && (
<div className="field">
<span className="label">{t("val.minutes")}</span>
<input className="input w-32" value={minutes} onChange={(e) => setMinutes(e.target.value)} placeholder="60" />
</div>
)}
{mode === "percent" && (
<div className="field">
<span className="label">{t("val.percent")}</span>
<input className="input w-32" value={percent} onChange={(e) => setPercent(e.target.value)} placeholder="100" />
</div>
)}
{mode === "fixed" && (
<div className="field">
<span className="label">{t("val.maxAmount")}</span>
<input className="input w-32" value={maxAmount} onChange={(e) => setMaxAmount(e.target.value)} placeholder="1000" />
</div>
)}
<div className="field">
<span className="label">{t("val.maxPerDay")}</span>
<input className="input w-32" value={maxPerDay} onChange={(e) => setMaxPerDay(e.target.value)} />
</div>
<div>
<div className="label">{t("val.users")}</div>
<span className="hint block">{t("val.usersHint")}</span>
<div className="mt-1 grid gap-1">
{users == null ? (
<span className="text-term-muted">…</span>
) : users.length === 0 ? (
<span className="text-[0.75rem] text-term-muted">{t("val.noUsers")}</span>
) : (
users.map((u) => (
<label key={u.id} className="flex items-center gap-2 text-[0.75rem] text-term-text">
<input
type="checkbox"
className="accent-term-amber"
checked={userIds.has(u.id)}
onChange={() => toggleUser(u.id)}
/>
{u.username}
{u.fullName && <span className="text-term-muted">({u.fullName})</span>}
</label>
))
)}
</div>
</div>
<div className="flex items-center gap-3">
<button type="button" className="btn btn-primary btn-sm" disabled={!valid} onClick={save}>
{t("site.save")}
</button>
{msg && <span className="text-[0.75rem] text-term-muted">{msg}</span>}
</div>
</div>
);
}
/** The right-column panel: tabs across the ENABLED stations, one form each. */
export function ValidationStationsPanel({
programs,
onSaved,
}: {
programs: ValidationProgramView[];
onSaved: (p: ValidationProgramView) => void;
}) {
const { t } = useTranslation();
const enabled = STATIONS.map((id) => programs.find((p) => p.id === id)).filter(
(p): p is ValidationProgramView => p != null && p.active,
);
const [tab, setTab] = useState<string | null>(null);
const current = enabled.find((p) => p.id === tab) ?? enabled[0];
if (!current) return null;
return (
<section className="card w-full max-w-md p-4">
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("val.sectionTitle")}</div>
{enabled.length > 1 && (
<div className="mt-2 flex gap-1">
{enabled.map((p) => (
<button
key={p.id}
type="button"
className={`btn btn-sm ${p.id === current.id ? "btn-primary" : "btn-ghost"}`}
onClick={() => setTab(p.id)}
>
{t(p.id === "bar" ? "val.enableBar" : "val.enableLavazh")}
</button>
))}
</div>
)}
<StationForm program={current} onSaved={onSaved} />
</section>
);
}
+169 -13
View File
@@ -6,8 +6,8 @@
// wiki/entities/local-jwt-auth.md.
import { logFailedRequest } from "./lib/logger.js";
import { apiUrl } from "./lib/origin.js";
import type { AppLogRecord } from "@parking/shared";
import { apiUrl, platformFetch } from "./lib/origin.js";
import type { AppLogRecord, ValidationLine, ValidationMode } from "@parking/shared";
const CSRF_COOKIE = "parking_csrf";
const CSRF_HEADER = "X-CSRF-Token";
@@ -28,7 +28,7 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
const csrf = readCookie(CSRF_COOKIE);
if (csrf) headers.set(CSRF_HEADER, csrf);
}
const res = await fetch(apiUrl(path), { ...init, headers, credentials: "include" });
const res = await platformFetch(apiUrl(path), { ...init, headers, credentials: "include" });
if (!res.ok) {
const msg = (await res.json().catch(() => ({}))) as { error?: string; problems?: string[]; [k: string]: unknown };
const error = msg.error ?? `${path}: ${res.status}`;
@@ -253,6 +253,15 @@ export async function fetchBackupStatus(): Promise<BackupStatus> {
return apiFetch("/api/backup/status");
}
export interface VersionInfo {
/** "<branch>-<short-sha>" baked in at image build time; null on a local/dev build. */
buildVersion: string | null;
}
export async function fetchVersion(): Promise<VersionInfo> {
return apiFetch("/api/version");
}
export interface BackupConfigPatch {
/** "" clears the target. Omit a field to leave it unchanged; null resets retention to default. */
targetDir?: string | null;
@@ -483,7 +492,11 @@ export interface ReportSeriesPoint {
entries: number;
exits: number;
revenueMinor: number;
cashMinor: number;
cardMinor: number;
payments: number;
/** Cars inside at the END of the bucket. */
occupancyEnd: number;
}
export interface ReportTotals {
@@ -500,6 +513,8 @@ export interface ReportTotals {
totalParkedMinutes: number;
avgParkedMinutes: number;
medianParkedMinutes: number;
voids: number;
anomalies: number;
}
export interface ReportSubscriptionStats {
@@ -519,6 +534,12 @@ export interface ReportSummary {
totals: ReportTotals;
series: ReportSeriesPoint[];
entriesByHour: number[];
/** 7×24, row 0 = Monday — entries heatmap (weekday-vs-weekend patterns). */
entriesByDowHour: number[][];
/** Stay-duration histogram; last bucket has uptoMin null (>24h tail). */
stayHistogram: { uptoMin: number | null; count: number }[];
occupancyStart: number;
capacity: number | null;
subscriptions: ReportSubscriptionStats;
}
@@ -592,6 +613,11 @@ export interface AssignBody {
backendIp?: string;
}
/** USB printers currently visible on the appliance (/dev/usb/lpN + sysfs model). */
export function fetchUsbPrinters(): Promise<{ printers: { path: string; description: string | null }[] }> {
return apiFetch("/api/setup/usb-printers");
}
/** Save + configure the device (preconditions, push setup), then persist. */
export function assignDevice(body: AssignBody): Promise<AssignResult> {
return apiFetch("/api/setup/assign", { method: "POST", body: JSON.stringify(body) });
@@ -669,10 +695,14 @@ export interface TariffCard {
priority: number;
category?: string;
window?: TariffWindow;
/** Flat price PER INCREMENT (an hourly flat rate) — not a whole-stay price. */
flatMinor?: number;
blocks?: TariffBlock[];
/** STEPPED ("up-to") table (defaultCard only); mutually exclusive with flat/blocks. */
steps?: TariffStep[];
/** WINDOW PACKAGE (windowed cards only): ONE total per contiguous window occurrence
* ("any presence in the window = this price"). Mirrors @parking/shared. */
packageMinor?: number;
dailyCapMinor?: number | null;
}
/** One row of a STEPPED ("up-to") tariff: a TOTAL price for a stay up to and including
@@ -702,6 +732,8 @@ export function isTariffV2(t: TariffStructure): t is TariffStructureV2 {
export interface TariffVersion {
id: string;
tariffId: string;
/** Optional human label, stamped at publish (e.g. carried from a lab draft). */
name?: string | null;
effectiveFrom: string;
currency: string;
structure: TariffStructure;
@@ -723,6 +755,7 @@ export function publishTariffVersion(body: {
currency: string;
structure: TariffStructure;
effectiveFrom?: string;
name?: string;
}): Promise<TariffVersion> {
return apiFetch("/api/tariff/versions", { method: "POST", body: JSON.stringify(body) });
}
@@ -743,6 +776,9 @@ export interface SimSessionPricing {
export interface SimulateResult {
currency: string | null;
pricing: SimSessionPricing;
/** Line items explaining pricing.amountMinor (same engine walk, Σ ≡ amount);
* null when the session is settled (within walk-back grace). */
breakdown: import("@parking/shared").FeeBreakdown | null;
curve: { minutes: number; amountMinor: number }[];
gracePeriodExitMin: number;
}
@@ -761,18 +797,40 @@ export function simulateTariff(body: SimulateBody): Promise<SimulateResult> {
return apiFetch("/api/tariff/simulate", { method: "POST", body: JSON.stringify(body) });
}
export interface SimSessionLoad {
identity: string;
enteredAt: string;
exitedAt: string | null;
payments: SimPayment[];
category: string | null;
tariffVersionId: string | null;
// --- Tariff Lab drafts ------------------------------------------------------
// Mutable experimental rate cards — the lab composes + simulates these, and
// publishing one goes through the normal immutable-version path above.
export interface TariffDraft {
id: string;
name: string;
currency: string;
structure: TariffStructure;
createdBy: string | null;
createdAt: string;
updatedAt: string;
}
/** Prefill the lab from a real ledger session. */
export function loadSimSession(identity: string): Promise<SimSessionLoad> {
return apiFetch(`/api/tariff/simulate/session/${encodeURIComponent(identity)}`);
export function fetchTariffDrafts(): Promise<{ drafts: TariffDraft[] }> {
return apiFetch("/api/tariff/drafts");
}
export interface TariffDraftBody {
name: string;
currency: string;
structure: TariffStructure;
}
export function createTariffDraft(body: TariffDraftBody): Promise<TariffDraft> {
return apiFetch("/api/tariff/drafts", { method: "POST", body: JSON.stringify(body) });
}
export function updateTariffDraft(id: string, body: TariffDraftBody): Promise<TariffDraft> {
return apiFetch(`/api/tariff/drafts/${encodeURIComponent(id)}`, { method: "PUT", body: JSON.stringify(body) });
}
export function deleteTariffDraft(id: string): Promise<void> {
return apiFetch(`/api/tariff/drafts/${encodeURIComponent(id)}`, { method: "DELETE" });
}
// --- Subscriptions --------------------------------------------------------
@@ -1089,6 +1147,12 @@ export function fetchDrawerMovements(status?: MovementStatus): Promise<{
return apiFetch(`/api/drawer/movements${qs}`);
}
/** The physical drawer balance NOW (cash payments + vouchers over the whole chain —
* the amount that carries across shifts). */
export function fetchDrawerBalance(): Promise<{ balanceMinor: number; currency: string | null }> {
return apiFetch("/api/drawer/balance");
}
/** Admin AUTHORIZES or DENIES a recorded movement (a flag — never a cash reversal). */
export function reviewDrawerMovement(args: {
refId: string;
@@ -1122,6 +1186,8 @@ export interface ShiftSummary extends ShiftSourceSplit {
export function fetchShifts(params: { operator?: string; from?: string; to?: string } = {}): Promise<{
shifts: ShiftSummary[];
scope: "all" | "self";
/** Admin scope only: every operator that has a shift — feeds the filter dropdown. */
operators?: string[];
}> {
const qs = new URLSearchParams();
if (params.operator) qs.set("operator", params.operator);
@@ -1244,6 +1310,11 @@ export interface SessionLookup {
subscriptionHolder: string | null;
/** Advisory licence plate recognized for this session (ANPR). Null when none. */
plate: string | null;
/** Merchant validations folded into `amountMinor` (which is NET): pre-discount fee,
* total taken off, and the per-validation lines. See validation-discounts.md. */
grossMinor: number | null;
discountMinor: number | null;
validationLines: ValidationLine[];
}
/** Look up a ticket/session for the booth modal (entry/exit, paid, amount owed). */
@@ -1418,3 +1489,88 @@ export function updatePresenceBypass(patch: { radar?: boolean; camera?: boolean
export function setCapacity(capacity: number | null): Promise<SiteConfig> {
return saveSiteConfig({ capacity });
}
// --- Merchant validations (bar / lavazh) -----------------------------------
// The merchant is VALIDATION-ONLY: they scan the ticket on their device and apply
// their program; the booth settles NET of the applied validations and prints the
// detailed receipt. Program config lives on /setup/site. See validation-discounts.md.
export type { ValidationLine, ValidationMode } from "@parking/shared";
/** An admin-composed program (mirrors the server row + its bound users). */
export interface ValidationProgramView {
id: string;
name: string;
mode: ValidationMode;
minutes: number | null;
percent: number | null;
maxAmountMinor: number | null;
maxPerDay: number | null;
active: boolean;
userIds: string[];
}
/** A validation applied to a session, with its lifecycle state. */
export interface AppliedValidationView {
eventId: string;
occurredAt: string;
programId: string;
label: string;
mode: ValidationMode;
minutes?: number;
amountMinor?: number;
percent?: number;
operator: string | null;
voided: boolean;
consumedBy: string | null;
}
/** The merchant screen's minimal session view — deliberately no money data. */
export interface ValidationSessionView {
identity: string;
found: boolean;
open: boolean;
enteredAt: string | null;
subscription: boolean;
validations: AppliedValidationView[];
}
/** All programs + bound users (the /setup/site panel). site:read. */
export function fetchValidationPrograms(): Promise<{ programs: ValidationProgramView[] }> {
return apiFetch("/api/validation/programs");
}
/** Upsert a program's config + binding set (site:update; signs a config_change). */
export function saveValidationProgram(
id: string,
body: Omit<ValidationProgramView, "id">,
): Promise<ValidationProgramView> {
return apiFetch(`/api/validation/programs/${encodeURIComponent(id)}`, {
method: "PUT",
body: JSON.stringify(body),
});
}
/** MY bound, active programs (the merchant screen). validation:create. */
export function fetchMyValidationPrograms(): Promise<{ programs: Omit<ValidationProgramView, "userIds">[] }> {
return apiFetch("/api/validation/mine");
}
/** Merchant lookup of a scanned ticket (no money data). validation:create. */
export function fetchValidationSession(identity: string): Promise<ValidationSessionView> {
return apiFetch(`/api/validation/session/${encodeURIComponent(identity)}`);
}
/** Apply my program to a ticket (signed, attributed). `amountMinor` only for fixed mode. */
export function applyValidation(body: {
identity: string;
programId: string;
amountMinor?: number;
}): Promise<{ ok: true; eventId: string; label: string }> {
return apiFetch("/api/validation/apply", { method: "POST", body: JSON.stringify(body) });
}
/** Void my own UNUSED validation (append-only correction). */
export function voidValidation(body: { eventId: string; identity: string }): Promise<{ ok: true }> {
return apiFetch("/api/validation/void", { method: "POST", body: JSON.stringify(body) });
}
+45 -10
View File
@@ -13,9 +13,42 @@
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. */
Offline appliance: NO webfont @import (no network at runtime). The primary
face is Chakra Petch, SELF-HOSTED from public/fonts/chakra-petch (SIL OFL,
license alongside the files) — latin subset only (covers en + sq ë/ç), the
weights the UI actually uses (400/600/700 + 400 italic). Not a true
monospace: it stays FIRST in --font-mono for the look, with the real mono
stack behind it as fallback; .num/.tabular still request tabular figures. */
@font-face {
font-family: "Chakra Petch";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/fonts/chakra-petch/chakra-petch-latin-400.woff2") format("woff2");
}
@font-face {
font-family: "Chakra Petch";
font-style: normal;
font-weight: 600;
font-display: swap;
src: url("/fonts/chakra-petch/chakra-petch-latin-600.woff2") format("woff2");
}
@font-face {
font-family: "Chakra Petch";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("/fonts/chakra-petch/chakra-petch-latin-700.woff2") format("woff2");
}
@font-face {
font-family: "Chakra Petch";
font-style: italic;
font-weight: 400;
font-display: swap;
src: url("/fonts/chakra-petch/chakra-petch-latin-400-italic.woff2") format("woff2");
}
@theme {
/* ============================================================
TERMINAL ACCENTS — aligned onto TRM's exact values.
@@ -92,13 +125,15 @@
--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;
/* Chakra Petch (self-hosted, see @font-face above) is the booth's primary
face everywhere — it leads every stack so headings, body, and the
`font-mono` chrome all render with it; the stacks behind it are the
pre-2026-07-05 fallbacks for glyphs outside the latin subset. */
--font-mono: "Chakra Petch", "JetBrains Mono", "IBM Plex Mono", ui-monospace,
"SFMono-Regular", "Menlo", "Consolas", monospace;
--font-display: "Chakra Petch", "Helvetica Neue", Arial, sans-serif;
--font-ui: "Chakra Petch", "Helvetica Neue", Arial, sans-serif;
--font-body: "Chakra Petch", "Inter", "Helvetica Neue", Arial, sans-serif;
/* ---------- TYPE — scale (TRM, optimised for data density) ---------- */
--text-overline: 11px;
+11
View File
@@ -0,0 +1,11 @@
// The currencies the booth can price in (ISO 4217). Money is always stored as
// integer minor units + one of these codes; the UI offers a closed select rather
// than free text so a typo can never publish an unknown currency.
export const CURRENCIES = ["ALL", "EUR", "USD"] as const;
/** The select options: the known set, plus the current value when it's some
* historical code outside it (so an old record still displays + round-trips). */
export function currencyOptions(current: string): string[] {
const cur = current.trim().toUpperCase();
return cur && !CURRENCIES.includes(cur as (typeof CURRENCIES)[number]) ? [...CURRENCIES, cur] : [...CURRENCIES];
}
+14 -3
View File
@@ -41,11 +41,22 @@ export async function checkForDesktopUpdate(
// Download + install the signed update (signature verified against the
// pubkey in tauri.conf.json), then relaunch into the new version.
await update.downloadAndInstall();
try {
await update.downloadAndInstall();
} catch (err) {
// A real update WAS found and accepted — this is a genuine install
// failure (bad signature, corrupted download, disk/permission issue),
// not "offline". Surface it instead of silently reverting to the old
// version with no explanation.
console.error("desktop update download/install failed:", err);
throw err;
}
const { relaunch } = await import("@tauri-apps/plugin-process");
await relaunch();
} catch {
} catch (err) {
// Offline / endpoint unreachable / no update server yet → ignore. The app
// keeps running on the current version; checking again next launch.
// keeps running on the current version; checking again next launch. Still
// log it so a real install failure (rethrown above) isn't invisible.
console.warn("desktop update check/apply skipped:", err);
}
}
+7 -11
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { formatMoney, formatDuration, formatTime, formatRelativeDateTime, type TFn } from "./format.js";
import { formatMoney, formatDuration, formatRelativeDateTime, type TFn } from "./format.js";
// The booth's display formatters. Money is integer MINOR units (never a float, matching
// the ledger/tariff model); duration is whole minutes; relative dates drive the session/
@@ -35,16 +35,6 @@ describe("formatDuration", () => {
});
});
describe("formatTime", () => {
it("returns an em dash for null/invalid", () => {
expect(formatTime(null)).toBe("—");
expect(formatTime("not-a-date")).toBe("—");
});
it("renders HH:MM:SS local time", () => {
expect(formatTime("2026-06-21T10:48:25.000Z")).toMatch(/^\d{2}:\d{2}:\d{2}$/);
});
});
describe("formatRelativeDateTime", () => {
// A tiny fake t(): today/yesterday words + the month-name array.
const months = ["Jan","Shkurt","Mars","Prill","Maj","Qershor","Korrik","Gusht","Sht","Tet","Nën","Dhj"];
@@ -61,6 +51,12 @@ describe("formatRelativeDateTime", () => {
expect(formatRelativeDateTime(now.toISOString(), t)).toMatch(/^Sot \d{2}:\d{2}$/);
});
it("appends :ss with the seconds option (entry/exit rows read alike)", () => {
const now = new Date();
now.setHours(19, 25, 44, 0);
expect(formatRelativeDateTime(now.toISOString(), t, { seconds: true })).toMatch(/^Sot \d{2}:\d{2}:44$/);
});
it("labels yesterday with the localized word", () => {
const y = new Date();
y.setDate(y.getDate() - 1);
+49 -18
View File
@@ -46,13 +46,6 @@ export function formatMinutes(mins: number): string {
return h > 0 ? `${h}h ${m % 60}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);
}
/** Calendar-day difference (local) between two dates: 0 = same day, 1 = d is one day
* before ref, etc. Compares date parts only (ignores time-of-day). */
function dayDiff(d: Date, ref: Date): number {
@@ -61,10 +54,11 @@ function dayDiff(d: Date, ref: Date): number {
return Math.round((b.getTime() - a.getTime()) / 86_400_000);
}
/** HH:MM (local, 24h) for the relative-day labels. */
function hhmm(d: Date): string {
/** HH:MM (local, 24h) for the relative-day labels; ":ss" appended when `seconds`. */
function hhmm(d: Date, seconds = false): string {
const p = (n: number) => String(n).padStart(2, "0");
return `${p(d.getHours())}:${p(d.getMinutes())}`;
const base = `${p(d.getHours())}:${p(d.getMinutes())}`;
return seconds ? `${base}:${p(d.getSeconds())}` : base;
}
/** Minimal shape of i18next's `t` that we rely on: a string lookup, plus the
@@ -85,6 +79,42 @@ function monthName(d: Date, t: TFn): string {
return String(d.getMonth() + 1);
}
/** Short month ("Qer", "Korr") from the catalog — the UI-wide date standard
* (2026-07-06): every visible date reads "25 Qer" / "7 Korr 2025", never the
* browser-locale "7/6/2026". Falls back to the full name, then the number. */
function monthShort(d: Date, t: TFn): string {
const months = t("common.monthsShort", { returnObjects: true });
if (Array.isArray(months) && typeof months[d.getMonth()] === "string") {
return months[d.getMonth()] as string;
}
return monthName(d, t);
}
/** "HH:mm" (local, 24h) — the unified time-of-day everywhere ("—" for bad input). */
export function formatClock(iso: string | null): string {
if (!iso) return "—";
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? "—" : hhmm(d);
}
/** "25 Qer" (current year) / "25 Qer 2025" (other years) — the unified DATE. */
export function formatDate(iso: string | null, t: TFn): string {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
const base = `${d.getDate()} ${monthShort(d, t)}`;
return d.getFullYear() === new Date().getFullYear() ? base : `${base} ${d.getFullYear()}`;
}
/** "25 Qer 14:30" (+ ":ss" when `seconds`) — the unified absolute DATE+TIME. Use
* formatRelativeDateTime instead where "Sot/Dje" reads better (feeds, history). */
export function formatDateTime(iso: string | null, t: TFn, opts?: { seconds?: boolean }): string {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
return `${formatDate(iso, t)} ${hhmm(d, opts?.seconds)}`;
}
/**
* Human, day-relative date+time for sessions/logs/history. An event from earlier
* today reads "Sot 10:48", yesterday "Dje 17:33", and anything older a localized
@@ -93,17 +123,18 @@ function monthName(d: Date, t: TFn): string {
*
* `t` supplies the today/yesterday words AND the month names (the appliance browser
* may lack Albanian Intl data, so month names come from the catalog, not Intl).
*
* `seconds` appends ":ss" — use it where a timestamp sits next to another that shows
* seconds (e.g. the booth pay modal's entry vs. exit rows), so the two read alike.
*/
export function formatRelativeDateTime(iso: string | null, t: TFn): string {
export function formatRelativeDateTime(iso: string | null, t: TFn, opts?: { seconds?: boolean }): string {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
const time = hhmm(d, opts?.seconds);
const diff = dayDiff(d, new Date());
if (diff === 0) return `${t("common.today")} ${hhmm(d)}`;
if (diff === 1) return `${t("common.yesterday")} ${hhmm(d)}`;
// Older (or future): "17 Qershor 10:48", with the year only if it differs.
const sameYear = d.getFullYear() === new Date().getFullYear();
const month = monthName(d, t);
const date = sameYear ? `${d.getDate()} ${month}` : `${d.getDate()} ${month} ${d.getFullYear()}`;
return `${date} ${hhmm(d)}`;
if (diff === 0) return `${t("common.today")} ${time}`;
if (diff === 1) return `${t("common.yesterday")} ${time}`;
// Older (or future): "17 Qer 10:48" — the short-month standard, year only if it differs.
return `${formatDate(iso, t)} ${time}`;
}
+116 -17
View File
@@ -33,6 +33,7 @@ export const en: Catalog = {
"November",
"December",
],
monthsShort: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
},
auth: {
title: "Parking System",
@@ -64,8 +65,19 @@ export const en: Catalog = {
logs: "Logs",
backup: "Backup",
profile: "Profile",
validate: "Validations",
},
drawer: {
stateTitle: "Drawer now",
openShift: "Open shift: {{operator}}",
noShiftOpen: "No shift open — the drawer carries the last shift's closing balance.",
thisShift: "This shift:",
todayTitle: "Today's cash activity",
todayPayments: "{{count}} payments",
payment: "Payment",
noActivity: "No cash activity today.",
historyTitle: "Closed shifts",
noShifts: "No closed shifts yet.",
recordTitle: "Record a cash movement",
amount: "amount",
reasonPlaceholder: "reason (e.g. supplier payment, bank drop)",
@@ -219,6 +231,7 @@ export const en: Catalog = {
evtCashOut: "PAY-OUT",
evtCashReview: "REVIEW",
evtConfigChange: "CONFIG",
evtValidation: "VALIDATION",
decision: { authorize: "authorized", deny: "denied" },
evtAnomaly: "ANOMALY",
evtRefused: "REFUSED",
@@ -319,25 +332,39 @@ export const en: Catalog = {
bandDuration: "Band duration",
hoursUnit: "hours",
egHours: "e.g. 2",
pricePerIncrement: "Price / increment",
pricePerIncrement: "Price / increment (per hour)",
pricePerHour: "Price / hour",
pricePerN: "Price / {{min}} min",
modeFlatN: "Flat price / {{min}} min",
perHourEquiv: "= {{amount}} / hour",
incrementWarning:
"Careful: the billing increment is {{min}} min — every price below is charged per started {{min}} minutes, NOT per hour.",
thereafter: "thereafter (open-ended)",
remove: "Remove",
addBlock: "+ Add block",
publishNewVersion: "Publish new version",
publishing: "Publishing…",
versionNamePh: "Version name (optional), e.g. Summer 2026",
versionsTitle: "Published versions",
versionsHint: "Click one to load it into the editor. Publishing always creates a new version — past versions never change.",
activeBadge: "active",
publishedOk: "New tariff version published — it's now the active rate.",
defaultCard: "Base rate (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",
modeFlat: "Flat price / hour",
modeStepped: "By duration (up-to)",
modePackage: "Window package (one total)",
packageHint:
"ONE total for any presence inside this tier's window — leaving earlier costs the same. Touching the window on two different nights charges the package twice (once per night). Hours outside the window are priced by the base rate.",
packageTotal: "Package total",
steppedHint:
"Set the TOTAL price for a stay up to a given time (e.g. up to 3h = 500). The first row whose limit ≥ the duration wins (the limit is inclusive). The last row's total repeats as a per-day price for longer stays.",
stepUpTo: "Up to",
stepTotal: "Total price",
addStep: "+ Add row",
steppedTiersConflict:
"⚠ Time/seasonal tiers do NOT apply when the base rate is 'By duration (up-to)' — the engine ignores them entirely. Remove the tiers, or switch the base rate to 'Hourly ladder' or 'Flat price'. Publishing is blocked until this is fixed.",
"⚠ Time/seasonal tiers do NOT apply when the base rate is 'By duration (up-to)' — the engine ignores them entirely. Remove the tiers, or switch the base rate to 'Hourly ladder' or 'Flat price / hour'. Publishing is blocked until this is fixed.",
tiersAdvanced: "Advanced: time & seasonal tiers",
tiersHint: "Optional. Add tiers that apply only at certain hours/days/dates or for a category (e.g. happy hour, night rate, weekend, bus). With no tiers, just the base rate is published.",
tierName: "Name",
@@ -394,6 +421,8 @@ export const en: Catalog = {
scan: "Scan for controllers",
scanning: "Scanning…",
noControllersFound: "No controllers found on the LAN.",
usbNoneFound: "No USB printer found (/dev/usb/lpN) — check cable/power; the path can be typed manually.",
usbSavedMissing: "saved — not present now",
use: "Use",
test: "Test connection",
testing: "Testing…",
@@ -513,21 +542,26 @@ export const en: Catalog = {
lab: {
title: "Tariff Lab",
intro:
"Test rates in time (day/night windows, daily caps, overstay) in seconds, with no waiting. Pricing uses the same logic as the booth; nothing is written to the ledger.",
loadTicket: "Load from a real ticket",
loadTicketPh: "Ticket number / identity",
load: "Load",
loaded: "Loaded session {{id}}",
tariffVersion: "Tariff version",
activeVersion: "Active version (current)",
"Compose experimental rate cards and price hypothetical stays against them — nothing goes live until you publish. Pricing uses the same logic as the booth; nothing is written to the ledger.",
drafts: "Lab tariffs",
newDraft: "New draft",
activeTariff: "Active tariff",
published: "Published versions",
noDrafts: "No lab tariffs yet — create a draft to experiment.",
edit: "Edit",
publish: "Publish",
delete: "Delete",
draftName: "Name",
draftNamePh: "e.g. Winter proposal",
saveDraft: "Save draft",
savingDraft: "Saving…",
newDraftTitle: "New lab tariff",
editDraftTitle: "Edit lab tariff",
confirmPublish: 'Publish "{{name}}" as the new live rate card? It takes effect immediately.',
confirmDelete: 'Delete lab tariff "{{name}}"?',
entered: "Entered",
asOf: "As of (now/exit)",
exit: "Exit",
now: "Now",
category: "Category",
categoryPh: "e.g. bus (blank = car)",
payment: "Payment",
paid: "paid",
graceMin: "grace (min)",
price: "Compute price",
pricing: "Pricing…",
outcome: "Outcome",
@@ -539,6 +573,16 @@ export const en: Catalog = {
graceExpires: "Grace expires",
curve: "Duration curve",
curveHint: "Fee from entry at several durations — see where the daily cap flattens or windows shift.",
bd: {
title: "How the amount is produced",
rounding: "{{raw}} min parked → {{billed}} min billed ({{inc}}-min increments)",
grace: "Free — within the entry grace ({{min}} min)",
package: "window package",
step: "Day {{day}}: stay up to {{hours}}h — total",
stepRepeated: "Day {{day}}: beyond the top tier — full-day total",
cap: "Daily cap {{cap}} applied (day {{day}})",
total: "Total",
},
},
subs: {
title: "Subscriptions",
@@ -693,6 +737,51 @@ export const en: Catalog = {
fieldPhone: "Phone",
fieldEmail: "Email",
},
// Merchant validations (bar / lavazh) — the /setup/site panel, the merchant's
// /validate screen, and the booth-modal discount lines. See validation-discounts.md.
val: {
// /setup/site
sectionTitle: "Merchant validations",
sectionHint: "An in-park merchant (bar / car-wash) scans the customer's ticket and grants a parking discount — payment and the receipt always stay at the booth.",
enableBar: "Bar",
enableLavazh: "Car wash",
labelName: "Receipt label",
labelNamePh: "e.g. Car wash — first hour free",
mode: "Discount type",
modeComp: "Parking fully free",
modeTimeCredit: "First minutes free",
modeFixed: "Amount off (typed at scan)",
modePercent: "Percent off",
minutes: "Free minutes",
percent: "Percent (%)",
maxAmount: "Cap per validation",
maxPerDay: "Max validations per day (blank = unlimited)",
users: "Validating users",
usersHint: "Only the selected users (whose role grants validation:create) can apply this program from their device.",
noUsers: "No users in the system — create one under Users.",
saved: "Saved.",
// /validate (the merchant screen)
title: "Ticket validation",
scanPrompt: "Scan or type the ticket number",
lookup: "Look up",
entry: "Entry:",
notFound: "No ticket found with this number.",
closed: "The ticket is closed (exited or voided).",
subscription: "This is a subscriber entry — not validatable.",
amountLabel: "Discount amount",
amountHint: "max {{max}}",
apply: "Apply validation",
applied: "Validation applied.",
existing: "Validations on this ticket",
voided: "voided",
used: "used in a payment",
void: "Void",
confirmVoid: "Void this validation?",
noPrograms: "You have no validation program bound to you — contact the administrator.",
// booth pay modal / receipts
gross: "Fee",
discount: "Discount",
},
users: {
title: "Users",
add: "+ Add user",
@@ -843,14 +932,23 @@ export const en: Catalog = {
payments: "Payments",
avgStay: "Avg stay",
subscribers: "Subscribers",
peakOcc: "Peak occupancy",
voids: "Voided tickets",
anomalies: "Anomalies",
},
chart: {
flow: "Entries & exits over time",
occupancy: "Occupancy — cars inside",
occupancySeries: "Cars inside",
revenue: "Revenue ({{currency}})",
mix: "Revenue mix",
peakHours: "Entries by hour of day",
stay: "Stay duration (closed sessions)",
heatmap: "Entries heatmap — hour × day",
breakdown: "Breakdown",
},
capacityLine: "capacity",
stay: { m: "m", h: "h" },
dowShort: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
mix: { ticket: "Transient", subSales: "Subscriptions", subWindow: "Out-of-window" },
row: {
cash: "Cash",
@@ -896,6 +994,7 @@ export const en: Catalog = {
status: "Status",
path: "Path",
empty: "No logs.",
repeated: "Repeated {{count}} times (first at {{firstAt}})",
},
backup: {
title: "Backup",
+121 -21
View File
@@ -35,6 +35,8 @@ export const sq = {
"Nëntor",
"Dhjetor",
],
// Short month names — the UI-wide date standard ("25 Qer", "7 Korr").
monthsShort: ["Jan", "Shk", "Mar", "Pri", "Maj", "Qer", "Korr", "Gush", "Sht", "Tet", "Nën", "Dhj"],
},
auth: {
title: "Sistemi i Parkimit",
@@ -66,8 +68,19 @@ export const sq = {
logs: "Loget",
backup: "Kopje rezervë",
profile: "Profili",
validate: "Validime",
},
drawer: {
stateTitle: "Arka tani",
openShift: "Turn i hapur: {{operator}}",
noShiftOpen: "Asnjë turn i hapur — arka mban gjendjen e mbylljes së turnit të fundit.",
thisShift: "Ky turn:",
todayTitle: "Aktiviteti i arkës sot",
todayPayments: "{{count}} pagesa",
payment: "Pagesë",
noActivity: "Pa lëvizje arke sot.",
historyTitle: "Turne të mbyllura",
noShifts: "Ende pa turne të mbyllura.",
recordTitle: "Regjistro një lëvizje arke",
amount: "shuma",
reasonPlaceholder: "arsyeja (p.sh. pagesë furnitori, depozitë banke)",
@@ -223,6 +236,7 @@ export const sq = {
evtCashOut: "PAGESË",
evtCashReview: "SHQYRTIM",
evtConfigChange: "KONFIG",
evtValidation: "VALIDIM",
decision: { authorize: "autorizuar", deny: "refuzuar" },
evtAnomaly: "ANOMALI",
evtRefused: "REFUZUAR",
@@ -322,25 +336,39 @@ export const sq = {
bandDuration: "Kohëzgjatja e brezit",
hoursUnit: "orë",
egHours: "p.sh. 2",
pricePerIncrement: "Çmimi / interval",
pricePerIncrement: "Çmimi / interval (min)",
pricePerHour: "Çmimi / orë",
pricePerN: "Çmimi / {{min}} min",
modeFlatN: "Çmim fiks / {{min}} min",
perHourEquiv: "= {{amount}} / orë",
incrementWarning:
"Kujdes: intervali i faturimit është {{min}} min — çdo çmim më poshtë faturohet për çdo {{min}} minuta të filluara, JO për orë.",
thereafter: "më pas (i hapur)",
remove: "Hiq",
addBlock: "+ Shto bllok",
publishNewVersion: "Publiko version të ri",
publishing: "Duke publikuar…",
versionNamePh: "Emri i versionit (opsional), p.sh. Vera 2026",
versionsTitle: "Versione të publikuara",
versionsHint: "Kliko një për ta ngarkuar në editor. Publikimi krijon gjithmonë version të ri — versionet e kaluara nuk ndryshojnë kurrë.",
activeBadge: "aktive",
publishedOk: "U publikua versioni i ri i tarifës — tani është tarifa aktive.",
defaultCard: "Tarifa bazë (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",
modeFlat: "Çmim fiks / orë",
modeStepped: "Sipas kohëzgjatjes (deri-në)",
modePackage: "Paketë dritareje (një total)",
packageHint:
"NJË çmim total për çdo prani brenda dritares së këtij niveli — largimi më herët kushton njësoj. Prekja e dritares në dy net të ndryshme e faturon paketën dy herë (një herë për natë). Orët jashtë dritares vlerësohen me tarifën bazë.",
packageTotal: "Çmimi i paketës",
steppedHint:
"Vendos çmimin TOTAL për një qëndrim deri në një kohë të caktuar (p.sh. deri 3 orë = 500). Fiton rreshti i parë me kufi ≥ kohëzgjatjes (kufiri përfshihet). Totali i rreshtit të fundit përsëritet si çmim ditor për qëndrime më të gjata.",
stepUpTo: "Deri në",
stepTotal: "Çmimi total",
addStep: "+ Shto rresht",
steppedTiersConflict:
"⚠ Nivelet kohore/sezonale NUK zbatohen kur tarifa bazë është 'Sipas kohëzgjatjes (deri-në)' — motori i shpërfill plotësisht. Hiqi nivelet, ose ndrysho tarifën bazë në 'Shkallë orësh' a 'Çmim fiks'. Publikimi bllokohet derisa kjo të rregullohet.",
"⚠ Nivelet kohore/sezonale NUK zbatohen kur tarifa bazë është 'Sipas kohëzgjatjes (deri-në)' — motori i shpërfill plotësisht. Hiqi nivelet, ose ndrysho tarifën bazë në 'Shkallë orësh' a 'Çmim fiks / orë'. Publikimi bllokohet derisa kjo të rregullohet.",
tiersAdvanced: "Të avancuara: nivele kohore & sezonale",
tiersHint: "Opsionale. Shto nivele tarifore që vlejnë vetëm në orë/ditë/data ose kategori të caktuara (p.sh. orë e lirë, tarifë nate, fundjavë, autobus). Pa nivele, publikohet vetëm tarifa bazë.",
tierName: "Emri",
@@ -402,6 +430,8 @@ export const sq = {
scan: "Skano për kontroller",
scanning: "Duke skanuar…",
noControllersFound: "Asnjë kontroller në LAN.",
usbNoneFound: "Nuk u gjet asnjë printer USB (/dev/usb/lpN) — kontrollo kabllon/ushqimin; rruga mund të shkruhet me dorë.",
usbSavedMissing: "i ruajtur — jo i pranishëm tani",
use: "Përdor",
test: "Testo lidhjen",
testing: "Duke testuar…",
@@ -494,10 +524,10 @@ export const sq = {
// Anashkalimi i portës së pranisë (radar/kamera me defekt) — admini heq një sinjal si kusht.
presenceGateTitle: "Porta e pranisë në hyrje",
presenceGateHint:
"Butoni i hyrjes normalisht kërkon edhe radarin/lakun edhe një zbulim nga kamera për të konfirmuar një automjet real. Nëse një pajisje ka defekt, anashkaloje që kalimtarët të mund të hyjnë derisa ta rregullojë ekipi i mbështetjes. Çdo ndryshim regjistrohet në ledger, dhe biletat e lëshuara gjatë anashkalimit shënohen.",
presenceBypassRadar: "Anashkalo radarin / lakun (sensor prania me defekt)",
presenceBypassCamera: "Anashkalo kamerën (zbulim automjeti me defekt)",
presenceBypassActive: "Anashkalimi i pranisë aktiv — porta e hyrjes është dobësuar. Fike sapo pajisja të rregullohet.",
"Butoni i hyrjes normalisht kërkon edhe radarin edhe një event nga kamera për të konfirmuar një automjet në hyrje. Nëse një pajisje ka defekt, anashkaloje që kalimtarët të mund të hyjnë derisa ta rregullohet/ndërrohet. Çdo ndryshim regjistrohet në ledger, dhe biletat e lëshuara gjatë anashkalimit shënohen.",
presenceBypassRadar: "Anashkalo radarin (radari me defekt)",
presenceBypassCamera: "Anashkalo kamerën (kamera me defekt)",
presenceBypassActive: "Anashkalimi i pranisë aktiv — siguria e hyrjes është dobësuar.",
// Reveal/hide toggle for a secret field (e.g. the device web password).
revealSecret: "Shfaq fjalëkalimin",
hideSecret: "Fshih fjalëkalimin",
@@ -525,21 +555,26 @@ export const sq = {
lab: {
title: "Lab Tarife",
intro:
"Testo tarifat në kohë (dritare ditë/natë, kufi ditor, qëndrim tej afatit) në sekonda, pa pritur orë. Çmimi llogaritet me të njëjtën logjikë si kabina; nuk shkruhet asgjë në ledger.",
loadTicket: "Ngarko nga një biletë reale",
loadTicketPh: "Numri i biletës / identiteti",
load: "Ngarko",
loaded: "U ngarkua sesioni {{id}}",
tariffVersion: "Versioni i tarifës",
activeVersion: "Versioni aktiv (i tanishëm)",
"Kompozo tarifa eksperimentale dhe llogarit qëndrime hipotetike kundrejt tyre — asgjë nuk hyn në fuqi pa u publikuar. Çmimi llogaritet me të njëjtën logjikë si kabina; nuk shkruhet asgjë në ledger.",
drafts: "Tarifa laboratori",
newDraft: "Draft i ri",
activeTariff: "Tarifa aktive",
published: "Versione të publikuara",
noDrafts: "Ende pa tarifa laboratori — krijo një draft për të eksperimentuar.",
edit: "Ndrysho",
publish: "Publiko",
delete: "Fshi",
draftName: "Emri",
draftNamePh: "p.sh. Propozimi i dimrit",
saveDraft: "Ruaj draftin",
savingDraft: "Duke ruajtur…",
newDraftTitle: "Tarifë e re laboratori",
editDraftTitle: "Ndrysho tarifën e laboratorit",
confirmPublish: 'Të publikohet "{{name}}" si karta e re aktive e çmimeve? Hyn në fuqi menjëherë.',
confirmDelete: 'Të fshihet tarifa e laboratorit "{{name}}"?',
entered: "Hyrja",
asOf: "Deri më (tani/dalja)",
exit: "Dalja",
now: "Tani",
category: "Kategoria",
categoryPh: "p.sh. bus (bosh = makinë)",
payment: "Pagesa",
paid: "u pagua",
graceMin: "afati (min)",
price: "Llogarit çmimin",
pricing: "Duke llogaritur…",
outcome: "Rezultati",
@@ -551,6 +586,16 @@ export const sq = {
graceExpires: "Afati skadon",
curve: "Kurba sipas kohëzgjatjes",
curveHint: "Tarifa nga hyrja për disa kohëzgjatje — shih ku rrafshohet kufiri ditor ose ndryshojnë dritaret.",
bd: {
title: "Si prodhohet shuma",
rounding: "{{raw}} min qëndrim → {{billed}} min të faturuara (njësi {{inc}} min)",
grace: "Falas — brenda minutave të hirit ({{min}} min)",
package: "paketë dritareje",
step: "Dita {{day}}: qëndrim deri në {{hours}}h — total",
stepRepeated: "Dita {{day}}: mbi shkallën më të lartë — totali ditor",
cap: "U zbatua kufiri ditor {{cap}} (dita {{day}})",
total: "Totali",
},
},
subs: {
title: "Abonimet",
@@ -705,6 +750,51 @@ export const sq = {
fieldPhone: "Telefoni",
fieldEmail: "Email",
},
// Merchant validations (bar / lavazh) — the /setup/site panel, the merchant's
// /validate screen, and the booth-modal discount lines. See validation-discounts.md.
val: {
// /setup/site
sectionTitle: "Validime tregtare",
sectionHint: "Shërbime të tjera brenda parkut (bar / lavazh) skanojnë biletën e hyrjes dhe bëjnë zbritje — pagesa dhe fatura bëhen në kabinë.",
enableBar: "Bar",
enableLavazh: "Lavazh",
labelName: "Etiketa në faturë",
labelNamePh: "p.sh. Lavazh — 1 orë falas",
mode: "Lloji i zbritjes",
modeComp: "Parkimi falas plotësisht",
modeTimeCredit: "Minutat e para falas",
modeFixed: "Zbritje shume (shkruhet në skanim)",
modePercent: "Zbritje në përqindje",
minutes: "Minuta falas",
percent: "Përqindja (%)",
maxAmount: "Tavani i zbritjes për validim",
maxPerDay: "Maks. validime në ditë (bosh = pa kufi)",
users: "Përdoruesit që validojnë",
usersHint: "Vetëm përdoruesit e zgjedhur (me lejen validation:create në rolin e tyre) mund të aplikojnë këtë program nga pajisja e tyre.",
noUsers: "Asnjë përdorues në sistem — krijojeni te Përdoruesit.",
saved: "U ruajt.",
// /validate (the merchant screen)
title: "Validim biletash",
scanPrompt: "Skanoni ose shkruani numrin e biletës",
lookup: "Kërko",
entry: "Hyrja:",
notFound: "Nuk u gjet biletë me këtë numër.",
closed: "Bileta është e mbyllur (ka dalë ose është anuluar).",
subscription: "Kjo është hyrje abonenti — nuk validohet.",
amountLabel: "Shuma e zbritjes",
amountHint: "maks. {{max}}",
apply: "Apliko validimin",
applied: "Validimi u aplikua.",
existing: "Validime në këtë biletë",
voided: "anuluar",
used: "përdorur në pagesë",
void: "Anulo",
confirmVoid: "Të anulohet ky validim?",
noPrograms: "Nuk keni asnjë program validimi të lidhur me ju — kontaktoni administratorin.",
// booth pay modal / receipts
gross: "Tarifa",
discount: "Zbritje",
},
users: {
title: "Përdoruesit",
add: "+ Shto përdorues",
@@ -858,14 +948,23 @@ export const sq = {
payments: "Pagesa",
avgStay: "Qëndrim mes.",
subscribers: "Abonentë",
peakOcc: "Zënia maksimale",
voids: "Bileta të anuluara",
anomalies: "Anomali",
},
chart: {
flow: "Hyrjet & daljet me kalimin e kohës",
occupancy: "Zënia — makina brenda",
occupancySeries: "Makina brenda",
revenue: "Të ardhurat ({{currency}})",
mix: "Përbërja e të ardhurave",
peakHours: "Hyrjet sipas orës së ditës",
stay: "Kohëzgjatja e qëndrimit (sesione të mbyllura)",
heatmap: "Harta e hyrjeve — orë × ditë",
breakdown: "Ndarja",
},
capacityLine: "kapaciteti",
stay: { m: "m", h: "o" },
dowShort: ["Hën", "Mar", "Mër", "Enj", "Pre", "Sht", "Die"],
mix: { ticket: "Tranzit", subSales: "Abonime", subWindow: "Jashtë orarit" },
row: {
cash: "Para në dorë",
@@ -911,6 +1010,7 @@ export const sq = {
status: "Statusi",
path: "Rruga",
empty: "Asnjë regjistër.",
repeated: "Përsëritur {{count}} herë (hera e parë {{firstAt}})",
},
backup: {
title: "Kopje rezervë",
+7 -3
View File
@@ -14,6 +14,7 @@
// high-signal sources (failed requests, uncaught errors) are always captured.
import { LOG_LEVEL_ORDER, type ClientLogInput, type LogLevel } from "@parking/shared";
import { apiUrl, platformFetch } from "./origin.js";
const ENDPOINT = "/api/logs";
const FLUSH_MS = 4000;
@@ -76,7 +77,7 @@ async function flush(): Promise<void> {
const headers: Record<string, string> = { "content-type": "application/json" };
const csrf = readCookie(CSRF_COOKIE);
if (csrf) headers[CSRF_HEADER] = csrf;
await fetch(ENDPOINT, {
await platformFetch(apiUrl(ENDPOINT), {
method: "POST",
headers,
credentials: "include",
@@ -90,7 +91,10 @@ async function flush(): Promise<void> {
}
}
/** Best-effort synchronous flush on page hide (sendBeacon survives unload). */
/** Best-effort synchronous flush on page hide (sendBeacon survives unload). Browser
* only — sendBeacon is a native browser API with no Tauri-HTTP-plugin equivalent,
* so this drops silently in the desktop shell (unload is rare there; the regular
* 4s-interval flush above covers the common case). */
function flushBeacon(): void {
if (queue.length === 0) return;
const entries = queue.splice(0, queue.length);
@@ -99,7 +103,7 @@ function flushBeacon(): void {
// sendBeacon can't set the CSRF header; the server accepts the ingest for any
// signed-in session (cookie sent automatically). If CSRF later guards it strictly,
// this path degrades to "lost on unload" — acceptable for diagnostics.
navigator.sendBeacon(ENDPOINT, blob);
navigator.sendBeacon(apiUrl(ENDPOINT), blob);
} catch {
/* ignore */
}
+28
View File
@@ -10,6 +10,15 @@
// Keep this the SINGLE source for the backend origin — api.ts and the live-feed
// WebSocket both read it, so the web app and the desktop shell stay identical
// except for this one build-time value.
//
// platformFetch(): WebKitGTK treats tauri://localhost as a SECURE origin, so a
// plain http://127.0.0.1:3000 fetch() from inside it is blocked as mixed
// content (a WebKit limitation — CSP's connect-src does NOT override this;
// found 2026-09-03 as "Load failed" on every desktop request). Inside Tauri we
// dynamically import @tauri-apps/plugin-http's fetch, which routes the request
// through Tauri's native side instead of the webview's own fetch, sidestepping
// the check entirely. Browser build never imports the plugin (dynamic import,
// same pattern as desktop-updater.ts).
/** Backend HTTP origin, no trailing slash. Empty string = same-origin/relative. */
export const API_BASE: string = (import.meta.env.VITE_API_BASE ?? "").replace(/\/$/, "");
@@ -28,3 +37,22 @@ export function wsUrl(path: string): string {
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${proto}//${window.location.host}${path}`;
}
/** True when running inside the Tauri webview (not a normal browser). */
function inTauri(): boolean {
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
}
/**
* fetch(), but routed through @tauri-apps/plugin-http inside the desktop
* shell (see the file header for why the webview's own fetch can't reach
* the local backend). Same signature as the global fetch; a plain pass-
* through in the browser.
*/
export async function platformFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
if (inTauri()) {
const { fetch: tauriFetch } = await import("@tauri-apps/plugin-http");
return tauriFetch(input, init);
}
return fetch(input, init);
}
+109
View File
@@ -0,0 +1,109 @@
// Desktop-only WebSocket adapter.
//
// WebKitGTK treats tauri://localhost as a SECURE origin, so a plain
// ws://127.0.0.1:3000 connection from inside it is blocked as mixed content —
// same root cause as the HTTP fetch() issue (see origin.ts's platformFetch),
// but WS is a separate browser check with its own plugin
// (@tauri-apps/plugin-websocket), which routes the connection through Tauri's
// native side instead of the webview's own WebSocket.
//
// That plugin's API is async/listener-based, not the synchronous
// onopen/onmessage/onclose event surface use-live-feed.ts is written against
// (and has already been hardened for — reconnect backoff, StrictMode
// double-invoke, cleanup). Rather than rewrite that hook around a different
// API shape, this adapter presents the same native-WebSocket-like interface
// use-live-feed.ts already expects, so that hook needs no changes at all.
//
// Browser build: plain pass-through to the real WebSocket (this file's
// createPlatformSocket is only called from inside inTauri() callers).
export interface PlatformSocket {
onopen: (() => void) | null;
onmessage: ((ev: { data: string }) => void) | null;
onclose: (() => void) | null;
onerror: (() => void) | null;
close(): void;
}
class NativeSocketAdapter implements PlatformSocket {
onopen: (() => void) | null = null;
onmessage: ((ev: { data: string }) => void) | null = null;
onclose: (() => void) | null = null;
onerror: (() => void) | null = null;
#sock: WebSocket;
constructor(url: string) {
this.#sock = new WebSocket(url);
this.#sock.onopen = () => this.onopen?.();
this.#sock.onmessage = (ev) => this.onmessage?.({ data: ev.data as string });
this.#sock.onclose = () => this.onclose?.();
this.#sock.onerror = () => this.onerror?.();
}
close(): void {
this.#sock.close();
}
}
class TauriSocketAdapter implements PlatformSocket {
onopen: (() => void) | null = null;
onmessage: ((ev: { data: string }) => void) | null = null;
onclose: (() => void) | null = null;
onerror: (() => void) | null = null;
#closed = false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
#conn: any = null;
constructor(url: string) {
void this.#connect(url);
}
async #connect(url: string): Promise<void> {
try {
const { default: TauriWebSocket } = await import("@tauri-apps/plugin-websocket");
if (this.#closed) return; // close() called before connect resolved
// Runs on Tauri's native (Rust) side, NOT inside the webview page — there
// is no page context to auto-attach an Origin header the way a real
// browser WebSocket would. The server's anti-CSWSH check (routes/ws.ts)
// rejects any handshake with a missing/mismatched Origin, so it must be
// set explicitly here to match what WS_ALLOWED_ORIGINS expects
// (tauri://localhost — see apps/server/.env.example).
const conn = await TauriWebSocket.connect(url, { headers: { Origin: "tauri://localhost" } });
if (this.#closed) {
void conn.disconnect();
return;
}
this.#conn = conn;
conn.addListener((msg: { type: string; data: unknown }) => {
if (msg.type === "Text") {
this.onmessage?.({ data: msg.data as string });
} else if (msg.type === "Close") {
this.onclose?.();
}
// Binary/Ping/Pong: the server protocol here is text-JSON only (see
// routes/ws.ts) — nothing else is expected.
});
this.onopen?.();
} catch (err) {
console.error("Tauri WebSocket connect failed:", url, err);
this.onerror?.();
this.onclose?.();
}
}
close(): void {
this.#closed = true;
void this.#conn?.disconnect();
}
}
/** True when running inside the Tauri webview (not a normal browser). */
function inTauri(): boolean {
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
}
/** Open a live-feed socket, routed through the Tauri WebSocket plugin inside the
* desktop shell (mixed-content workaround), or the native WebSocket in a browser. */
export function createPlatformSocket(url: string): PlatformSocket {
return inTauri() ? new TauriSocketAdapter(url) : new NativeSocketAdapter(url);
}
+18 -5
View File
@@ -4,6 +4,7 @@ import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js";
import { qk } from "./query.js";
import { useLiveStore, type LaneStatus, type LanePresence } from "./live-store.js";
import { wsUrl } from "./origin.js";
import { createPlatformSocket, type PlatformSocket } from "./platform-ws.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
@@ -23,23 +24,34 @@ type WsMessage =
| { kind: "plate-recognized"; plate: { identity: string; plate: string; direction: "entry" | "exit" } };
export function useLiveFeed(): void {
/**
* @param enabled Gate on the WATCHER permission (`report:read` — mirrors the server's
* WS guard in routes/ws.ts). A user whose role lacks it (e.g. a merchant validator
* with only `validation:create`) must not attempt the socket at all: the server
* 403s the upgrade and the capped-backoff reconnect would otherwise hammer it
* forever, filling the server log with a 403 every few seconds.
*/
export function useLiveFeed(enabled: boolean = true): void {
const qc = useQueryClient();
const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice, setLanes, setRadar, patchPlate } =
useLiveStore();
// Hold the socket + reconnect timer across renders; guard against StrictMode
// double-invoke and unmount.
const sockRef = useRef<WebSocket | null>(null);
const sockRef = useRef<PlatformSocket | null>(null);
const retryRef = useRef(0);
const closedRef = useRef(false);
useEffect(() => {
if (!enabled) {
setStatus("closed");
return;
}
closedRef.current = false;
const connect = () => {
if (closedRef.current) return;
setStatus(retryRef.current === 0 ? "connecting" : "connecting");
const sock = new WebSocket(wsUrl("/api/ws"));
const sock = createPlatformSocket(wsUrl("/api/ws"));
sockRef.current = sock;
sock.onopen = () => {
@@ -117,7 +129,8 @@ export function useLiveFeed(): void {
sockRef.current?.close();
sockRef.current = null;
};
// qc / store setters are stable; run once on mount.
// qc / store setters are stable; re-run only if the permission gate flips
// (login as a different role without a full reload).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [enabled]);
}
+106 -26
View File
@@ -14,6 +14,7 @@ import {
can,
closeShift,
fetchShiftReport,
fetchVersion,
logout,
openShift,
setLanguagePref,
@@ -25,6 +26,7 @@ import {
} from "./api.js";
import { qk, queryClient } from "./lib/query.js";
import { Modal } from "./ui/Modal.js";
import { Spinner } from "./ui/Spinner.js";
import { setLanguage } from "./lib/i18n/index.js";
import { applyTheme, applyFontScale } from "./lib/theme.js";
import { useLiveFeed } from "./lib/use-live-feed.js";
@@ -45,6 +47,7 @@ import { DrawerManager } from "./DrawerManager.js";
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
import { LogsViewer } from "./LogsViewer.js";
import { BackupSettings } from "./BackupSettings.js";
import { ValidateScreen } from "./ValidateScreen.js";
import { RecycleBin } from "./RecycleBin.js";
import { Profile } from "./Profile.js";
// Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's
@@ -92,6 +95,17 @@ function SetupTab({ to, label, exact = false }: { to: string; label: string; exa
);
}
/** The running deploy's "<branch>-<short-sha>" (matches the Komodo Stack's TAG in
* komodo/resources.toml), gated the same as the "Park" tab (site:read) since it's the
* same kind of read-only app metadata. Renders nothing if the value isn't known (e.g. a
* local/dev build with no CI-supplied BUILD_VERSION) rather than showing an empty badge. */
function VersionBadge() {
const q = useQuery({ queryKey: ["version"], queryFn: fetchVersion, staleTime: Infinity });
const version = q.data?.buildVersion;
if (!version) return null;
return <span className="ml-auto shrink-0 pl-3 text-[0.7rem] text-term-muted">{version}</span>;
}
/** Setup layout — the config hub. Renders a permission-gated tab bar and the active
* tab's screen via <Outlet>. Each tab is a child route (its own URL + guard), so
* deep links and the back button work and a denied tab redirects to the booth. */
@@ -110,6 +124,7 @@ function SetupLayout() {
{show("recyclebin:read") && <SetupTab to="/setup/recycle-bin" label={t("nav.recycleBin")} />}
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
{show("backup:read") && <SetupTab to="/setup/backup" label={t("nav.backup")} />}
{show("site:read") && <VersionBadge />}
</nav>
<Outlet />
</div>
@@ -117,9 +132,10 @@ function SetupLayout() {
}
/** Subscriptions layout — a standalone top-level section (its own header nav entry),
* with tabs for the subscriber catalog, the plan catalog, and the tariff lab. Each
* tab is a gated child route; an operator with only subscription:read sees just the
* first tab. */
* with tabs for the subscriber catalog and the plan catalog. Each tab is a gated
* child route; an operator with only subscription:read sees just the first tab.
* (The tariff lab moved to /setup/tariff/lab, 2026-07-05 — it tests the tariff, so
* it lives with the tariff.) */
function SubscriptionsLayout() {
const { user } = rootRoute.useRouteContext();
const { t } = useTranslation();
@@ -129,7 +145,21 @@ function SubscriptionsLayout() {
<nav className="mb-4 flex flex-wrap items-center gap-1 border-b border-term-border">
{show("subscription:read") && <SetupTab to="/subscriptions" label={t("nav.subscriptions")} exact />}
{show("subscription:plan") && <SetupTab to="/subscriptions/plans" label={t("nav.plans")} />}
{show("tariff:read") && <SetupTab to="/subscriptions/tariff-lab" label={t("nav.tariffLab")} />}
</nav>
<Outlet />
</div>
);
}
/** Tariff layout — the rate-card hub under Setup: the composer (index) and the
* pricing LAB as sub-tabs. One tariff:read gate on the parent covers both. */
function TariffLayout() {
const { t } = useTranslation();
return (
<div>
<nav className="mb-2 flex flex-wrap items-center gap-1 border-b border-term-border">
<SetupTab to="/setup/tariff" label={t("nav.tariff")} exact />
<SetupTab to="/setup/tariff/lab" label={t("nav.tariffLab")} />
</nav>
<Outlet />
</div>
@@ -320,9 +350,15 @@ function ShiftButton() {
disabled={busy || blockedByOther}
title={blockedByOther ? t("shift.headerHeldBy", { operator: heldBy ?? "?" }) : undefined}
onClick={onClick}
className={`rounded-term border px-2 py-0.5 text-[0.6875rem] font-semibold uppercase tracking-wider ${tone}`}
className={`rounded-term border px-2 py-0.5 text-[0.6875rem] font-semibold uppercase tracking-wider disabled:opacity-60 ${tone}`}
>
{busy ? t("shift.opening") : label}
{busy ? (
<span className="inline-flex items-center gap-1.5">
<Spinner /> {isMine ? t("shift.ending") : t("shift.opening")}
</span>
) : (
label
)}
</button>
{!isOpen && (
<span className="text-[0.625rem] uppercase tracking-wider text-term-amber">{t("shift.headerNoShift")}</span>
@@ -394,7 +430,13 @@ function CloseShiftConfirm({
{t("subs.cancel")}
</button>
<button type="button" className="btn btn-sm btn-danger" onClick={onConfirm} disabled={busy || !x}>
{busy ? t("shift.ending") : t("shift.endShift")}
{busy ? (
<span className="inline-flex items-center gap-1.5">
<Spinner /> {t("shift.ending")}
</span>
) : (
t("shift.endShift")
)}
</button>
</div>
</div>
@@ -419,19 +461,28 @@ function ConfirmFigure({ label, value, bold, sub }: { label: string; value: stri
function RootLayout() {
const { user, setUser } = rootRoute.useRouteContext();
const { t } = useTranslation();
// One app-wide WebSocket for the live feed (booth + any live widget).
useLiveFeed();
// Nav is gated by PERMISSION, not role — a tab shows iff the user's role grants
// the permission its screen needs (the route guards enforce the same server-side).
const show = (perm: Permission) => can(user, perm);
// One app-wide WebSocket for the live feed (booth + any live widget) — but ONLY
// for roles the server would accept (routes/ws.ts gates on report:read). A
// merchant validator must not even attempt it: the 403'd upgrade would reconnect
// on backoff forever and spam the server log. Same rule for the widgets that feed
// off it (StatusDot) or make their own gated calls (ShiftButton → shift:read,
// DeviceFooter → device:read).
const canWatch = show("report:read");
useLiveFeed(canWatch);
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="/shifts" label={t("nav.shifts")} />
{show("session:read") && <NavLink to="/booth" label={t("nav.booth")} />}
{show("shift:read") && <NavLink to="/shifts" label={t("nav.shifts")} />}
{/* The merchant's (bar/lavazh) scan-and-validate screen. Their typical role
grants ONLY validation:create, so this is often their whole nav. */}
{show("validation:create") && <NavLink to="/validate" label={t("nav.validate")} />}
{/* Drawer — record cash movements (operator) / review them (admin). Shown if the
user can do either. See wiki/concepts/shift.md. */}
{(show("drawer:create") || show("drawer:review")) && (
@@ -457,11 +508,11 @@ function RootLayout() {
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
</nav>
<div className="ml-auto flex items-center gap-3">
{user && <ShiftButton />}
{user && show("shift:read") && <ShiftButton />}
{user && <LanguageToggle user={user} setUser={setUser} />}
{user && <ThemeToggle user={user} setUser={setUser} />}
{user && <FontScaleToggle user={user} setUser={setUser} />}
<StatusDot />
{canWatch && <StatusDot />}
{user && (
<Link
to="/profile"
@@ -486,8 +537,10 @@ function RootLayout() {
<main className="min-h-0 flex-1 overflow-auto p-3">
<Outlet />
</main>
{/* Fixed device-status footer — relays, readers, cameras, printers. */}
{user && <DeviceFooter />}
{/* Fixed device-status footer — relays, readers, cameras, printers. Its REST
seed needs device:read (and its live updates ride the report:read WS), so
it's hidden for roles without device visibility (e.g. merchant validators). */}
{user && show("device:read") && <DeviceFooter />}
</div>
);
}
@@ -495,7 +548,12 @@ function RootLayout() {
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/",
beforeLoad: () => {
beforeLoad: ({ context }) => {
// A merchant-only user (validation:create without the booth's session:read)
// lands on their scan-and-validate screen; everyone else on the booth.
if (can(context.user, "validation:create") && !can(context.user, "session:read")) {
throw redirect({ to: "/validate" });
}
throw redirect({ to: "/booth" });
},
});
@@ -506,6 +564,20 @@ const boothRoute = createRoute({
component: BoothScreen,
});
// The merchant (bar/lavazh) scan-and-validate screen — usually the ONLY page a
// merchant user's role can reach. The server enforces the program↔user binding on
// apply; this gate is defence in depth. See wiki/concepts/validation-discounts.md.
const validateRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/validate",
beforeLoad: ({ context }) => requirePerm("validation:create")(context),
component: function ValidateRoute() {
const { user } = rootRoute.useRouteContext();
if (!user) return null;
return <ValidateScreen user={user} />;
},
});
// Back-compat redirects for paths that moved. Most config screens live under /setup;
// Subscriptions/Plans/Tariff-Lab were promoted OUT of /setup into the standalone
// /subscriptions section (2026-06-21) — redirect the old /setup/* paths too so existing
@@ -519,7 +591,10 @@ const legacyRedirects = (
["/shift", "/shifts"],
["/setup/subscriptions", "/subscriptions"],
["/setup/plans", "/subscriptions/plans"],
["/setup/tariff-lab", "/subscriptions/tariff-lab"],
// The tariff lab bounced twice: /setup/tariff-lab → /subscriptions/tariff-lab
// (2026-06-21) → /setup/tariff/lab (2026-07-05, back with the tariff it tests).
["/setup/tariff-lab", "/setup/tariff/lab"],
["/subscriptions/tariff-lab", "/setup/tariff/lab"],
["/setup/shifts", "/shifts"],
["/setup/reports", "/reports"],
] as const
@@ -633,8 +708,20 @@ const tariffRoute = createRoute({
getParentRoute: () => setupRoute,
path: "tariff",
beforeLoad: ({ context }) => requirePerm("tariff:read")(context),
component: TariffLayout,
});
const tariffComposerRoute = createRoute({
getParentRoute: () => tariffRoute,
path: "/",
component: () => <TariffComposer />,
});
// The tariff LAB — lives with the tariff it tests (moved from /subscriptions,
// 2026-07-05). The parent's tariff:read gate covers it.
const tariffLabRoute = createRoute({
getParentRoute: () => tariffRoute,
path: "lab",
component: () => <TariffLab />,
});
// --- /subscriptions — a standalone top-level section with its own tabs. The catalog
// (index), the plan catalog, and the tariff lab live here, not under /setup. ---
@@ -651,7 +738,6 @@ const subscriptionsIndexRoute = createRoute({
beforeLoad: ({ context }) => {
if (can(context.user, "subscription:read")) return;
if (can(context.user, "subscription:plan")) throw redirect({ to: "/subscriptions/plans" });
if (can(context.user, "tariff:read")) throw redirect({ to: "/subscriptions/tariff-lab" });
throw redirect({ to: "/booth" });
},
component: function SubscriptionsRoute() {
@@ -665,12 +751,6 @@ const subscriptionPlansRoute = createRoute({
beforeLoad: ({ context }) => requirePerm("subscription:plan")(context),
component: () => <SubscriptionPlansManager />,
});
const tariffLabRoute = createRoute({
getParentRoute: () => subscriptionsRoute,
path: "tariff-lab",
beforeLoad: ({ context }) => requirePerm("tariff:read")(context),
component: () => <TariffLab />,
});
const siteRoute = createRoute({
getParentRoute: () => setupRoute,
path: "site",
@@ -743,6 +823,7 @@ const profileRoute = createRoute({
const routeTree = rootRoute.addChildren([
indexRoute,
boothRoute,
validateRoute,
...legacyRedirects,
profileRoute,
shiftRoute,
@@ -751,11 +832,10 @@ const routeTree = rootRoute.addChildren([
subscriptionsRoute.addChildren([
subscriptionsIndexRoute,
subscriptionPlansRoute,
tariffLabRoute,
]),
setupRoute.addChildren([
setupDevicesRoute,
tariffRoute,
tariffRoute.addChildren([tariffComposerRoute, tariffLabRoute]),
siteRoute,
usersRoute,
rolesRoute,
+2 -1
View File
@@ -2,6 +2,7 @@ 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 { formatClock } from "../lib/format.js";
import { qk } from "../lib/query.js";
import { useLiveStore } from "../lib/live-store.js";
@@ -184,7 +185,7 @@ export function DeviceFooter() {
</div>
{d.detail && <div className="mt-0.5 break-words text-[0.6875rem] text-term-muted">{d.detail}</div>}
<div className="mt-0.5 text-[0.625rem] tabular-nums text-term-muted/70">
{t("devices.checkedAt", { time: new Date(d.checkedAt).toLocaleTimeString() })}
{t("devices.checkedAt", { time: formatClock(d.checkedAt) })}
</div>
</div>
</li>
+4 -3
View File
@@ -1,5 +1,6 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { formatDateTime } from "../lib/format.js";
import { useQuery } from "@tanstack/react-query";
import { fetchSnapshots, snapshotImageUrl, type PlateRead } from "../api.js";
@@ -52,7 +53,7 @@ export function SnapshotStrip({ identity }: { identity: string }) {
key={`${p.plate}-${p.direction}-${i}`}
className="inline-flex items-center gap-1.5 rounded-term border border-term-cyan/40 bg-term-cyan/10 px-2 py-0.5 text-[0.6875rem]"
title={`${dirLabel(p.direction)}${p.region ? ` · ${p.region}` : ""}${
p.at ? ` · ${new Date(p.at).toLocaleString()}` : ""
p.at ? ` · ${formatDateTime(p.at, t)}` : ""
}`}
>
<span className="text-[0.5625rem] uppercase tracking-wider text-term-muted">{t("pay.plate")}</span>
@@ -72,7 +73,7 @@ export function SnapshotStrip({ identity }: { identity: string }) {
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={`${dirLabel(s.direction)} · ${new Date(s.capturedAt).toLocaleString()}`}
title={`${dirLabel(s.direction)} · ${formatDateTime(s.capturedAt, t)}`}
>
<img
src={snapshotImageUrl(s.id)}
@@ -97,7 +98,7 @@ export function SnapshotStrip({ identity }: { identity: string }) {
<div
key={`fail-${f.direction ?? "both"}-${i}`}
className="flex h-[6.75rem] w-28 flex-col items-center justify-center gap-1 rounded-term border border-dashed border-term-amber/60 bg-term-amber/5 p-1 text-center"
title={`${dirLabel(f.direction)} · ${f.error}${f.occurredAt ? ` · ${new Date(f.occurredAt).toLocaleString()}` : ""}`}
title={`${dirLabel(f.direction)} · ${f.error}${f.occurredAt ? ` · ${formatDateTime(f.occurredAt, t)}` : ""}`}
>
<span className="text-lg leading-none text-term-amber">⚠</span>
<span className="text-[0.5625rem] uppercase tracking-wider text-term-amber">{dirLabel(f.direction)}</span>
+13
View File
@@ -0,0 +1,13 @@
// Inline busy indicator for buttons whose action can take a moment (opening a
// shift signs an event over the whole chain; printing waits on hardware). A label
// swap alone ("Opening…") proved too subtle on the booth — operators re-clicked or
// assumed the click was lost, so busy buttons pair the text with this spinner.
// Inherits the button's text colour via border-current.
export function Spinner() {
return (
<span
aria-hidden
className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent align-[-1px]"
/>
);
}
+3 -2
View File
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import { type ReactNode } from "react";
import { type LedgerEvent } from "../api.js";
import { formatMoney } from "../lib/format.js";
import { formatMoney, formatDateTime } from "../lib/format.js";
import { renderReason } from "../lib/reason.js";
import { Modal } from "./Modal.js";
import { SnapshotStrip } from "./SnapshotStrip.js";
@@ -25,6 +25,7 @@ export const EVENT_STYLE: Record<string, { labelKey: string; color: string }> =
cash_out: { labelKey: "booth.evtCashOut", color: "text-term-amber" },
cash_review: { labelKey: "booth.evtCashReview", color: "text-term-cyan" },
config_change: { labelKey: "booth.evtConfigChange", color: "text-term-amber" },
validation: { labelKey: "booth.evtValidation", color: "text-term-green" },
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
};
@@ -226,7 +227,7 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
{/* Humanized fields — labelled rows, not raw JSON. Only what applies renders. */}
<div>
<DetailRow label={t("booth.edTime")}>{new Date(e.occurredAt).toLocaleString()}</DetailRow>
<DetailRow label={t("booth.edTime")}>{formatDateTime(e.occurredAt, t, { seconds: true })}</DetailRow>
<DetailRow label={t("booth.edIndex")}>#{e.index}</DetailRow>
{e.direction && <DetailRow label={t("booth.edDirection")}>{e.direction}</DetailRow>}
{e.source && <DetailRow label={t("booth.edSource")}>{e.source}</DetailRow>}
+11 -4
View File
@@ -68,9 +68,10 @@ WS_ALLOWED_ORIGINS=
# # (multi-NIC hosts; usually auto-detected fine)
# WEB_DIST_DIR= # where the built SPA lives (the image sets it)
# --- logging ---
# LOG_LEVEL=info # debug|info|warn|error
# LOG_RETENTION_DAYS=30 # app_logs auto-purge age
# LOG_RETENTION_MAX_ROWS=50000 # app_logs row cap
# LOG_LEVEL=info # trace|debug|info|warn|error|fatal (pino)
# LOG_RETENTION_DAYS=60 # app_logs auto-purge age (~2 months, matches the
# # container-log rotation caps in compose.prod)
# LOG_RETENTION_MAX_ROWS=50000 # app_logs row cap (burst guard regardless of age)
# RECYCLE_BIN_RETENTION_DAYS=30 # soft-deleted items auto-purge age (0 = keep forever)
# --- snapshots (camera evidence; stored re-encoded, then pruned under disk pressure) ---
# SNAPSHOT_MAX_EDGE=1280 # downscale long edge (px) before storing
@@ -84,7 +85,11 @@ WS_ALLOWED_ORIGINS=
# DEVICE_POLL_MS=8000 # device health poll interval
# PRINTER_POLL_MS=5000 # printer status poll interval
# LANE_BUSY_TTL_MS=30000 # how long a lane stays "busy" after a vehicle push
# CAPTURE_TTL_MS=30000 # snapshot evidence cache TTL
# CAPTURE_TTL_MS=30000 # credential-enrollment arm window (card capture form)
# --- readers (DT-008 channel tagging — must MATCH the vendor tool's output prefixes;
# see wiki/entities/dingtian-dt008-reader.md) ---
# READER_QR_PREFIX=Q: # optical-decode marker the reader prepends
# READER_CARD_PREFIX=K: # RF-read marker the reader prepends
# --- ANPR / vision (server side) ---
# VISION_URL is compose-set (above). These are the knobs you may tweak per booth:
# VISION_TIMEOUT_MS=1500 # per /analyze call timeout
@@ -98,6 +103,8 @@ WS_ALLOWED_ORIGINS=
# # barrier means the car waits >8s before reading clean.
# ANPR_POLL_MAX_MS=30000 # hard ceiling on one loop from start (so a continuously
# # busy lane can't slide the window forever)
# ENTRY_DUP_PLATE_WINDOW_MIN=15 # entry ANPR duplicate-plate lookback (same plate already
# # OPEN under another session → signed anomaly)
# ════════════════════════════════════════════════════════════════════════════
# VISION CONTAINER env (the Python ANPR service — its OWN process, prefix VISION_)
+38 -2
View File
@@ -49,11 +49,47 @@ REGISTRY=git.infra.msai.al/mca/parking_solution
# Staging booth: pinned immutable stage-<sha>. After each promotion (merge dev → stage, CI builds
# :stage-<sha>), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag
# exists as the pointer; we deploy the sha, not the mover.
TAG=stage-93f9ebe
TAG=stage-28bd838
COOKIE_SECURE=0
VISION_ENABLED=1
WS_ALLOWED_ORIGINS=
# Desktop app WS handshake: Origin is tauri://localhost (set explicitly by
# platform-ws.ts, since the native WS plugin has no page context to auto-attach
# one). Linux may also send http://tauri.localhost. See routes/ws.ts anti-CSWSH check.
WS_ALLOWED_ORIGINS=tauri://localhost,http://tauri.localhost
JWT_SECRET=[[park_buzi_jwt_secret]]
EVENT_SIGNING_KEY=[[park_buzi_event_signing_key]]
BACKUP_KEY=[[park_buzi_backup_key]]
"""
##############################################################################
[[stack]]
name = "park-2"
[stack.config]
server = "park-2"
git_provider = "git.infra.msai.al"
git_account = "komodo"
repo = "mca/parking_solution"
branch = "stage"
file_paths = [
"docker-compose.yml",
"docker-compose.prod.yml"
]
registry_provider = "git.infra.msai.al"
registry_account = "komodo"
environment = """
REGISTRY=git.infra.msai.al/mca/parking_solution
# Staging booth: pinned immutable stage-<sha>. After each promotion (merge dev → stage, CI builds
# :stage-<sha>), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag
# exists as the pointer; we deploy the sha, not the mover.
TAG=stage-28bd838
COOKIE_SECURE=0
VISION_ENABLED=1
# Desktop app WS handshake: Origin is tauri://localhost (set explicitly by
# platform-ws.ts, since the native WS plugin has no page context to auto-attach
# one). Linux may also send http://tauri.localhost. See routes/ws.ts anti-CSWSH check.
WS_ALLOWED_ORIGINS=tauri://localhost,http://tauri.localhost
JWT_SECRET=[[park_2_jwt_secret]]
EVENT_SIGNING_KEY=[[park_2_event_signing_key]]
BACKUP_KEY=[[park_2_backup_key]]
"""
@@ -0,0 +1,14 @@
-- Tariff-lab drafts (2026-07-05). A mutable scratchpad for the lab: the admin composes
-- experimental rate cards here, simulates them against hypothetical stays, and only
-- PUBLISHES (normal immutable tariff_versions path) when satisfied. Deliberately mutable —
-- a draft prices nothing and signs nothing; experimenting through real publishes would
-- churn permanent versions and risk a wrong card going live. See wiki/concepts/tariff.md.
CREATE TABLE `tariff_drafts` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`currency` text NOT NULL,
`structure` text NOT NULL,
`created_by` text,
`created_at` text DEFAULT (current_timestamp) NOT NULL,
`updated_at` text DEFAULT (current_timestamp) NOT NULL
);
@@ -0,0 +1,6 @@
-- Optional name on published tariff versions (2026-07-05). The lab's draft workflow gave
-- rate cards human names; published versions were only tellable apart by effective date +
-- UUID prefix. The name is stamped at publish (carried from the lab draft, or typed in the
-- composer) and is immutable like the rest of the row. Nullable — old versions and unnamed
-- publishes are fine.
ALTER TABLE `tariff_versions` ADD `name` text;
@@ -0,0 +1,6 @@
-- Driver rename (2026-07-06): "cashino" → "escpos". The driver was always the GENERIC
-- ESC/POS printer driver (reachability-only clones); it carried the first unit's vendor
-- name, which read as misleading in the setup UI once other clones (ICS/Xprinter
-- XP-K200L) used it. Rewrite stored device rows; the registry also keeps a permanent
-- cashino→escpos alias so restored pre-rename backups still resolve.
UPDATE `devices` SET `driver_id` = 'escpos' WHERE `driver_id` = 'cashino';
@@ -0,0 +1,31 @@
-- Merchant validation programs (2026-07-13). In-park merchants (bar / lavazh) validate a
-- customer's ticket so the BOOTH settlement discounts the fee — the merchant only
-- validates, all money and paper stay at the booth. The /setup/site checkboxes toggle the
-- WELL-KNOWN rows ("bar", "lavazh"); a future merchant is a new row, not a migration.
-- Config is plainly MUTABLE (no versioning): the applied validation is a signed ledger
-- event carrying the RESOLVED values, so reproducibility never depends on these rows.
-- See wiki/concepts/validation-discounts.md.
CREATE TABLE `validation_programs` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`mode` text DEFAULT 'comp' NOT NULL,
`minutes` integer,
`percent` integer,
`max_amount_minor` integer,
`max_per_day` integer,
`active` integer DEFAULT 0 NOT NULL,
`created_at` text DEFAULT (current_timestamp) NOT NULL,
`deleted_at` text,
`deleted_by` text
);
--> statement-breakpoint
-- WHICH users may apply a program: the apply guard is `validation:create` AND a binding
-- row here — a bar user can never apply the lavazh program.
CREATE TABLE `validation_program_users` (
`program_id` text NOT NULL,
`user_id` text NOT NULL,
FOREIGN KEY (`program_id`) REFERENCES `validation_programs`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `validation_program_users_program_id_user_id_unique` ON `validation_program_users` (`program_id`,`user_id`);
@@ -0,0 +1,11 @@
-- Last-success/last-error for the encrypted DB backup were previously tracked only as
-- in-process fields on BackupService (never written to the DB) — so every server restart
-- (deploy/crash/OOM/host reboot, all routine under `restart: always`) silently reset the admin
-- UI's "last successful backup" to "Never", even with valid, correctly-rotating backups already
-- on disk (2026-08-30 field incident, park-buzi). Four additive, nullable columns; null = no
-- run recorded yet (or, for the error pair, no failure since the last success). See
-- wiki/concepts/backup-recovery.md.
ALTER TABLE `site_config` ADD `backup_last_success_at` text;--> statement-breakpoint
ALTER TABLE `site_config` ADD `backup_last_result_json` text;--> statement-breakpoint
ALTER TABLE `site_config` ADD `backup_last_error_at` text;--> statement-breakpoint
ALTER TABLE `site_config` ADD `backup_last_error` text;
+35
View File
@@ -148,6 +148,41 @@
"when": 1781886300000,
"tag": "0020_entry_presence_bypass",
"breakpoints": true
},
{
"idx": 21,
"version": "6",
"when": 1781886400000,
"tag": "0021_tariff_drafts",
"breakpoints": true
},
{
"idx": 22,
"version": "6",
"when": 1781886500000,
"tag": "0022_tariff_version_name",
"breakpoints": true
},
{
"idx": 23,
"version": "6",
"when": 1781886600000,
"tag": "0023_driver_id_escpos",
"breakpoints": true
},
{
"idx": 24,
"version": "6",
"when": 1783948800000,
"tag": "0024_validation_programs",
"breakpoints": true
},
{
"idx": 25,
"version": "6",
"when": 1788078414270,
"tag": "0025_backup_last_status",
"breakpoints": true
}
]
}
+50 -4
View File
@@ -17,9 +17,17 @@
// their credentials/plates, blocklist. KEEPS users, devices, config,
// tariffs, subscription PLANS.
// --config site_config, devices, setup_state (re-runs first-run setup),
// tariffs + tariff_versions, subscription_plans.
// tariffs + tariff_versions + tariff_drafts, subscription_plans.
// --users users, roles, role_permissions, auth sessions. (After this or --all,
// re-seed an admin: apps/server/scripts/seed-admin.mjs.)
// --diagnostics app_logs (the unsigned diagnostic store behind the /setup/logs
// viewer). Separate from --financial: logs are evidence about the BOX,
// not the traffic — wipe them only when handing over a blank slate.
//
// DRIFT GUARD: before doing anything, the script compares the union of the categories
// above against the tables actually present in the DB and REFUSES if any table is
// uncategorized — so a new table can't silently survive resets (app_logs and
// tariff_drafts did exactly that until 2026-07-08).
//
// Safety gates (BOTH required):
// 1. env RESET_ALLOWED=1 — a real booth never sets this.
@@ -44,10 +52,44 @@ const CATEGORIES = {
"subscriptions",
"blocklist",
],
config: ["site_config", "devices", "setup_state", "tariff_versions", "tariffs", "subscription_plans"],
config: [
"site_config",
"devices",
"setup_state",
"tariff_drafts",
"tariff_versions",
"tariffs",
"subscription_plans",
// Merchant validation programs (bar/lavazh) + their user bindings (child first).
// A --users reset without --config may orphan a binding row; harmless — a binding
// whose user is gone grants nothing.
"validation_program_users",
"validation_programs",
],
users: ["sessions", "role_permissions", "users", "roles"],
diagnostics: ["app_logs"],
};
/** Every user table in the DB must belong to a category above (internal bookkeeping
* like sqlite_* and drizzle's __* migration table excepted). Dies listing offenders —
* the fix is a one-line addition to CATEGORIES, decided deliberately, not by omission. */
function assertNoUncategorizedTables(sqlite) {
const known = new Set(Object.values(CATEGORIES).flat());
const actual = sqlite
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table'`)
.all()
.map((r) => r.name)
.filter((n) => !n.startsWith("sqlite_") && !n.startsWith("__"));
const uncategorized = actual.filter((n) => !known.has(n));
if (uncategorized.length > 0) {
die(
`schema drift — table(s) not covered by any reset category: ${uncategorized.join(", ")}\n` +
` add them to CATEGORIES in packages/db/scripts/reset-db.mjs (this guard exists so\n` +
` new tables can't silently survive resets).`,
);
}
}
function parseArgs(argv) {
const flags = new Set(argv.filter((a) => a.startsWith("--")).map((a) => a.slice(2)));
const wantAll = flags.has("all");
@@ -75,7 +117,7 @@ async function main() {
const { cats, autoYes, wantAll } = parseArgs(process.argv.slice(2));
if (cats.length === 0) {
die("nothing to do — pass --all, --financial, --config, and/or --users");
die("nothing to do — pass --all, --financial, --config, --users, and/or --diagnostics");
}
// GATE 1: env opt-in. A production booth never sets this.
@@ -86,6 +128,11 @@ async function main() {
);
}
// Open early: the drift guard must run BEFORE anything is printed or confirmed, so
// an uncategorized table aborts the whole run rather than surviving a "successful" reset.
const sqlite = new Database(dbPath);
assertNoUncategorizedTables(sqlite);
// Resolve the ordered, de-duplicated table list for the chosen categories.
const tables = [];
for (const c of cats) for (const t of CATEGORIES[c]) if (!tables.includes(t)) tables.push(t);
@@ -106,7 +153,6 @@ async function main() {
if (!ok) die("confirmation did not match — aborted, nothing changed.");
}
const sqlite = new Database(dbPath);
try {
// FKs OFF for the wipe so we can delete in any order without ordering hazards;
// a single transaction makes it all-or-nothing.
+1 -1
View File
@@ -5,7 +5,7 @@ import * as schema from "./schema.js";
export * from "./schema.js";
// Re-export the query helpers consumers need, so they don't depend on
// drizzle-orm directly (it's an implementation detail of this package).
export { eq, ne, and, or, asc, desc, gte, lte, isNull, isNotNull, inArray, sql } from "drizzle-orm";
export { eq, ne, and, or, asc, desc, gt, gte, lt, lte, isNull, isNotNull, inArray, sql } from "drizzle-orm";
/**
* Open the local SQLite database in WAL mode. WAL allows many concurrent readers
+95
View File
@@ -288,6 +288,20 @@ export const siteConfig = sqliteTable("site_config", {
backupKeepLast: integer("backup_keep_last"),
/** Beyond keepLast, keep one backup per day for this many days. null ⇒ code default (30). */
backupKeepDailyDays: integer("backup_keep_daily_days"),
/** ISO timestamp of the last backup that actually completed successfully. Persisted here
* (not just in-process memory) so the admin UI's "last successful backup" survives a
* server restart — before this column existed, a restart silently reset that status to
* "Never" even with valid backups already on disk. null = no successful run recorded yet.
* See wiki/concepts/backup-recovery.md. */
backupLastSuccessAt: text("backup_last_success_at"),
/** JSON-encoded { path, bytes, prunedFiles } of the last successful run, for the same
* restart-durability reason as backupLastSuccessAt. null = none recorded yet. */
backupLastResultJson: text("backup_last_result_json"),
/** ISO timestamp of the last FAILED scheduled/manual backup attempt, persisted for the same
* reason. null = no failure recorded (or none since the last success). */
backupLastErrorAt: text("backup_last_error_at"),
/** Error message of the last failed attempt. Cleared (set null) on the next success. */
backupLastError: text("backup_last_error"),
updatedAt: text("updated_at")
.notNull()
.default(sql`(current_timestamp)`),
@@ -316,6 +330,10 @@ export const tariffs = sqliteTable("tariffs", {
export const tariffVersions = sqliteTable("tariff_versions", {
id: text("id").primaryKey(),
tariffId: text("tariff_id").notNull(),
// Optional human label ("Winter 2027", carried from the lab draft it was published
// from). Stamped at publish, immutable like the rest of the row — versions are
// told apart in the UI by name, not UUID prefix.
name: text("name"),
// The version is in force from this instant (latest with effectiveFrom ≤ entry wins).
effectiveFrom: text("effective_from").notNull(),
// ISO 4217; selectable. Money everywhere is { minorUnits, currency }, never a float.
@@ -329,6 +347,29 @@ export const tariffVersions = sqliteTable("tariff_versions", {
.default(sql`(current_timestamp)`),
});
// A LAB DRAFT rate card — the tariff-lab scratchpad. MUTABLE by design (the one
// exception to "editing publishes a version"): a draft prices nothing and signs
// nothing — it exists so the admin can experiment in the lab without churning real
// tariff_versions (each publish is permanent; experimenting through publishes would
// bury the history in noise and risk a wrong card going live). Publishing a draft
// goes through the normal POST /api/tariff/versions path (validated, tz-stamped,
// immutable). See wiki/concepts/tariff.md (Tariff Lab).
export const tariffDrafts = sqliteTable("tariff_drafts", {
id: text("id").primaryKey(),
name: text("name").notNull(),
currency: text("currency").notNull(),
// Same TariffStructure shape as tariff_versions.structure; validated on save so
// the lab can always simulate it.
structure: text("structure", { mode: "json" }).notNull().$type<Record<string, unknown>>(),
createdBy: text("created_by"),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
updatedAt: text("updated_at")
.notNull()
.default(sql`(current_timestamp)`),
});
// --- Subscriptions --------------------------------------------------------
// A subscriber: a known holder who parks on a recurring plan (e.g. 10,000 ALL /
// month) instead of paying per stay. Mutable master data; every USE still produces a
@@ -431,6 +472,57 @@ export const subscriptionPlates = sqliteTable("subscription_plates", {
plate: text("plate").notNull(),
});
// --- Merchant validation programs (bar / lavazh) --------------------------
// Admin-composed master data for in-park merchant discounts: the /setup/site
// checkboxes toggle the WELL-KNOWN rows ("bar", "lavazh") — a future merchant is a
// new row, not a migration. Config is plainly MUTABLE (no versioning): the applied
// validation is a signed ledger event carrying the RESOLVED values, so historical
// reproducibility never depends on this row. Enabling/saving signs a config_change.
// See wiki/concepts/validation-discounts.md.
export const validationPrograms = sqliteTable("validation_programs", {
// Well-known slug ("bar" | "lavazh"); generic text so future merchants are rows.
id: text("id").primaryKey(),
// Receipt label printed on the booth settlement line (e.g. "Lavazh — 1 orë falas").
name: text("name").notNull(),
// How the program discounts — see @parking/shared ValidationMode.
mode: text("mode", { enum: ["comp", "timeCredit", "fixed", "percent"] })
.notNull()
.default("comp"),
// timeCredit: the free minutes.
minutes: integer("minutes"),
// percent: 1..100 off the fee.
percent: integer("percent"),
// fixed: cap on the amount the merchant may type at scan time (minor units).
maxAmountMinor: integer("max_amount_minor"),
// Anti-abuse cap: max applications per local day (null = unlimited).
maxPerDay: integer("max_per_day"),
// The /setup/site checkbox. Inactive = merchants can't apply it (row + history kept).
active: integer("active", { mode: "boolean" }).notNull().default(false),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
// Soft delete (recycle bin) — see roles.deletedAt.
deletedAt: text("deleted_at"),
deletedBy: text("deleted_by"),
});
// The program↔user binding: WHICH users may apply a program (the guard is
// `validation:create` AND a binding row — a bar user can never apply lavazh).
export const validationProgramUsers = sqliteTable(
"validation_program_users",
{
programId: text("program_id")
.notNull()
.references(() => validationPrograms.id),
userId: text("user_id")
.notNull()
.references(() => users.id),
},
(t) => ({
uniq: unique().on(t.programId, t.userId),
}),
);
// --- Blocklist (banlist) -------------------------------------------------
// Plates/cards refused at ENTRY (never at exit — never trap a vehicle). A hit appends
// a signed anomaly/refused-entry ledger event. See wiki/entities/blocklist.md.
@@ -513,10 +605,13 @@ export type SetupStateRow = typeof setupState.$inferSelect;
export type SiteConfigRow = typeof siteConfig.$inferSelect;
export type TariffRow = typeof tariffs.$inferSelect;
export type TariffVersionRow = typeof tariffVersions.$inferSelect;
export type TariffDraftRow = typeof tariffDrafts.$inferSelect;
export type SubscriptionRow = typeof subscriptions.$inferSelect;
export type SubscriptionPlanRow = typeof subscriptionPlans.$inferSelect;
export type SubscriptionCredentialRow = typeof subscriptionCredentials.$inferSelect;
export type SubscriptionPlateRow = typeof subscriptionPlates.$inferSelect;
export type BlocklistRow = typeof blocklist.$inferSelect;
export type ValidationProgramRow = typeof validationPrograms.$inferSelect;
export type ValidationProgramUserRow = typeof validationProgramUsers.$inferSelect;
export type SessionRow = typeof sessions.$inferSelect;
export type AppLogRow = typeof appLogs.$inferSelect;
+97 -1
View File
@@ -7,7 +7,11 @@ import type { DigestGetResult } from "./http-digest.js";
// fail FAST on a config error (401 auth / 404 path). See camera.ts.
const digestGet = vi.fn<(...a: unknown[]) => Promise<DigestGetResult>>();
vi.mock("./http-digest.js", () => ({ digestGet: (...a: unknown[]) => digestGet(...a) }));
const digestRequest = vi.fn<(...a: unknown[]) => Promise<DigestGetResult>>();
vi.mock("./http-digest.js", () => ({
digestGet: (...a: unknown[]) => digestGet(...a),
digestRequest: (...a: unknown[]) => digestRequest(...a),
}));
// Import the driver AFTER the mock is registered.
const { hikvisionDriver } = await import("./camera.js");
@@ -22,6 +26,7 @@ function makeCamera() {
beforeEach(() => {
digestGet.mockReset();
digestRequest.mockReset();
vi.useFakeTimers();
});
afterEach(() => {
@@ -113,3 +118,94 @@ describe("hikvision snapshot stream selection (main vs sub)", () => {
expect(pathFor({ host: "1.2.3.4", channel: 1, stream: 9 })).toBe("/ISAPI/Streaming/channels/101/picture");
});
});
describe("healthCheck detail is a STABLE size bucket (log-noise fix, 2026-07-05)", () => {
// The device monitor logs + re-emits whenever the detail string changes. JPEG
// frame size differs on every frame, so an exact byte count made healthy cameras
// "change" on nearly every poll. The detail must stay identical across ordinary
// frame-size jitter and only move on a real shift (different stream/res, tiny body).
it("frames of similar size land in the same bucket", async () => {
const cam = makeCamera();
digestGet.mockResolvedValueOnce(reply(200, "x".repeat(16_716)));
const a = await cam.healthCheck();
digestGet.mockResolvedValueOnce(reply(200, "x".repeat(17_902)));
const b = await cam.healthCheck();
expect(a).toEqual({ status: "ready", detail: "snapshot ≈16 KB" });
expect(b.detail).toBe(a.detail); // jitter does NOT change the detail
});
it("a genuinely different size (sub vs main stream) lands in a different bucket", async () => {
const cam = makeCamera();
digestGet.mockResolvedValueOnce(reply(200, "x".repeat(299_395)));
const big = await cam.healthCheck();
expect(big.detail).toBe("snapshot ≈256 KB");
});
it("an empty-ish 200 body is flagged, not bucketed away", async () => {
const cam = makeCamera();
digestGet.mockResolvedValueOnce(reply(200, "xx"));
const tiny = await cam.healthCheck();
expect(tiny.detail).toBe("snapshot <1 KB");
});
});
describe("hikvision syncClock — the 1970 power-cut recovery (ISAPI /System/time)", () => {
const timeXml = (localTime: string) =>
reply(
200,
`<?xml version="1.0"?><Time><timeMode>NTP</timeMode><localTime>${localTime}</localTime><timeZone>CST-2:00:00DST01:00:00</timeZone></Time>`,
);
const HOST_NOW = "2026-07-07T12:00:00+02:00";
function cam() {
return makeCamera() as unknown as {
syncClock(localIso: string, maxDriftSec: number): Promise<{ driftSeconds: number | null; synced: boolean }>;
};
}
it("in-sync camera: reads, does NOT set", async () => {
digestRequest.mockResolvedValueOnce(timeXml("2026-07-07T12:00:10+02:00"));
const r = await cam().syncClock(HOST_NOW, 60);
expect(r).toEqual({ driftSeconds: 10, synced: false });
expect(digestRequest).toHaveBeenCalledTimes(1); // GET only
});
it("1970 camera: PUTs manual time with the host instant, echoing the camera's timeZone", async () => {
digestRequest
.mockResolvedValueOnce(timeXml("1970-01-01T03:12:44+01:00"))
.mockResolvedValueOnce(reply(200, "<ResponseStatus/>"));
const r = await cam().syncClock(HOST_NOW, 60);
expect(r.synced).toBe(true);
expect(r.driftSeconds).toBeGreaterThan(1_000_000_000); // ~56 years
const put = digestRequest.mock.calls[1]![0] as { method: string; path: string; body: string };
expect(put.method).toBe("PUT");
expect(put.path).toBe("/ISAPI/System/time");
expect(put.body).toContain("<timeMode>manual</timeMode>");
expect(put.body).toContain(`<localTime>${HOST_NOW}</localTime>`);
expect(put.body).toContain("<timeZone>CST-2:00:00DST01:00:00</timeZone>"); // echoed, never invented
});
it("unparseable camera time = infinite drift → syncs", async () => {
digestRequest
.mockResolvedValueOnce(reply(200, "<Time><localTime>garbage</localTime></Time>"))
.mockResolvedValueOnce(reply(200, "<ResponseStatus/>"));
const r = await cam().syncClock(HOST_NOW, 60);
expect(r).toEqual({ driftSeconds: null, synced: true });
});
it("a failed set surfaces as an error (monitor logs it, backstop retries)", async () => {
digestRequest
.mockResolvedValueOnce(timeXml("1970-01-01T01:00:00+01:00"))
.mockResolvedValueOnce(reply(403, "denied"));
await expect(cam().syncClock(HOST_NOW, 60)).rejects.toThrow("clock set failed: HTTP 403");
});
it("the dahua driver does NOT claim the capability (no ISAPI time endpoint)", async () => {
const { dahuaDriver, hikvisionDriver } = await import("./camera.js");
const { isClockSyncable } = await import("../interfaces.js");
const mk = (d: typeof dahuaDriver) =>
d.create({ host: "10.0.10.12", port: 80, username: "admin", password: "x", channel: 1 });
expect(isClockSyncable(mk(dahuaDriver))).toBe(false);
expect(isClockSyncable(mk(hikvisionDriver))).toBe(true);
});
});
+84 -3
View File
@@ -1,5 +1,6 @@
import type {
CameraDevice,
ClockSyncResult,
DeviceHealth,
Snapshot,
SnapshotContext,
@@ -12,7 +13,7 @@ import {
usernameField,
stubLog,
} from "./common.js";
import { digestGet } from "./http-digest.js";
import { digestGet, digestRequest } from "./http-digest.js";
// Camera drivers — entry/exit snapshot-on-event. The host pulls a still over
// HTTP when an event fires; the bytes are stored and referenced from the signed
@@ -40,6 +41,19 @@ const SNAPSHOT_RETRY_BASE_MS = 250;
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
/** Coarse, STABLE size label for the health-check detail: nearest power-of-two KB
* (`≈16 KB`, `≈256 KB`). JPEG frame size varies with every frame, and the device
* monitor logs + re-emits a status whenever the detail string changes — an exact
* byte count made every healthy camera "change" on nearly every poll, spamming the
* rotated container logs. A pow-2 bucket keeps the diagnostic value (a suddenly
* tiny frame still shows) while flapping only on a real scene/stream shift. */
function sizeBucket(bytes: number): string {
const kb = bytes / 1024;
if (kb < 1) return "<1 KB"; // empty-ish 200 body — suspicious, worth seeing as-is
const pow = Math.round(Math.log2(kb));
return `≈${2 ** pow} KB`;
}
class HttpCamera implements CameraDevice {
readonly #host: string;
readonly #port: number;
@@ -85,7 +99,7 @@ class HttpCamera implements CameraDevice {
try {
const res = await this.#get();
if (res.status === 200)
return { status: "ready", detail: `${res.body.length} bytes` };
return { status: "ready", detail: `snapshot ${sizeBucket(res.body.length)}` };
if (res.status === 401)
return {
status: "degraded",
@@ -147,6 +161,73 @@ class HttpCamera implements CameraDevice {
localAddress: this.#localAddress,
});
}
/** Digest request against an arbitrary device path (ISAPI config reads/writes). */
protected isapi(method: "GET" | "PUT", path: string, body?: string) {
return digestRequest({
host: this.#host,
port: this.#port,
path,
method,
body,
user: this.#user,
password: this.#password,
timeoutMs: this.#timeout,
localAddress: this.#localAddress,
});
}
}
// --- Hikvision clock sync (ISAPI /System/time) ---------------------------------
// These cameras lose their clock on a power cut (no/dead RTC battery): they reboot
// at the 1970 epoch and stay there until a human logs into the web UI (which
// silently pushes the browser clock). A wrong camera clock corrupts the OSD
// timestamp burned into every snapshot — the evidence trail — and the times on
// ANPR pushes. So the host (the site's time authority — offline-first, no NTP
// dependency) re-syncs the camera over the same Digest-auth ISAPI used for
// snapshots. The device monitor calls this at the offline→ready edge (the
// power-restored moment) + a daily backstop. See wiki/entities/lpr-camera.md.
class HikvisionCamera extends HttpCamera {
/**
* Read the camera clock; when it drifts more than `maxDriftSec` from `localIso`
* (the site's wall-clock now, WITH utc offset), set it via
* PUT /ISAPI/System/time. The camera's own `timeZone` string is echoed back
* verbatim — we correct the CLOCK, never fight the tz/DST config; `localIso`'s
* explicit offset makes the instant unambiguous regardless of that config.
*/
async syncClock(localIso: string, maxDriftSec: number): Promise<ClockSyncResult> {
const read = await this.isapi("GET", "/ISAPI/System/time");
if (read.status !== 200) {
throw new Error(`clock read failed: HTTP ${read.status}`);
}
const xml = read.body.toString("utf8");
const cameraTime = xml.match(/<localTime>([^<]+)<\/localTime>/)?.[1]?.trim() ?? null;
const timeZone = xml.match(/<timeZone>([^<]+)<\/timeZone>/)?.[1]?.trim() ?? "";
// Drift: parse both sides as instants. A camera reply without a UTC offset (or
// otherwise unparseable) can't be trusted → treat as infinite drift and sync.
const cameraMs = cameraTime ? Date.parse(cameraTime) : NaN;
const hostMs = Date.parse(localIso);
const driftSeconds = Number.isFinite(cameraMs)
? Math.round(Math.abs(hostMs - cameraMs) / 1000)
: null;
if (driftSeconds != null && driftSeconds <= maxDriftSec) {
return { driftSeconds, synced: false };
}
const body =
`<?xml version="1.0" encoding="UTF-8"?>` +
`<Time><timeMode>manual</timeMode><localTime>${localIso}</localTime>` +
(timeZone ? `<timeZone>${timeZone}</timeZone>` : "") +
`</Time>`;
const put = await this.isapi("PUT", "/ISAPI/System/time", body);
if (put.status !== 200) {
throw new Error(`clock set failed: HTTP ${put.status}`);
}
stubLog(this.driverId, `clock synced (was ${driftSeconds ?? "unparseable"}s off)`);
return { driftSeconds, synced: true };
}
}
const channelField: ConfigField = {
@@ -237,7 +318,7 @@ export const hikvisionDriver: CameraDriver = {
// ISAPI channel id: <channel><stream>, e.g. ch1 main = 101, ch1 sub = 102, ch2 main = 201.
// stream 1 → "01" (main), 2 → "02" (sub).
create: (c) =>
new HttpCamera(
new HikvisionCamera(
"hikvision",
c,
(ch, stream) => `/ISAPI/Streaming/channels/${ch}0${stream}/picture`,
+31 -10
View File
@@ -73,19 +73,33 @@ export interface DigestGetOptions {
readonly localAddress?: string;
}
function getOnce(
o: DigestGetOptions,
/** digestGet + a method and optional body — for ISAPI configuration writes
* (e.g. PUT /ISAPI/System/time). The digest handshake is method-aware (HA2
* hashes the method), so this generalisation is the real one, not a shortcut. */
export interface DigestRequestOptions extends DigestGetOptions {
readonly method: "GET" | "PUT" | "POST";
readonly body?: Buffer | string;
readonly contentType?: string;
}
function requestOnce(
o: DigestRequestOptions,
authHeader?: string,
): Promise<{ res: IncomingMessage; body: Buffer }> {
return new Promise((resolve, reject) => {
const payload = o.body == null ? null : Buffer.isBuffer(o.body) ? o.body : Buffer.from(o.body, "utf8");
const headers: Record<string, string> = {};
if (authHeader) headers["authorization"] = authHeader;
if (payload) {
headers["content-type"] = o.contentType ?? "application/xml";
headers["content-length"] = String(payload.length);
}
const req = httpRequest(
{
host: o.host,
port: o.port,
path: o.path,
method: "GET",
method: o.method,
timeout: o.timeoutMs,
localAddress: o.localAddress,
headers,
@@ -97,19 +111,21 @@ function getOnce(
},
);
req.on("error", reject);
req.on("timeout", () => req.destroy(new Error("digest GET timeout")));
req.on("timeout", () => req.destroy(new Error(`digest ${o.method} timeout`)));
if (payload) req.write(payload);
req.end();
});
}
/**
* GET a resource with HTTP Digest auth. Does the standard two-shot handshake:
* Request a resource with HTTP Digest auth. Does the standard two-shot handshake:
* the first request (no Authorization) draws a 401 + challenge, the second
* carries the computed response. If the server doesn't challenge (200 straight
* carries the computed response (the body is sent BOTH times — the challenge shot
* needs the same request shape). If the server doesn't challenge (200 straight
* away, or no auth required), the first response is returned as-is.
*/
export async function digestGet(o: DigestGetOptions): Promise<DigestGetResult> {
const first = await getOnce(o);
export async function digestRequest(o: DigestRequestOptions): Promise<DigestGetResult> {
const first = await requestOnce(o);
if (first.res.statusCode !== 401) {
return {
status: first.res.statusCode ?? 0,
@@ -129,11 +145,16 @@ export async function digestGet(o: DigestGetOptions): Promise<DigestGetResult> {
}
const challenge = parseChallenge(challengeHeader);
const auth = buildAuthHeader(challenge, o.user, o.password, "GET", o.path);
const second = await getOnce(o, auth);
const auth = buildAuthHeader(challenge, o.user, o.password, o.method, o.path);
const second = await requestOnce(o, auth);
return {
status: second.res.statusCode ?? 0,
contentType: String(second.res.headers["content-type"] ?? ""),
body: second.body,
};
}
/** GET with Digest auth (the original entry point; snapshots and status reads). */
export function digestGet(o: DigestGetOptions): Promise<DigestGetResult> {
return digestRequest({ ...o, method: "GET" });
}
+3 -3
View File
@@ -5,7 +5,7 @@ import { registry } from "../registry.js";
import { dingtianDriver } from "./access-dingtian.js";
import { stubAccessDriver } from "./access-stub.js";
import { dahuaDriver, hikvisionDriver } from "./camera.js";
import { cashinoDriver } from "./printer-cashino.js";
import { escposDriver } from "./printer-generic.js";
import { rongtaDriver } from "./printer-rongta.js";
import { dingtianQrReaderDriver, tcpipReaderDriver, wiegandReaderDriver } from "./reader.js";
@@ -23,7 +23,7 @@ export function registerBuiltinDrivers(): void {
registry.register(hikvisionDriver);
registry.register(dahuaDriver);
registry.register(rongtaDriver);
registry.register(cashinoDriver);
registry.register(escposDriver);
}
export {
@@ -35,5 +35,5 @@ export {
hikvisionDriver,
dahuaDriver,
rongtaDriver,
cashinoDriver,
escposDriver,
};
@@ -10,6 +10,7 @@ import {
probeUsb,
sendRawUsb,
transportFromConfig,
writeAllUsb,
stamp,
} from "./printer-escpos.js";
@@ -183,3 +184,72 @@ describe("stamp (Albanian date format)", () => {
expect(stamp("not-a-date")).toBe("not-a-date");
});
});
describe("writeAllUsb — partial writes / EAGAIN / close-cancel (field bugs 2026-07-06/07)", () => {
// A NONBLOCK usblp fd accepts only what fits the printer's USB buffer per write,
// write() returns at URB submission, and close() KILLS the in-flight URB — so the
// loop must deliver every byte AND certify delivery before the caller may close
// (final byte written alone; its acceptance proves all prior bytes landed). A
// regular file can't reproduce any of that, so these drive a fake handle.
/** Accepts at most `cap` bytes per call; records everything accepted in order. */
function slowHandle(cap: number) {
const chunks: Buffer[] = [];
return {
chunks,
write(buffer: Buffer, offset: number, length: number) {
const n = Math.min(cap, length);
chunks.push(Buffer.from(buffer.subarray(offset, offset + n)));
return Promise.resolve({ bytesWritten: n });
},
};
}
it("delivers the WHOLE payload across many short writes (barcode + cut included)", async () => {
const payload = renderTicket({ ticketId: "12345678901", issuedAt: "2026-06-21T10:00:00.000Z" });
const h = slowHandle(100); // way smaller than the job → many partial writes
await writeAllUsb(h, payload, Date.now() + 2000, 5);
expect(Buffer.concat(h.chunks).equals(payload)).toBe(true);
});
it("the FINAL byte is written alone — the delivery certificate before close", async () => {
const payload = Buffer.from("x".repeat(5000)); // > one 4K chunk
const h = slowHandle(100_000); // accepts anything → chunking is ours, not the cap's
await writeAllUsb(h, payload, Date.now() + 2000, 5);
expect(Buffer.concat(h.chunks).equals(payload)).toBe(true);
expect(h.chunks.at(-1)!.length).toBe(1); // usblp: its acceptance proves the rest landed
});
it("retries EAGAIN (buffer full) until the kernel accepts the rest", async () => {
const payload = Buffer.from("x".repeat(300));
let calls = 0;
const accepted: Buffer[] = [];
const h = {
write(buffer: Buffer, offset: number, length: number) {
calls++;
if (calls % 2 === 0) {
const err = new Error("EAGAIN") as NodeJS.ErrnoException;
err.code = "EAGAIN";
return Promise.reject(err);
}
const n = Math.min(120, length);
accepted.push(Buffer.from(buffer.subarray(offset, offset + n)));
return Promise.resolve({ bytesWritten: n });
},
};
await writeAllUsb(h, payload, Date.now() + 2000, 5);
expect(Buffer.concat(accepted).equals(payload)).toBe(true);
});
it("a wedged printer (never accepts a byte) fails at the deadline instead of hanging", async () => {
const h = { write: () => Promise.resolve({ bytesWritten: 0 }) };
await expect(writeAllUsb(h, Buffer.from("job"), Date.now() + 60)).rejects.toThrow(/usb write timeout/);
});
it("a non-EAGAIN error surfaces immediately", async () => {
const err = new Error("EIO") as NodeJS.ErrnoException;
err.code = "EIO";
const h = { write: () => Promise.reject(err) };
await expect(writeAllUsb(h, Buffer.from("job"), Date.now() + 1000)).rejects.toThrow("EIO");
});
});
+93 -7
View File
@@ -202,6 +202,9 @@ const STR = {
tenderCard: "Kartë",
/** "Paid:" amount label (precedes the large total). */
amountLabel: "PAGUAR",
/** Merchant-validation lines: the pre-discount fee + one line per discount. */
gross: (v: string) => `Tarifa: ${v}`,
discount: (label: string, v: string) => `${label}: -${v}`,
/** 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[] => [
@@ -413,6 +416,16 @@ export function renderReceipt(data: ReceiptData): Buffer {
line(
STR.tender(data.tender === "card" ? STR.tenderCard : STR.tenderCash),
),
// Merchant validations: gross fee + one line per discount, so the customer sees
// the full gross → discounts → net story (the big amount below is the NET).
...(data.validationLines?.length
? [
line(STR.gross(money(data.grossMinor ?? data.amountMinor, data.currency))),
...data.validationLines.map((v) =>
line(STR.discount(v.label, money(v.discountMinor, data.currency))),
),
]
: []),
line(),
// The amount, large and centred.
ALIGN_CENTER,
@@ -600,10 +613,83 @@ function withTimeout<T>(p: Promise<T>, ms: number, msg: string): Promise<T> {
});
}
/** usblp accepts only what fits its kernel buffer (~8 KB) per write on a NONBLOCK fd,
* so jobs are pushed in chunks safely under that. */
const USB_WRITE_CHUNK = 4096;
/** Pause after the FINAL byte's write is accepted, before close. Its acceptance
* proves everything before it is physically in the printer (see writeAllUsb); this
* covers the one-byte URB still in flight — a single bulk packet the printer ACKs
* immediately (it just freed buffer space by ACKing the previous chunk). */
const USB_DRAIN_MS = 300;
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
/** The slice of FileHandle the USB write loop needs (injectable for tests — a real
* regular file can't reproduce the char device's partial writes / EAGAIN). */
export interface UsbWriteHandle {
write(buffer: Buffer, offset: number, length: number): Promise<{ bytesWritten: number }>;
}
/**
* Push the WHOLE payload through a non-blocking usblp fd AND ensure the printer has
* physically received it before the caller may close. TWO field-verified truncation
* modes on the ICS XP-K200L (same symptom: text head prints, barcode/feed/CUT tail
* lost; TCP fine):
*
* 1. SHORT WRITES (2026-07-06): a single fire-and-forget write() only delivers what
* the kernel accepts. Fix: chunked loop, retry EAGAIN, until all bytes accepted.
* 2. CLOSE CANCELS THE LAST TRANSFER (2026-07-07, lab bench): per usblp.c, write()
* returns at URB *submission*, only ONE write URB is in flight at a time, and
* usblp_release() (our close) KILLS in-flight URBs. The printer consumes bulk
* data at PRINT speed (tiny internal buffer), so closing right after the last
* accepted write cancels the still-transferring tail — which is exactly where
* the feed + GS V cut live ("have to press the feed button to see the text").
*
* The delivery guarantee follows from usblp's one-URB rule: ACCEPTANCE OF WRITE N
* PROVES WRITE N−1 FULLY COMPLETED (the driver EAGAINs until the previous URB's
* completion). So the payload is pushed as chunks, then its FINAL BYTE alone: when
* that 1-byte write is accepted, every byte before it is physically in the printer.
* A short drain pause then covers the lone final-byte URB (one bulk packet), and
* close is safe. `drainMs` is parameterised only for tests.
*/
export async function writeAllUsb(
handle: UsbWriteHandle,
payload: Buffer,
deadlineMs: number,
drainMs: number = USB_DRAIN_MS,
): Promise<void> {
if (payload.length === 0) return;
const lastByteAt = payload.length - 1;
let off = 0;
while (off < payload.length) {
if (Date.now() > deadlineMs) {
throw new Error(`usb write timeout (${off}/${payload.length} bytes accepted)`);
}
try {
// Never let the final byte ride a bigger chunk: it is written ALONE so its
// acceptance certifies delivery of everything before it (see doc above).
const len = off === lastByteAt ? 1 : Math.min(USB_WRITE_CHUNK, lastByteAt - off);
const { bytesWritten } = await handle.write(payload, off, len);
off += bytesWritten;
if (bytesWritten === 0) await delay(5); // buffer full, no error — breathe
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "EAGAIN") {
await delay(10); // printer draining its buffer — retry until the deadline
} else {
throw err;
}
}
}
// All bytes accepted; only the 1-byte final URB can still be in flight. Give it a
// moment to land before the caller closes (close would cancel it).
await delay(drainMs);
}
/** Write an ESC/POS payload to a USB-lp character device (e.g. /dev/usb/lp0). usblp
* is a RAW character device: a single open + write delivers the job — there is no
* FIN/half-close dance (that was a TCP concern, where an early destroy() could
* truncate the stream). We always close the handle (even on a failed write). */
* is a RAW character device — no FIN/half-close dance (that was a TCP concern) —
* but delivery must go through the chunked loop above (see its doc for why). We
* always close the handle (even on a failed write). */
export async function sendRawUsb(
devicePath: string,
payload: Buffer,
@@ -615,7 +701,7 @@ export async function sendRawUsb(
"usb open timeout",
);
try {
await withTimeout(handle.write(payload), timeoutMs, "usb write timeout");
await writeAllUsb(handle, payload, Date.now() + timeoutMs);
} finally {
await handle.close();
}
@@ -701,8 +787,8 @@ export const transportField: ConfigField = {
required: true,
default: "tcp-ip",
options: [
{ value: "tcp-ip", label: "Network (raw TCP, port 9100)" },
{ value: "usb", label: "USB (local /dev/usb/lp0)" },
{ value: "tcp-ip", label: "Network (raw TCP)" },
{ value: "usb", label: "USB (local printer)" },
],
help: "USB drives a printer plugged into the appliance (usblp); Network drives one on the isolated device VLAN.",
};
@@ -714,5 +800,5 @@ export const devicePathField: ConfigField = {
type: "string",
required: false,
default: "/dev/usb/lp0",
help: "Character device for a USB printer (usblp), e.g. /dev/usb/lp0. Only used when Connection is USB.",
help: "usblp character device (/dev/usb/lpN). The setup UI lists the printers actually present; the kernel numbers them (lp0, lp1, …) by plug/boot order. Only used when Connection is USB.",
};
@@ -2,19 +2,20 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { cashinoDriver } from "./printer-cashino.js";
import { escposDriver } from "./printer-generic.js";
import { renderTicket } from "./printer-escpos.js";
// End-to-end transport routing through the real driver: a USB-configured Cashino must
// End-to-end transport routing through the real driver: a USB-configured generic
// ESC/POS printer (Cashino / ICS XP-K200L family) must
// resolve to the char-device transport and write the SAME ESC/POS bytes the TCP path
// would. (The TCP path is exercised by the routing/escpos suites and on hardware.)
describe("cashinoDriver — USB transport", () => {
describe("escposDriver (generic ESC/POS) — USB transport", () => {
let dir: string;
let devicePath: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "cashino-usb-"));
dir = mkdtempSync(join(tmpdir(), "escpos-usb-"));
devicePath = join(dir, "lp0");
// Stand in for an enumerated usblp node (the kernel creates it; we only open it).
writeFileSync(devicePath, "");
@@ -24,7 +25,7 @@ describe("cashinoDriver — USB transport", () => {
});
it("prints a ticket to the configured USB device path", async () => {
const printer = cashinoDriver.create({ transport: "usb", devicePath, timeoutMs: 1000 });
const printer = escposDriver.create({ transport: "usb", devicePath, timeoutMs: 1000 });
const data = { ticketId: "12345678901", issuedAt: "2026-06-21T10:00:00.000Z" };
await printer.printTicket(data);
const written = readFileSync(devicePath);
@@ -32,10 +33,10 @@ describe("cashinoDriver — USB transport", () => {
});
it("healthCheck reports ready when the node exists, offline when it doesn't", async () => {
const present = cashinoDriver.create({ transport: "usb", devicePath, timeoutMs: 1000 });
const present = escposDriver.create({ transport: "usb", devicePath, timeoutMs: 1000 });
expect((await present.healthCheck()).status).toBe("ready");
// An absent device node (printer unplugged / not enumerated) → offline.
const absent = cashinoDriver.create({
const absent = escposDriver.create({
transport: "usb",
devicePath: join(dir, "absent-lp0"),
timeoutMs: 1000,
@@ -44,7 +45,7 @@ describe("cashinoDriver — USB transport", () => {
});
it("advertises both transports", () => {
expect(cashinoDriver.transports).toContain("usb");
expect(cashinoDriver.transports).toContain("tcp-ip");
expect(escposDriver.transports).toContain("usb");
expect(escposDriver.transports).toContain("tcp-ip");
});
});
@@ -23,25 +23,26 @@ import {
type Transport,
} from "./printer-escpos.js";
// Cashino 80mm thermal printer driver (network OR USB). 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,
// over either transport. 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.
// GENERIC ESC/POS 80mm thermal printer driver (network OR USB) — any clone that
// PRINTS the shared ESC/POS byte stream (see ./printer-escpos.ts) but serves no
// Rongta-style decoded status page (/prn_stat.htm). Verified fits: Cashino (the
// first unit we drove — the driver carried its name until 2026-07-06), ICS/Xprinter
// XP-K200L. Tickets, reports and subscription cards render identically to the
// Rongta, over either transport; what these clones can NOT do is report paper-out /
// cover-open / cutter faults in a form we trust.
//
// TRANSPORT: a single `config.transport` ("tcp-ip" | "usb") picks the wire; the
// driver resolves it ONCE into a Transport and every print/probe stays transport-
// blind (see transportFromConfig/sendTo/probeTo). USB writes the same bytes to a
// local usblp char device (/dev/usb/lp0); TCP writes to the raw print socket. This
// clone is the natural USB candidate — reachability-only, no status page to lose.
// clone family is the natural USB candidate — reachability-only, no page to lose.
//
// 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
// (Reusing the Rongta driver made it scrape a status page these clones don'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
@@ -49,8 +50,8 @@ import {
// (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";
class GenericEscposPrinter implements PrinterDevice {
readonly driverId = "escpos";
readonly #transport: Transport;
readonly #timeout: number;
@@ -69,7 +70,7 @@ class CashinoPrinter implements PrinterDevice {
/**
* Reachability only — a connect probe (TCP) or char-device open probe (USB) of
* the print path. The Cashino has no trustworthy status protocol, so this is the
* the print path. These clones have 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.
@@ -140,12 +141,16 @@ const rankField: ConfigField = {
help: "Higher = tried first within the same role. The booth printer also backs up the entry dispenser.",
};
export const cashinoDriver: PrinterDriver = {
id: "cashino",
export const escposDriver: PrinterDriver = {
// Renamed from id "cashino" (the first clone we drove) on 2026-07-06 — the vendor
// name was misleading in the setup UI once other clones (ICS/Xprinter XP-K200L)
// used it. Stored configs with driverId "cashino" still resolve via the registry
// alias + are rewritten by migration 0023.
id: "escpos",
category: "printer",
label: "Cashino 80mm thermal printer",
label: "Generic ESC/POS 80mm printer (Cashino, ICS/Xprinter…)",
description:
"Cashino 80mm thermal printer (ESC/POS over raw TCP port 9100, OR local USB /dev/usb/lp0). Prints like the Rongta but has no status page — monitored by reachability only (no paper/cover/cutter reporting). No auth on the print socket — isolate the VLAN.",
"Generic ESC/POS 80mm thermal printer over raw TCP (port 9100) OR local USB /dev/usb/lp0 — Cashino, ICS/Xprinter XP-K200L, and similar clones. Prints like the Rongta but has no status page — monitored by reachability only (no paper/cover/cutter reporting). No auth on the print socket — isolate the VLAN.",
transports: ["tcp-ip", "usb"],
configFields: [
transportField,
@@ -167,5 +172,5 @@ export const cashinoDriver: PrinterDriver = {
default: 3000,
},
],
create: (c) => new CashinoPrinter(c),
create: (c) => new GenericEscposPrinter(c),
};
+1 -1
View File
@@ -18,7 +18,7 @@ export {
hikvisionDriver,
dahuaDriver,
rongtaDriver,
cashinoDriver,
escposDriver,
} from "./drivers/index.js";
export { isPrinter, type PrinterRole } from "./drivers/printer-rongta.js";
// Albanian human date/time for printed slips (receipts, tickets, shift Z-report),
+29
View File
@@ -194,6 +194,30 @@ export function isCamera(device: Device): device is Device & CameraDevice {
return typeof (device as Partial<CameraDevice>).captureSnapshot === "function";
}
/** Outcome of a camera clock sync attempt (see ClockSyncDevice). */
export interface ClockSyncResult {
/** Camera-vs-host drift in whole seconds at check time; null = the camera's
* reply was unparseable (treated as infinite drift → sync). */
readonly driftSeconds: number | null;
/** True when the camera clock was actually set (drift exceeded the threshold). */
readonly synced: boolean;
}
/** Optional capability: a device whose clock the HOST can read + set. Hikvision
* cameras lose their clock on power cuts (no/dead RTC battery, reboot at the 1970
* epoch) and only heal when a human logs into the web UI — so the device monitor
* re-syncs them from the host clock at the offline→ready edge + a daily backstop.
* See wiki/entities/lpr-camera.md (clock sync). */
export interface ClockSyncDevice {
/** Compare the device clock to `localIso` (the site's wall-clock now, WITH utc
* offset) and set it when drift exceeds `maxDriftSec`. */
syncClock(localIso: string, maxDriftSec: number): Promise<ClockSyncResult>;
}
export function isClockSyncable(device: Device): device is Device & ClockSyncDevice {
return typeof (device as Partial<ClockSyncDevice>).syncClock === "function";
}
export interface SnapshotContext {
readonly direction: "entry" | "exit";
}
@@ -251,6 +275,11 @@ export interface ReceiptData {
readonly voucher: boolean;
/** Minutes the customer has to reach the exit after paying (voucher mode only). */
readonly graceExitMin?: number | null;
/** Merchant validations (bar/lavazh): the PRE-discount fee and the per-validation
* lines. When present, `amountMinor` is the NET actually paid and the receipt
* shows the full gross → discounts → net story. See validation-discounts.md. */
readonly grossMinor?: number | null;
readonly validationLines?: readonly { label: string; discountMinor: number }[];
readonly header?: TicketHeader;
}
+10 -2
View File
@@ -97,6 +97,14 @@ export function isDiscoverable(
return typeof (driver as Partial<DiscoverableDriver>).discover === "function";
}
/** Renamed driver ids: what a STORED config may still say → the current id. Kept
* tiny + permanent so old DB rows, exports, and backups resolve across renames
* (migration 0023 rewrites live rows, but a restored old backup may reintroduce
* the historical id). */
const DRIVER_ID_ALIASES: Record<string, string> = {
cashino: "escpos", // renamed 2026-07-06 — it was always the generic ESC/POS driver
};
class DeviceRegistry {
readonly #drivers = new Map<string, DeviceDriver>();
@@ -114,12 +122,12 @@ class DeviceRegistry {
}
get(id: string): DeviceDriver | undefined {
return this.#drivers.get(id);
return this.#drivers.get(DRIVER_ID_ALIASES[id] ?? id);
}
/** Validate config against a driver's declared fields and build the adapter. */
create(id: string, config: DeviceConfig): Device {
const driver = this.#drivers.get(id);
const driver = this.get(id);
if (!driver) throw new Error(`unknown driver: ${id}`);
for (const field of driver.configFields) {
if (field.required && config[field.key] === undefined) {
+353 -25
View File
@@ -20,6 +20,7 @@ export const RESOURCES = [
"tariff", // read / publish a new version
"subscription", // the subscription registry
"site", // site_config + device setup/assign
"validation", // merchant validations: apply a discount to a session (bar/lavazh)
"device", // device status / printers / snapshots / catalog
"shift", // open/close own shift
"drawer", // record cash receipts/disbursements (operator); review them (admin)
@@ -54,6 +55,13 @@ export const PERMISSIONS: readonly Permission[] = [
"subscription:plan", // compose the plan catalog (admin-grade); selling = subscription:create
"site:read", "site:update",
// Merchant validations (bar/lavazh): create = APPLY a validation to a session (the
// merchant user's one permission — guarded further by the program↔user binding, so a
// bar user can never apply the lavazh program) + void their OWN unused validation;
// read = see applied validations (reports/history). Program COMPOSITION needs no new
// permission — it lives on /setup/site behind site:update. See
// wiki/concepts/validation-discounts.md.
"validation:create", "validation:read",
"device:read",
"shift:read", "shift:create", "shift:cash",
// Drawer cash movements: create (operator RECORDS a receipt/disbursement — freely, no
@@ -272,6 +280,14 @@ export type LedgerEventType =
// the admin is NOT the adversary, but weakening an anti-fraud gate must still be
// attributed + auditable). See wiki/concepts/entry-presence-bypass.md.
| "config_change"
// A merchant validation applied to (or voided from) a transient session: the bar/
// lavazh user scanned the customer's ticket, so the booth settlement discounts the
// fee. Payload carries the RESOLVED values (programId, label, mode, minutes/
// amountMinor/percent) — reproducible even if the program config later changes —
// plus `operator` (the merchant username). A payload with `refId` set is a VOID of
// the referenced validation event (append-only correction, mirrors cash_review).
// See wiki/concepts/validation-discounts.md.
| "validation"
| "anomaly";
/** How money was tendered (for payment events + the shift Z-report). */
@@ -291,9 +307,25 @@ export interface LedgerPayload {
readonly tender?: Tender;
/** payment: which tariff_version priced it (reproducible repricing). */
readonly tariffVersionId?: string;
/** payment: gross/discount/net split when a validation applied. */
/** payment: gross/discount/net split when a validation applied. `amountMinor` is the
* NET collected; grossMinor the pre-discount fee; discountMinor what validations took
* off. `validationIds` = the validation event ids this payment CONSUMED (so an
* overstay's fresh period never re-applies them). */
readonly grossMinor?: number;
readonly discountMinor?: number;
readonly validationIds?: string[];
/** payment: the per-validation receipt lines as settled (label + amount taken off) —
* stamped so the printed receipt reproduces without re-deriving the fold. */
readonly validationLines?: { programId: string; label: string; mode: string; discountMinor: number }[];
/** validation: which program (bar/lavazh) + its receipt label, frozen at apply time. */
readonly programId?: string;
readonly programLabel?: string;
/** validation: resolved values by mode — timeCredit's free minutes / percent off.
* A fixed amount rides the shared `amountMinor`. */
readonly minutes?: number;
readonly percent?: number;
/** validation / cash vouchers: the username of the user who recorded it. */
readonly operator?: string;
/** FX-ready, deferred: rate applied (null/absent now). See open-questions #8. */
readonly fxRate?: number | null;
/** void / anomaly / override: a human-readable English sentence, signed as the
@@ -326,7 +358,8 @@ export interface LedgerPayload {
* cash_review event, so new movements do NOT carry this. Kept so historical events
* still verify + display. See wiki/concepts/shift.md. */
readonly authorizedBy?: string;
/** cash_review: the id of the cash_in/cash_out event this review decides on. */
/** cash_review: the id of the cash_in/cash_out event this review decides on.
* validation: set = this event VOIDS the referenced validation event. */
readonly refId?: string;
/** cash_review: the admin's decision on the referenced movement. A FLAG only —
* neither value moves cash or touches the drawer balance. */
@@ -606,8 +639,9 @@ export interface TariffWindow {
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. */
/** A V2 pricing card: a per-increment flat rate, a block ladder, a stepped table
* (defaultCard only), or a whole-window package (windowed cards only). The pricing
* fields are mutually exclusive — exactly one. The defaultCard has no window. */
export interface TariffCard {
/** Human label (also the final, deterministic precedence tiebreak). */
readonly name: string;
@@ -617,13 +651,21 @@ export interface TariffCard {
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`/`steps`). */
/** Flat price PER BILLING INCREMENT (an hourly flat rate at increment 60) —
* mutually exclusive with the other pricing fields. NOT a whole-stay price;
* for "one total for the whole window" use `packageMinor`. */
readonly flatMinor?: number;
/** Marginal block ladder (mutually exclusive with `flatMinor`/`steps`); last open-ended. */
/** Marginal block ladder (mutually exclusive with the other pricing fields); last open-ended. */
readonly blocks?: readonly TariffBlock[];
/** STEPPED ("up-to") total-by-duration table (mutually exclusive with `flatMinor`/
* `blocks`). The top tier's total is this card's per-day price. */
/** STEPPED ("up-to") total-by-duration table (mutually exclusive with the other
* pricing fields; defaultCard only). The top tier's total is the per-day price. */
readonly steps?: readonly TariffStep[];
/** WINDOW PACKAGE (windowed cards only, 2026-07-05): ONE total charged per
* contiguous occurrence of this card winning increments — e.g. "any presence in
* the 20:00–07:00 window = 400, leave earlier and it's still 400". Any touch of
* the window pays the full package; a stay spanning two nights pays it twice
* (once per occurrence). Mutually exclusive with the other pricing fields. */
readonly packageMinor?: number;
/** 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;
@@ -693,6 +735,58 @@ export interface SessionPayment {
readonly graceExitMin: number | null;
}
// --- Merchant validations (bar / lavazh discounts) ---------------------------
// An in-park merchant validates a customer's ticket so the BOOTH settlement charges
// less or nothing. The program is admin-composed MUTABLE master data (no versioning:
// the applied validation is a signed ledger event carrying the RESOLVED values, so
// reproducibility never depends on the row). All money stays at the booth — the
// merchant only validates. See wiki/concepts/validation-discounts.md.
/** How a program discounts: full comp / first-N-minutes free / a fixed amount (typed
* by the merchant at scan time, capped) / a percentage off. */
export type ValidationMode = "comp" | "timeCredit" | "fixed" | "percent";
export const VALIDATION_MODES: readonly ValidationMode[] = ["comp", "timeCredit", "fixed", "percent"];
/** An admin-composed validation program (one per merchant station; `bar` and `lavazh`
* are the well-known ids the /setup/site checkboxes toggle). */
export interface ValidationProgram {
readonly id: string; // well-known slug ("bar" | "lavazh"); generic for future merchants
/** Receipt label, e.g. "Lavazh — 1 orë falas". Printed on the booth receipt line. */
readonly name: string;
readonly mode: ValidationMode;
/** timeCredit: the free minutes. */
readonly minutes: number | null;
/** percent: 1..100 off the fee. */
readonly percent: number | null;
/** fixed: cap on the amount the merchant may type at scan time (minor units). */
readonly maxAmountMinor: number | null;
/** Cap: max applications of this program per local day (null = unlimited). */
readonly maxPerDay: number | null;
readonly active: boolean;
}
/** An APPLIED validation as pricing cares about it — the RESOLVED values folded off
* the signed validation event (never the mutable program row). */
export interface SessionValidation {
/** The validation event id (payments record which ids they consumed). */
readonly eventId?: string;
readonly programId: string;
readonly label: string;
readonly mode: ValidationMode;
readonly minutes?: number; // timeCredit
readonly amountMinor?: number; // fixed
readonly percent?: number; // percent
}
/** One receipt/display line: what a validation actually saved on this settlement. */
export interface ValidationLine {
readonly programId: string;
readonly label: string;
readonly mode: ValidationMode;
/** The (positive) amount this line took off the fee. */
readonly discountMinor: number;
}
/** The full pricing outcome for a session at a moment in time — what the booth's
* `quote()` and the exit flow compute, made PURE so it can be tested or previewed
* without a real ledger. See wiki/concepts/booth-exit-flow.md (overstay pricing). */
@@ -700,8 +794,14 @@ export interface SessionPricing {
/** The window actually billed now: entry→asOf normally, or grace-expiry→asOf for an
* overstay (a paid session whose walk-back grace lapsed — a new period began). */
readonly periodStart: string;
/** Fee for [periodStart, asOf]. */
/** Amount DUE for [periodStart, asOf] — NET of any merchant validations. */
readonly amountMinor: number;
/** The pre-validation fee for the same period (= amountMinor when no validations). */
readonly grossMinor: number;
/** Total the validations took off (grossMinor − amountMinor). */
readonly discountMinor: number;
/** Per-validation receipt lines, in the canonical application order. */
readonly validationLines: ValidationLine[];
/** True when the latest payment's grace has lapsed (overstay = new period). */
readonly overstay: boolean;
/** True when paid AND still inside the walk-back window (a settled, exitable stay). */
@@ -723,6 +823,15 @@ export interface SessionPricing {
* `payments` is the session's payment history (only the LATEST matters for grace);
* pass [] for an unpaid session. The tariff version is the one frozen at entry — the
* customer keeps their rate card even across an overstay. See booth-exit-flow.md.
*
* `validations` are the UNCONSUMED merchant validations on the session (the caller
* filters out ids already recorded on a prior payment's `validationIds`, so an
* overstay's fresh period never re-applies them). Canonical application order —
* deterministic regardless of scan order: timeCredit (shifts the billed period's
* start forward, so "first hour free" is literal and windowed/stepped cards price
* the remainder correctly) → percent (of the remaining fee) → fixed amounts
* (clamped to the remainder) → comp (zeroes whatever is left). Net never goes
* below 0. See wiki/concepts/validation-discounts.md.
*/
export function priceSession(
enteredAt: string,
@@ -730,6 +839,7 @@ export function priceSession(
tariff: TariffStructure,
payments: readonly SessionPayment[] = [],
category?: string,
validations: readonly SessionValidation[] = [],
): SessionPricing {
const last = payments.length ? payments[payments.length - 1] : null;
const graceExpiryMs =
@@ -739,10 +849,50 @@ export function priceSession(
const withinGrace = graceExpiryMs != null && asOfMs <= graceExpiryMs;
const periodStart = overstay ? new Date(graceExpiryMs!).toISOString() : enteredAt;
// A settled (paid + within grace) session owes nothing more; otherwise bill the period.
const amountMinor = withinGrace ? 0 : computeFee(periodStart, asOf, tariff, category);
const grossMinor = withinGrace ? 0 : computeFee(periodStart, asOf, tariff, category);
// Fold the validations (nothing to discount on a settled session or a zero fee is
// still folded so the receipt can show "Lavazh — falas" even when gross is 0-adjacent).
const lines: ValidationLine[] = [];
let net = grossMinor;
if (!withinGrace && validations.length) {
const byMode = (m: ValidationMode) => validations.filter((v) => v.mode === m);
// 1. Time credits: bill as if the period started later (clamped at asOf). The
// marginal saving of each credit is its line amount.
let startMs = Date.parse(periodStart);
for (const v of byMode("timeCredit")) {
const minutes = v.minutes ?? 0;
const shiftedMs = Math.min(startMs + minutes * 60_000, asOfMs);
const newFee = computeFee(new Date(shiftedMs).toISOString(), asOf, tariff, category);
lines.push({ programId: v.programId, label: v.label, mode: v.mode, discountMinor: net - newFee });
startMs = shiftedMs;
net = newFee;
}
// 2. Percent of the remaining fee (floor — integer minor units).
for (const v of byMode("percent")) {
const off = Math.floor((net * Math.min(Math.max(v.percent ?? 0, 0), 100)) / 100);
lines.push({ programId: v.programId, label: v.label, mode: v.mode, discountMinor: off });
net -= off;
}
// 3. Fixed amounts, clamped to the remainder so Σ lines ≡ gross − net.
for (const v of byMode("fixed")) {
const off = Math.min(Math.max(v.amountMinor ?? 0, 0), net);
lines.push({ programId: v.programId, label: v.label, mode: v.mode, discountMinor: off });
net -= off;
}
// 4. Comp: zero whatever is left.
for (const v of byMode("comp")) {
lines.push({ programId: v.programId, label: v.label, mode: v.mode, discountMinor: net });
net = 0;
}
}
return {
periodStart,
amountMinor,
amountMinor: net,
grossMinor,
discountMinor: grossMinor - net,
validationLines: lines,
overstay,
withinGrace,
graceExpiresAt: graceExpiryMs != null ? new Date(graceExpiryMs).toISOString() : null,
@@ -755,6 +905,115 @@ export function hasSteps(s: { steps?: readonly TariffStep[] }): boolean {
return Array.isArray(s.steps) && s.steps.length > 0;
}
// --- Fee breakdown (explainability) -------------------------------------------
// One line item per priced "reason": a run of same-priced increments, a window
// package occurrence, a stepped day total, a daily-cap clamp, or the entry grace.
// Produced by the SAME walk computeFee runs (an optional trace collector inside
// computeFeeV1/V2), so Σ item amounts ≡ the fee by construction — the breakdown can
// never tell a different story than the bill. Built for the Tariff Lab's "how is
// this sum produced" view (2026-07-06). Minutes are offsets from the priced
// period's start.
export type FeeBreakdownItem =
/** The whole stay fit inside the free entry-grace window (fee 0). */
| { readonly kind: "grace"; readonly minutes: number }
/** A contiguous run of increments billed at one unit price by one card.
* `card` is the windowed card's name, or null for the base/default rate. */
| {
readonly kind: "band";
readonly card: string | null;
readonly fromMin: number;
readonly toMin: number;
readonly increments: number;
readonly unitMinor: number;
readonly amountMinor: number;
}
/** One window-package occurrence (charged once per contiguous run the card wins). */
| { readonly kind: "package"; readonly card: string; readonly fromMin: number; readonly amountMinor: number }
/** A stepped ("up-to") day total: day N used `dayMinutes`, priced by the tier at
* `uptoMin` (`repeated` = past the top tier, so the top total repeats as a cap). */
| {
readonly kind: "step";
readonly day: number;
readonly dayMinutes: number;
readonly uptoMin: number;
readonly amountMinor: number;
readonly repeated: boolean;
}
/** The daily cap clamped day N: amountMinor is the (negative) adjustment. */
| { readonly kind: "cap"; readonly day: number; readonly capMinor: number; readonly amountMinor: number };
export interface FeeBreakdown {
/** Actual stay length in whole minutes (before increment rounding). */
readonly rawMinutes: number;
/** Minutes billed after rounding UP to the increment (0 within grace). */
readonly billedMinutes: number;
readonly incrementMin: number;
readonly items: FeeBreakdownItem[];
/** Σ item amounts — always equals computeFee for the same arguments. */
readonly totalMinor: number;
}
/**
* Explain a fee: run the exact computeFee walk with a trace collector and return
* the line items plus the total. Same arguments as computeFee; the total returned
* here IS computeFee's answer (one code path, not a parallel calculation).
*/
export function explainFee(
enteredAt: string,
asOf: string,
tariff: TariffStructure,
category?: string,
): FeeBreakdown {
const items: FeeBreakdownItem[] = [];
const totalMinor = isTariffV2(tariff)
? computeFeeV2(enteredAt, asOf, tariff, category, items)
: computeFeeV1(enteredAt, asOf, tariff, items);
const ms = Date.parse(asOf) - Date.parse(enteredAt);
const rawMinutes = Number.isFinite(ms) && ms > 0 ? Math.round(ms / 60_000) : 0;
const inc = Math.max(1, tariff.incrementMin);
const inGrace = items.length === 1 && items[0]!.kind === "grace";
const billedMinutes =
inGrace || rawMinutes === 0 || ms / 60_000 <= tariff.gracePeriodEntryMin
? 0
: Math.ceil(ms / 60_000 / inc) * inc;
return { rawMinutes, billedMinutes, incrementMin: inc, items, totalMinor };
}
/** Band-merging helper for the trace: accumulate consecutive increments that share
* a (card, unit price) and flush them as one `band` item. */
class BandTracer {
#card: string | null = null;
#unit = 0;
#from = 0;
#count = 0;
constructor(private readonly items: FeeBreakdownItem[], private readonly inc: number) {}
add(card: string | null, unitMinor: number, atMin: number): void {
if (this.#count > 0 && this.#card === card && this.#unit === unitMinor) {
this.#count++;
return;
}
this.flush();
this.#card = card;
this.#unit = unitMinor;
this.#from = atMin;
this.#count = 1;
}
flush(): void {
if (this.#count === 0) return;
this.items.push({
kind: "band",
card: this.#card,
fromMin: this.#from,
toMin: this.#from + this.#count * this.inc,
increments: this.#count,
unitMinor: this.#unit,
amountMinor: this.#count * this.#unit,
});
this.#count = 0;
}
}
/**
* Total fee for ELAPSED minutes under a STEPPED tariff, per the rolling-24h-day rule.
* Pure + integer. The smallest tier whose `uptoMin ≥` the day's minutes wins (≤ /
@@ -762,7 +1021,7 @@ export function hasSteps(s: { steps?: readonly TariffStep[] }): boolean {
* FULL day (a daily-cap repeat) and price the remainder on the next day's ladder.
* `steps` need not be sorted; we sort defensively. See wiki/concepts/tariff.md.
*/
function steppedFee(minutes: number, steps: readonly TariffStep[]): number {
function steppedFee(minutes: number, steps: readonly TariffStep[], trace?: FeeBreakdownItem[]): number {
if (minutes <= 0 || steps.length === 0) return 0;
const sorted = [...steps].sort((a, b) => a.uptoMin - b.uptoMin);
const top = sorted[sorted.length - 1]!;
@@ -771,8 +1030,17 @@ function steppedFee(minutes: number, steps: readonly TariffStep[]): number {
for (let dayStart = 0; dayStart < minutes; dayStart += DAY) {
const dayMin = Math.min(DAY, minutes - dayStart); // minutes within this rolling day
// Beyond the largest tier → the whole day is the top total (per-day cap repeat).
const tier = sorted.find((s) => dayMin <= s.uptoMin) ?? top;
const found = sorted.find((s) => dayMin <= s.uptoMin);
const tier = found ?? top;
total += tier.totalMinor;
trace?.push({
kind: "step",
day: dayStart / DAY + 1,
dayMinutes: dayMin,
uptoMin: tier.uptoMin,
amountMinor: tier.totalMinor,
repeated: found == null,
});
}
return total;
}
@@ -782,30 +1050,50 @@ function steppedFee(minutes: number, steps: readonly TariffStep[]): number {
* identically. Do not "unify" this into the V2 path: a rounding divergence would
* corrupt repricing of already-signed sessions. A `steps` table (when present)
* REPLACES the ladder via {@link steppedFee}. */
function computeFeeV1(enteredAt: string, asOf: string, tariff: TariffStructureV1): number {
function computeFeeV1(
enteredAt: string,
asOf: string,
tariff: TariffStructureV1,
trace?: FeeBreakdownItem[],
): number {
const ms = Date.parse(asOf) - Date.parse(enteredAt);
if (!Number.isFinite(ms) || ms <= 0) return 0;
const rawMinutes = ms / 60_000;
// Grace uses the RAW duration (a 10-min stay is free even if the increment is
// 60 min — otherwise rounding-up would defeat the grace window).
if (rawMinutes <= tariff.gracePeriodEntryMin) return 0;
if (rawMinutes <= tariff.gracePeriodEntryMin) {
trace?.push({ kind: "grace", minutes: Math.round(rawMinutes) });
return 0;
}
const inc = Math.max(1, tariff.incrementMin);
const minutes = Math.ceil(rawMinutes / inc) * inc; // round UP to the increment
// STEPPED pricing: a total-by-duration table replaces the marginal ladder.
if (hasSteps(tariff)) return steppedFee(minutes, tariff.steps!);
if (hasSteps(tariff)) return steppedFee(minutes, tariff.steps!, trace);
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;
const bands = trace ? new BandTracer(trace, inc) : null;
// The block ladder RESETS each rolling-24h day: `within` is minutes elapsed
// WITHIN this day, so day 2 starts at the first block again (decision 2026-06-15).
for (let within = 0; segStart + within < segEnd; within += inc) {
segFee += rateAt(tariff.blocks, within);
const unit = rateAt(tariff.blocks, within);
segFee += unit;
bands?.add(null, unit, segStart + within);
}
bands?.flush();
if (tariff.dailyCapMinor != null && segFee > tariff.dailyCapMinor) {
trace?.push({
kind: "cap",
day: segStart / DAY + 1,
capMinor: tariff.dailyCapMinor,
amountMinor: tariff.dailyCapMinor - segFee,
});
segFee = tariff.dailyCapMinor;
}
if (tariff.dailyCapMinor != null) segFee = Math.min(segFee, tariff.dailyCapMinor);
total += segFee;
}
return total;
@@ -828,12 +1116,16 @@ function computeFeeV2(
asOf: string,
tariff: TariffStructureV2,
category?: string,
trace?: FeeBreakdownItem[],
): 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)
if (rawMinutes <= tariff.gracePeriodEntryMin) {
trace?.push({ kind: "grace", minutes: Math.round(rawMinutes) });
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)
@@ -853,23 +1145,51 @@ function computeFeeV2(
// defaultCard is stepped we price the WHOLE stay by the stepped day rule and ignore
// windowed cards (they have nothing to override at the increment level). This is the
// only sound place for steps in V2. See wiki/concepts/tariff.md.
if (hasSteps(tariff.defaultCard)) return steppedFee(minutes, tariff.defaultCard.steps!);
if (hasSteps(tariff.defaultCard)) return steppedFee(minutes, tariff.defaultCard.steps!, trace);
// Trace labels: the defaultCard reads as the base rate (null), a windowed card by
// its name.
const traceName = (card: TariffCard): string | null => (card === tariff.defaultCard ? null : card.name);
let total = 0;
// WINDOW-PACKAGE tracking (2026-07-05): a `packageMinor` card charges ONE total per
// contiguous run of increments it wins (an "occurrence" — e.g. one night), however
// little of the window the car actually used. The tracker survives the day-segment
// loop so a night run crossing the rolling-24h boundary charges once, not twice;
// the charge lands in the segment where the occurrence starts (that day's cap
// applies to it). A stay touching the window on two different nights = two
// occurrences = two charges.
let prevWinner: TariffCard | null = null;
for (let segStart = 0; segStart < minutes; segStart += DAY) {
const segEnd = Math.min(segStart + DAY, minutes);
let segFee = 0;
const bands = trace ? new BandTracer(trace, inc) : null;
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) {
if (card.packageMinor != null) {
// First increment of a new occurrence pays the package; the rest ride free.
if (prevWinner !== card) {
segFee += card.packageMinor;
bands?.flush();
trace?.push({ kind: "package", card: card.name, fromMin: within, amountMinor: card.packageMinor });
}
} else if (card.flatMinor != null) {
segFee += card.flatMinor;
bands?.add(traceName(card), card.flatMinor, within);
} else {
// Ladder position = minutes into THIS rolling-24h day (resets each day, V1 rule).
segFee += rateAt(card.blocks ?? [], within - segStart);
const unit = rateAt(card.blocks ?? [], within - segStart);
segFee += unit;
bands?.add(traceName(card), unit, within);
}
prevWinner = card;
}
bands?.flush();
if (dayCap != null && segFee > dayCap) {
trace?.push({ kind: "cap", day: segStart / DAY + 1, capMinor: dayCap, amountMinor: dayCap - segFee });
segFee = dayCap;
}
if (dayCap != null) segFee = Math.min(segFee, dayCap);
total += segFee;
}
return total;
@@ -983,9 +1303,17 @@ function validateCard(c: Partial<TariffCard> | undefined, label: string, isDefau
const hasFlat = c.flatMinor != null;
const hasBlocks = c.blocks != null;
const hasStepTable = c.steps != null;
const modes = [hasFlat, hasBlocks, hasStepTable].filter(Boolean).length;
const hasPackage = c.packageMinor != null;
const modes = [hasFlat, hasBlocks, hasStepTable, hasPackage].filter(Boolean).length;
if (modes !== 1) {
errs.push(`${label} must set exactly one of flatMinor, blocks, or steps`);
errs.push(`${label} must set exactly one of flatMinor, blocks, steps, or packageMinor`);
} else if (hasPackage) {
// A whole-window package needs a window to be an occurrence of — meaningless on
// the always-active defaultCard (a base "one price per stay/day" is a 1-row
// stepped table there). See wiki/concepts/tariff-time-tiers.md.
if (isDefault) errs.push(`${label}: packageMinor (whole-window package) is only allowed on a windowed card`);
nonNegInt(c.packageMinor, `${label}.packageMinor`, errs);
if (c.dailyCapMinor != null) errs.push(`${label}: dailyCapMinor does not apply to a window package (the package IS the window's total)`);
} 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`);

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