Commit Graph

186 Commits

Author SHA1 Message Date
julian 420542ce10 fix(setup): add Dingtian relay-password field + secure secret re-merge on test
The relay control password (relay_pw) was read by the driver but had NO form field,
so Test connection sent it as 0 → the device ignored the probe → a controller showed
"offline" even though it pinged. Add a "Relay control password" config field (secret;
blank keeps the stored value).

Because relayPassword is redacted from the client, the edit form can't resend it — so
the test endpoint now re-merges the stored secret by device id (mirroring save). It is
re-merged ONLY when the submitted config addresses the SAME device: matching driverId
and every connection-identity field it sets (host/port/binaryPort/httpPort/serial). A
redirected host/port or mismatched driver yields NO secret, so a probe can't exfiltrate
the password to an attacker host (the booth operator is the threat-model adversary).
testDevice() now passes the device id; setup-secrets.test.ts covers the identity guard.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-24 19:03:53 +02:00
julian 2915d141aa feat(devices): radar presence input + button-light output on the controller
Model the entry button (I1) and a Hikvision radar (I2) as named children of the
access controller, and drive the button's 12V lamp on a spare relay.

- Radar = the existing relays[].presenceInput one-car-one-ticket gate, now labelled
  presenceKind: loop|radar. A radar may idle opposite the button, so add a per-input
  active-level override: relays[].presenceActiveLow -> driver inputActiveLow set,
  inverting just that terminal (pure helper inputActive()). The Dingtian has one
  board-wide resting level otherwise.
- AuxOutputDevice.setAux(channel,on) capability on the device interface (Dingtian
  latch) so business logic drives a NON-barrier lamp through the interface. Barriers
  still only pulseOpen — barrier-not-a-door preserved.
- ButtonLightController: subscribes to the radar input edge + the camera lane status
  and drives a 3-state lamp — radar+car=solid, radar-only=blink (~1Hz), else off.
  Fails OFF on host loss/error; de-duped. A radar detection never opens a barrier on
  its own (advisory; threat model).
- SetupWizard: presence kind + active-low + a button-light relay picker; sq+en i18n.

Tests: button-light.test.ts (truth table + blink + fail-OFF + de-dupe),
access-dingtian.test.ts (active-level inversion). Workspace build+lint+test green
(158 server tests). Wiki: hikvision-radar, button-light-indicator + updates.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-24 11:45:22 +02:00
julian 8129b63a8c feat(profile): self-service name/email/password + desktop installers in CI
Build desktop / desktop (push) Failing after 5m2s
Build & push images / images (push) Successful in 3m1s
CI / check (push) Successful in 40s
Self-service profile: any signed-in user edits their OWN fullName/email and
changes their OWN password (proving the current one), without any user:*
permission. New routes PUT /api/auth/profile + /api/auth/password act only on
req.user.sub (cannot touch username/role), CSRF-guarded; SPA screen at /profile
reachable from the header username chip. email added to the session view +
SessionUser. 7 tests (routes/profile.test.ts); 148 server tests green.

Desktop in CI: new .gitea/workflows/build-desktop.yml builds .deb + .AppImage
on every push to dev/main and uploads them as unsigned workflow artifacts
(per-commit test build). Signed/versioned release stays on release.yml (tag v*).

Wiki: local-jwt-auth (self-service routes), desktop-shell-tauri (two-workflow CI
split), log entry.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-24 10:15:34 +02:00
julian 77b2acb1ca fix(docker): SPA must use same-origin API base in the server image (CORS)
apps/web/.env.production sets VITE_API_BASE=http://127.0.0.1:3000 for the TAURI
desktop build (which loads from tauri://localhost and needs an absolute backend
origin). But Vite auto-loads .env.production for ANY `vite build`, so the server
image baked 127.0.0.1:3000 into the browser bundle — loading the UI from a real
host (e.g. http://parksystems.msai.al) then made the browser call 127.0.0.1:3000
cross-origin and fail the Same-Origin Policy on /api/auth/login.

Fix: the server Dockerfile writes apps/web/.env.production.local with an empty
VITE_API_BASE before the web build (.local has higher Vite precedence), so the SPA
served by Fastify stays relative/same-origin (/api/...). The desktop build is
unaffected (it doesn't use this Dockerfile). Verified: 127.0.0.1:3000 no longer in
the built bundle; /api/auth/login is relative.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-23 18:58:53 +02:00
julian 8155ff456b feat(deploy): Docker images for server (API+SPA) and vision + branch-aware build pipeline
CI / check (push) Successful in 35s
Build & push images / images (push) Failing after 17s
Containerize the two non-desktop apps for the booth appliance. The desktop app stays
on its own tag-only release.yml.

- apps/server/Dockerfile: multi-stage node:22-alpine. `pnpm deploy --legacy --prod`
  (NOT prune — the monorepo native better-sqlite3 won't resolve under a root prune)
  yields a self-contained bundle; build stage adds node-gyp toolchain, runtime adds
  libstdc++; non-root, healthcheck. Migrates the mounted DB on boot via a drizzle-kit-
  free runtime migrator (packages/db/scripts/migrate-runtime.mjs) — drizzle-kit is a
  devDep, pruned from prod.
- apps/server/src/static-spa.ts: Fastify serves the built React SPA (one container
  serves API + UI). GET-only fallback to index.html, excludes /api + /health so it never
  shadows the backend; a no-op in dev (no dist). Registered last in server.ts.
- apps/vision/Dockerfile: uv base, --extra alpr, model weights PRE-WARMED into the image
  as the runtime user so fast_alpr boots offline (0 downloads at runtime). Engine env-
  selected (VISION_RECOGNIZER stub|fast_alpr).
- Branch-aware: docker-compose.yml (base) + .dev.yml (build local, stub, ports) +
  .prod.yml (pull pinned, fast_alpr, vision internal, restart always); REGISTRY/TAG from
  env so a branch deploy pulls that branch's image.
- .gitea/workflows/build-images.yml: on push to dev/main, run the full turbo build+lint+
  test gate, then buildx push both images to git.infra.msai.al/mca/parking_solution with
  branch + branch-<sha> tags (registry cache; optional Komodo webhook behind KOMODO_ENABLED).
- .dockerignore excludes **/parking.sqlite* so the signed ledger is NEVER baked.

Verified locally (Docker 29): server image migrates + serves API+SPA (/health 200, /
+ /booth HTML, /api/nope JSON 404, no sqlite outside /data); vision image boots fast_alpr
with 0 runtime downloads; compose stack healthy with server→vision over the private network.

Wiki: new container-deployment.md; vision-service-packaging open Qs resolved; index + log.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-23 15:07:52 +02:00
julian 8a437d0c4b feat(booth): cancel wrongly-printed ticket (signed void) + refused-vs-anomaly display; fix CI uv
CI / check (push) Failing after 56s
Cancel a misprinted/test/wrong-vehicle ticket via a SIGNED `void` event — the
vehicle_entry is never edited/deleted (append-only). VoidFlow appends void{
voidedEntryRef, voidReason, operator, reasonCode:"void.ticketCancelled" }; route
POST /api/tickets/void gated event:void + open shift; reason REQUIRED. Refuses a
subscription / already-exited / already-voided / paid ticket (refund out of scope).
The void folds the session CLOSED everywhere it's counted — occupancy (count +
reserved spots), pay-station (lookup/activeSessions), exit-flow (#sessionFor), and
reports (excluded from entries) — so a voided car stops occupying a spot, can't be
paid/exited, and doesn't inflate "cars entered". No barrier action. Booth UI: a
"Cancel ticket" action in the pay/exit lookup modal (transient + unpaid + open;
gated on event:void) with a preset-or-free reason prompt.

Reclassify the Live feed: refused-action events (exitRefused/entryRefused/
permitRefused — e.g. a double card-scan, at-capacity subscriber, exit on a closed
session) are benign warnings, not red anomalies. event-detail.tsx now shows them as
amber REFUZUAR/REFUSED, reserving red ANOMALI for genuine red-flags. Display-only —
no ledger change, so historical events reclassify too.

CI: install uv + sync vision deps before the Turbo run. @parking/vision's lint/
typecheck/test shell to `uv run …`, but CI set up only Node+pnpm, so `uv run ruff`
failed ("uv not found") and broke the whole Turbo run. The Python checks pass once
uv provisions the toolchain.

- new: void-flow.ts (+ tests, 8) ; occupancy void-fold test
- shared: reason code void.ticketCancelled ; both web catalogs (sq/en parity)
- wiki: parking-session (ticket-void folds + guards, refused/anomaly split), log

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 20:13:21 +02:00
julian 65328b8c11 feat(anpr): subscriber-entry bridge + admin disable toggle
CI / check (push) Failing after 15s
Wire the lane camera's vehicle event into the gated subscription flow: on a
vehicle/active push from an opt-in (config.anpr) camera, AnprBridge pulls a fresh
snapshot, runs ANPR, applies a stricter entry confidence floor, debounces, and —
matching the plate to a subscription BEFORE emitting — emits a kind:"plate" read.
The existing ReadDispatcher -> SubscriptionFlow then signs the entry/exit and opens
the barrier. A plate is never the sole authority: it routes through the same gate
(active/window/blocklist/car-count) as any credential. Fail-soft, fire-and-forget,
subscriber-only by construction. Field-verified end to end (plate AA504LX opened the
entry barrier and appended a signed vehicle_entry).

Add an admin master switch (site_config.anpr_entry_enabled, default ON) in Site
Settings that disables ONLY the barrier-driving bridge; advisory snapshot-ANPR and
lane busy/free are unaffected. Read live per event, so toggling takes effect with no
restart. Migration 0013 (additive ALTER ADD COLUMN, default 1).

- New: apps/server/src/anpr-entry.ts (AnprBridge) + tests (9)
- hikvision-alarm.ts hands vehicle detections to the bridge (fire-and-forget) + wiring tests (3)
- server.ts reorders the read flows above the hik-alarm registration
- snapshot.ts exports buildCamera for reuse
- env: VISION_ENTRY_MIN_CONFIDENCE (0.85), ANPR_DEBOUNCE_MS (12000)
- site route + SiteSettings checkbox + i18n (sq/en parity)
- wiki: lane-presence-and-anpr-entry / lpr-camera / index / log -> BUILT

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 19:49:18 +02:00
julian a2bdf99db2 fix(lane-status): TTL 5s -> 30s after measuring the real re-fire pattern
Controlled in/out test on the camera: the `active` re-fire rate is
MOVEMENT-driven, not steady — ~1-3s apart while the car moves, but up to
~15-25s when it sits MOTIONLESS in the zone. A 5s TTL would flicker a
parked car free; the TTL must exceed the still-car gap. The camera has
~no dwell lag (goes silent within ~1s of the car leaving — measured: last
event 16:15:17 vs car-left ~16:15:30), so 30s keeps a motionless car busy
while clearing promptly after departure. This also confirms vision-based
tracking isn't warranted: the camera's leave signal is already tight.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 18:16:42 +02:00
julian 89542d4ab6 fix(lane-status): drop busy TTL 90s -> 5s (camera re-fires ~1s)
Measured the real re-fire rate on the camera: while a vehicle is in the
zone it POSTs `active` about every ~1 second (not the ~30-80s I'd guessed).
The camera sends no leave signal, so "free" is timeout-driven — but with a
~1s re-fire, 90s made the lane stay red for a minute and a half after the
car left. 5s of silence reliably means the car is gone; the light now
clears within seconds. Still override-able via LANE_BUSY_TTL_MS.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 17:58:25 +02:00
julian e0b9442acc feat(booth): live lane busy/free barrier lights from camera vehicle detection
A Hikvision vehicle detection (eventType=VMD, targetType=vehicle) on a
camera bound to entry/exit now marks that lane "busy" and shows it as a
barrier light beside the scan input on the booth (green=free, red=busy).
Advisory only — it gates nothing (never blocks a ticket or opens a barrier).

- Parse eventState (active/inactive) from the Hik payload.
- LaneStatus tracker: a vehicle `active` event marks the camera's bound lane
  busy + arms an auto-clear timer. This camera class sends no leave/`inactive`
  signal, so "free" is timeout-driven (LANE_BUSY_TTL_MS, default 90s; the
  camera re-fires `active` while a car sits there, refreshing the timer). A
  "both"-direction camera marks both lanes.
- Push lane-status over the existing booth WS (+ in the hello snapshot);
  live-store holds { entry, exit }; two BarrierLight icons render it.
- i18n booth.laneEntry/laneExit (sq + en).

Tests: lane-status.test.ts (7 — busy/free, TTL auto-clear, timer re-arm,
no re-emit while busy, both/exit direction, unknown device). server 120/120;
web + server build/lint green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 17:42:54 +02:00
julian 6f4e390c05 feat(dev): bind Vite to 0.0.0.0 for LAN access (phone over wifi)
Vite had no host set (localhost only). Bind 0.0.0.0 so the dev booth UI is
reachable from other LAN devices at http://<host-lan-ip>:5173. The SPA
already uses relative paths + the page origin for API and the live WS, so
no app code changes — but loading from a non-localhost origin means the
/api/ws handshake's Origin is the LAN address, which the backend's
WS_ALLOWED_ORIGINS must include (documented in .env.example; the host's own
.env is gitignored).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 17:34:35 +02:00
julian b7300ec080 fix(hik-alarm): listen on all methods + skippable source-IP guard (WSL)
Diagnosed why no camera push ever landed: (1) the route only registered
POST/GET, so a probe with another method got a generic 404 the camera
reads as "service available" while our handler never ran; (2) more
fundamentally, WSL mirrored mode REWRITES the inbound source IP to the
host's own address (10.0.10.203), so the camera's real IP (10.0.10.12)
never survives and the source-IP guard rejected every push as a mismatch.

- Register the event route on POST/GET/PUT/PATCH/DELETE/OPTIONS (HEAD comes
  with GET) so ANYTHING hitting the path reaches the handler and is recorded.
- Log + store the HTTP method of each hit; log every hit on arrival, before
  any guard, so even a rejected probe is visible immediately.
- Add per-device skipSourceIpCheck (a Setup checkbox) to bypass the
  source-IP guard where the network rewrites the source (WSL). Digest auth +
  the signed ledger remain the real guards.

Tests: hik-alarm 10 (skip-IP accept + method capture). server green;
web build green (new checkbox renderer + this field).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 11:02:17 +02:00
julian 461275521d fix(setup): render boolean config fields as a checkbox (not a text box)
The generic config-field loop had no boolean branch, so a type:"boolean"
field (e.g. the camera's alarmPushEnabled) fell through to a TEXT input and
saved the STRING "true" instead of a real boolean. Downstream checks use
=== true, so the feature read as disabled even when the admin ticked it.

- Web: render type:"boolean" config fields as a real checkbox; store/merge
  a true/false boolean (and persist false on edit so toggling off sticks);
  normalize a legacy string "true"/"false" on load.
- Server: isOn() coerces the flag when reading config (accepts true/"true"/
  1/"yes"/"on") so an existing row saved as the string "true" still works
  without a re-save, and no other boolean field hits the same trap.

Tests: hik-alarm accepts string "true" for alarmPushEnabled. server
112/112; web typecheck + build green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 10:39:10 +02:00
julian 3db8f517d3 feat(hik-alarm): record rejected pushes + a read endpoint to see arrivals
Debugging "is the camera event coming or not?" was painful: a rejected
push only logged a warning and recorded nothing, so "no event" was
ambiguous (never sent vs sent-and-refused), and the only durable record
was an unreadable device_events row.

- Record EVERY push, accepted or rejected: accepted -> kind:"alarm",
  rejected -> kind:"alarm-rejected" with the precise reason (unknown
  device / not-hikvision / push-disabled / source-IP mismatch / digest
  fail). The 404 body now also returns the reason.
- New GET /api/devices/hikvision/alarms (device:read): the recent pushes
  newest-first as JSON (accepted+rejected, with ip/reason/eventType/
  target/plate/rawHead) so you can SEE arrivals in the browser instead of
  grepping the dev log or querying SQLite.

Tests: hikvision-alarm.test.ts now 8 (rejection-recorded + read-endpoint
list + gating). server 111/111; build+lint green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 10:28:09 +02:00
julian 6133923094 feat(camera): Hikvision Alarm Server event-push ingress (discovery-first)
Newer Hik firmware can PUSH events to us: Event -> Smart/VCA with
"Detection Target: Human/Vehicle" + Notify Surveillance Center + Alarm
Settings -> Alarm Server makes the camera HTTP-POST an
EventNotificationAlert on each detection.

- New POST /api/devices/hikvision/:deviceId/event (routes/hikvision-alarm.ts):
  same machine-push pattern as the Dingtian Input Link — source-IP guarded
  + optional HTTP Digest, not behind the SPA cookie/CSRF.
- Discovery-first / permissive: a wildcard content-type parser accepts ANY
  body as raw bytes (event XML, multipart+JPEG, or JSON — Hik varies by
  firmware), records it verbatim as a kind:"alarm" device_event, and
  best-effort extracts eventType/target/plate/dateTime/channelID for the
  summary + a loud log line. The point is to SEE exactly what a camera
  sends before wiring it further.
- hikvision driver gains alarmPushEnabled + pushUser/pushPassword config and
  pushesToBackend:true (setup offers the backend push IP).
- NOT yet a barrier trigger / DeviceReadEvent — records only. A plate read
  is advisory, never the sole reason a barrier opens; the read-bus/ANPR
  wiring is a deliberate next step once the real payload is known.

Tests: hikvision-alarm.test.ts (6: vehicle XML summary, ANPR plate, raw
JSON, wrong-IP 404, disabled 404, unknown-device 404). server 109/109;
build+lint 14/14. Wiki: lpr-camera.md + log.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 10:02:35 +02:00
julian 7680d9a0ed feat(recycle-bin): soft delete + restore for master data
Accidental admin deletes of users/roles/subscriptions/plans/tariffs were
hard and unrecoverable. Now they soft-delete into a recycle bin.

Schema (migration 0012): nullable deleted_at + deleted_by on users, roles,
subscriptions, subscription_plans, tariffs. Additive ADD COLUMN; verified
against a copy of the live DB.

Backend: each resource's DELETE route STAMPS instead of removing; every
catalog list filters deleted_at IS NULL. New recycle-bin module + routes
(GET /api/recycle-bin, POST .../restore, DELETE .../:id purge) gated on a
new recyclebin:read/update/delete permission. A 6-hourly + startup sweep
auto-purges items older than RECYCLE_BIN_RETENTION_DAYS (default 30; 0 =
forever).

Invariants: soft-deleted users can't log in (login rejects deleted_at;
no-lockout counts live admins only); a soft-deleted subscription doesn't
open the barrier; plans are versioned so a delete stamps all versions of
the plan_id (bin shows one item); username/role-name UNIQUE spans deleted
rows so reuse returns a clear 409 pointing at the bin; restore doesn't
auto-cascade a dangling role (guard resolves missing role to empty perms).
The signed append-only ledger is OUT of scope (no delete path).

Web: a Recycle bin tab under Setup (RecycleBin.tsx) with Restore/Purge +
purge confirm; api client + i18n (sq + en parity).

Tests: recycle-bin.test.ts (9 unit) + recycle-bin-routes.test.ts (4
integration: delete -> can't-login -> restore -> login, purge, gating,
409 reuse). server 103/103; build+lint+test 19/19.

Wiki: new concepts/soft-delete.md; local-jwt-auth + index + log updated.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 09:33:54 +02:00
julian 3527f48d76 refactor(reports): top-level /reports section in the header, not a Setup tab
CI / check (push) Failing after 30s
Moves Reports out of the Setup tab bar into a standalone top-level route
(/reports) with its own header nav link, alongside Booth/Shifts/
Subscriptions. Adds a /setup/reports → /reports legacy redirect. Same
report:read gate. Wiki note updated.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 00:20:30 +02:00
julian 5a5f5c554b feat(reports): admin Reports dashboard — ledger-first charts
Adds an admin Reports screen (/setup/reports, gated report:read) — an
on-demand dashboard over the signed event log.

Server (ledger-first): GET /api/reports/summary?from&to&bucket aggregates
in one call — entry/exit counts + all money summed straight from
ledger_events (same source the shift Z-report reconciles, so totals tie
out to the drawer); revenue split into ticket / subscription-sale /
out-of-window mirrors the Z-report. Duration stats come from the sessions
cache (flagged). All bucketing is in the SITE timezone (siteTz). A .csv
export of the per-bucket series. reports.ts + routes/reports.ts.

Web: Reports.tsx — date-range presets (today/7d/30d/90d), hour/day/month
grain, KPI cards, entry/exit line, revenue bar + cash/card split,
revenue-mix pie, peak-hours histogram, numeric breakdown, subscription
stats. Charts via Recharts (MIT), lazy-loaded into its own chunk
(~111KB gz) so the booth bundle is untouched. New Setup tab + nav + i18n
(sq + en parity). asc() exported from @parking/db; formatMinutes helper.

Tests: reports.test.ts (10) pin the sums, tz bucketing, money split,
duration stats, subscription counts. server 90/90; build+lint 14/14.

Wiki: reporting-analytics.md "Built v1" section + log entry.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 00:16:07 +02:00
julian 742653aefb feat(setup): "Test ANPR" probe on ANPR-enabled cameras
Adds a bottom-of-modal "Test ANPR" button (shown only when a camera's
Plate recognition opt-in is checked) that captures a live snapshot off
the camera and runs it through the vision service, reporting the plate
read + confidence + elapsed time, or which stage failed.

- New POST /api/setup/test-anpr: builds the camera from the unsaved
  config (no DB write/device change, like /test), captures a snapshot,
  runs vision.analyze. Fail-soft like the runtime path (snapshot.ts):
  camera/vision failures are reported results, never a 500.
- Thread the existing VisionClient into setupRoutes; add an isCamera()
  type guard to @parking/devices.
- Web: testAnpr() client + AnprTestResult; button, hint, result line.
- i18n keys in sq + en (Catalog parity).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 00:02:31 +02:00
julian 7629d5d7b1 fix(auth): make the Secure cookie flag fail-safe (default on)
secureCookies() keyed off NODE_ENV === "production", so an appliance deployed
without that var silently sent the auth + CSRF cookies WITHOUT the Secure flag —
the review's one Medium finding.

Now Secure is the DEFAULT and you only ever opt OUT: a misconfigured/forgotten env
can only make cookies more restrictive, never drop the flag. Dropped only on a
deliberate COOKIE_SECURE=0/false/no/off (or an explicit NODE_ENV=development as a
dev fallback). The LAN appliance that serves the SPA over plain http sets
COOKIE_SECURE=0 on purpose (a Secure cookie would never be sent over its http origin
and would lock operators out); a TLS deploy leaves it unset and gets Secure.

- auth.test.ts (5): pins the matrix — default Secure, production Secure, dev opt-out,
  COOKIE_SECURE falsey opts out, any other value opts in.
- .env.example documents COOKIE_SECURE (replaces the stale NODE_ENV cookie note).
- dev .env sets COOKIE_SECURE=0 (local http://localhost login keeps working).

server 80/80; build+lint green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 23:50:40 +02:00
julian 2fb947e908 test(vision): fix stub-mode tests; close the testing-gap wiki note
The two failing apps/vision smoke tests assumed stub mode but the local .env sets
VISION_RECOGNIZER=fast_alpr (real-model work, 2026-06-19), so the app built the real
recognizer: /health reported "fast_alpr" not "stub", and /analyze on garbage bytes
422'd (real decode reject) instead of returning the empty stub contract.

Fix is test isolation: a conftest autouse fixture pins VISION_RECOGNIZER=stub for the
session (an OS env var overrides the .env in pydantic-settings), restoring it after.
vision 7/7.

Updates wiki/concepts/booth-console.md (the "no automated tests" Open note now reflects
the coverage that landed) and appends wiki/log.md.

Full workspace: shared 87, server 75, devices 18, web 17, vision 7 = 204 tests across
8 turbo test tasks, 0 failures; build/lint 14/14.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 16:25:21 +02:00
julian cae900afd2 test(web): Phase 4 — booth formatters + focus-independent scanner hook
Closes the standing "no automated frontend tests" gap for the pure, testable logic:

- format.test.ts (12): the booth display formatters — formatMoney (minor units →
  currency, malformed-code fallback), formatDuration (m / h+m / 0m / em-dash on
  negative-invalid), formatTime, and formatRelativeDateTime (today/yesterday words +
  catalog month names, no Intl dependence).
- use-scanner.test.ts (5): the 2026-06-21 focus-independent hardware scan — a fast
  burst+Enter on <body> fires onScan; slow human typing (gap > 50ms) does not; paused
  (modal open) no-ops; keystrokes into an editable field are ignored; a lone Enter /
  too-short burst is ignored.

Wires Vitest (jsdom + @testing-library/react) into @parking/web. web 17/17.

Full workspace green: shared 87, devices 18, server 75, web 17 (= 197) + build/lint
14/14. (apps/vision still has its 2 pre-existing failures — next.)

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 16:23:15 +02:00
julian 7e912e193b test(server): Phase 3 — HTTP route integration (auth + RBAC guards)
Boots the REAL Fastify app over a fresh in-memory DB (buildServer({ db }), driven by
app.inject — no listen) to exercise the security seam end to end:

- routes.test.ts (7): /health open; login rejects bad creds and sets token+csrf
  cookies on good ones; an unauthenticated GET /api/occupancy is 401; a site:read-only
  role GETs occupancy but is 403 on PUT /api/site-config (the permission gate, with a
  valid CSRF so the 403 is the perm check); an admin passes the same PUT; and a mutation
  with the auth cookie but NO csrf header is 403 (double-submit enforced).

Adds seedUser()/login() helpers (real bcrypt + the real /api/auth/login route) and
LOG_LEVEL=silent in the vitest env so asserted 401/403 responses don't flood output.

server 75/75 green (8 suites).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 16:20:14 +02:00
julian 5e9be16f65 test(server): Phase 1 — server-core suites (occupancy, pay, exit, shift)
Completes the anti-fraud/safety core coverage on a fresh in-memory DB:

- occupancy.test.ts (12): the ledger-fold count, the capacity/full gate, and the
  reserved-subscriber-spots model — never double-count a parked subscriber, reserve
  tightens only the TRANSIENT gate.
- pay-station.test.ts (12): quote math against the frozen tariff, the signed-payment
  side effect (+ chain verify), no-session / no-tariff errors, the booth lookup view,
  active-session listing.
- exit-flow.test.ts (9): the GATE — refuse unknown / unpaid / grace-expired (no exit
  signed); a paid-within-grace session signs the exit; the booth transient path has NO
  subscription bypass; a prepaid subscriber leaves via the assist (reopenBarrier) path.
- shift-service.test.ts (14): site-wide single-open invariant, the takings SPLIT by
  source (subscription sales vs out-of-window vs transient tickets), drawer carry-
  forward + cash_in/out vouchers, Z-report sign + listShifts read-back.
- entry-flow.test.ts (5): the exported validateTicketCode Luhn typo-guard. (The
  capacity-gate/print-hold/sign-before-open paths need device fakes — covered in the
  device + route phases.)

Adds test-helpers.ts (real EventLog, silent logger, tariff seeder). server 68/68 green.

Note: apps/vision has 2 PRE-EXISTING failures (test_app.py) — environment drift now
that fast_alpr + the ONNX model are installed (the "stub mode" assertions are stale).
Untouched here; to be fixed in the vision phase.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 16:15:56 +02:00
julian 0985b86fa7 test(server): add fresh-SQLite test harness + anti-fraud core suites
Foundation for testing every service. Adds @parking/db/testing — createTestDb()
spins a fresh in-memory SQLite and applies the real Drizzle migrations, so server
tests run against the production schema with zero live-DB risk.

Wires Vitest into apps/server (test script + config; test signing keys via env)
and adds the first Phase-1 suites against the anti-fraud core:

- signer.test.ts (10): sign/verify round-trip, tamper + forgery rejection,
  malformed-signature guard, determinism, keyId rotation (buildVerifier).
- event-log.test.ts (12): monotonic index, prevHash linkage, payload-in-signature,
  append serialization, and verifyChain() catching every tamper class — edited
  payload, deleted row (index gap), broken prevHash, unknown keyId — plus
  canonicalize byte-stability.

Also stops *.test.ts leaking into shipped dist/ (tsconfig exclude in server +
shared; shared had been emitting compiled tests all along).

server 22/22, shared 87/87 green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 15:20:38 +02:00
julian 3ed785c33e feat(booth): open the pay/exit modal on a hardware scan regardless of focus
A barcode/QR scanner is an HID "keyboard wedge" — it types the id + Enter into
whatever holds focus. Previously that only worked while the ticket <input> was
focused; a scan with focus elsewhere (or nowhere) went nowhere.

New useScanner hook (apps/web/src/lib/use-scanner.ts): a document-level keydown
listener that detects the scanner's FAST keystroke burst ended by Enter and opens
the pay/exit modal via setActiveTicket — regardless of focus. A gap > 50ms resets
the buffer, so human-paced typing with nothing focused never registers as a scan
(min length 3 guards stray Enters). Keystrokes into an input/textarea/select/
contenteditable are ignored, so the manual ticket field still works by hand. The
hook is paused while a modal is already open — a scan must not abandon an
in-progress payment; the operator finishes/closes, then scans the next car.

Verified at runtime (Playwright): a fast burst with focus on BODY opens the modal;
a second scan while the modal is open is ignored; slow (120ms) human typing does
NOT open it; the manual input submit still opens it. build+lint 14/14.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 15:03:01 +02:00
julian 35c10a7310 feat(shifts): /shift→/shifts, clickable activity log (shared event-detail), booth-style full-height layout
Three changes to the shift hub, addressing the report:

1. Route rename /shift → /shifts (matches the plural "Turnet" label and the
   section). /shift and /setup/shifts both redirect to /shifts; the header link
   and the operator-landing fallback point at /shifts.

2. The activity-log rows are now CLICKABLE and open the same read-only
   event-detail modal the booth live feed uses (full signed payload + entry/exit
   snapshots + chain provenance) — previously they were static rows. Extracted
   EVENT_STYLE, the feed row, the detail modal, and their helpers out of
   BoothScreen into a shared apps/web/src/ui/event-detail.tsx imported by both the
   booth and the shift log, so the two render and behave identically and can't
   drift.

3. Reworked the /shifts layout to fill the viewport like /booth: a fixed
   title + filters, then a two-pane area (shift list | activity log) where each
   pane scrolls independently (min-h-0/flex-1 + overflow-y-auto) instead of the
   whole page growing. ShiftActivityLog is now a flex column with a fixed header
   and a scrollable list.

Verified at runtime (Playwright): /shift redirects to /shifts, an activity row
opens the detail modal, the layout fills height, and the booth still works (0
console errors after the extraction). build+lint 14/14.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 14:58:50 +02:00
julian 2a9e6846a1 fix(nav): header "Turni"→"Turnet" (plural); remove duplicate Setup shifts tab
The header shift link used nav.shift (singular: Turni/Shift) but points at the
/shift HISTORY hub, so it now uses nav.shifts (plural: Turnet/Shifts).

The Setup "Turnet" tab was a duplicate — /setup/shifts and the standalone /shift
both rendered ShiftsHistory. Removed the Setup tab + its child route; /setup/shifts
redirects to /shift for old bookmarks, and the operator-landing fallback (a
shift:read user opening /setup) now points at /shift. The orphaned nav.shift key is
left in both catalogs (harmless).

Verified at runtime (Playwright): header reads Kabina·Turnet·Abonimet·Konfigurimi,
Setup no longer lists Turnet, /setup/shifts redirects to /shift. build+lint 14/14.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 14:47:00 +02:00
julian 051b440627 feat(nav): promote Subscriptions to a top-level section with its own tabs
Subscriptions, Plans, and Tariff Lab were tabs under /setup. Moved them into a
standalone /subscriptions section with its own header nav entry (between Turni and
Konfigurimi) and a tab bar: Abonimet (/subscriptions), Planet
(/subscriptions/plans), Lab Tarife (/subscriptions/tariff-lab).

- New SubscriptionsLayout (tab bar + <Outlet>); the three screens are now its
  child routes at the top level, not under setupRoute.
- Removed Subscriptions/Plans/Tariff-Lab from SetupLayout and SETUP_TABS. Setup
  now holds Devices/Tariff/Site/Users/Roles/Shifts/Logs.
- Header gains the "Abonimet" link, gated on subscription:read OR subscription:plan
  OR tariff:read (shown if the user can reach any sub-tab).
- Tabs are permission-gated; the /subscriptions index redirects a user lacking
  subscription:read to the first sub-tab they can see (or the booth).
- Legacy redirects: /setup/subscriptions → /subscriptions, /setup/plans →
  /subscriptions/plans, /setup/tariff-lab → /subscriptions/tariff-lab. Dropped the
  old /subscriptions → /setup redirect (it's a real route now).
- The Tariff COMPOSER stays in Setup; only the Tariff LAB simulator moved.

Verified at runtime (Playwright): header order Kabina·Turni·Abonimet·Konfigurimi,
the three sub-tabs render, Setup no longer lists them, /setup/subscriptions
redirects cleanly. build+lint 14/14.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 14:43:58 +02:00
julian eb47016ae3 feat(shift): confirm-before-close with X-report + split tickets vs subscriptions; fix dark <select>
CI / check (push) Failing after 31s
Three changes:

1. Confirm-before-close. The header shift button closed the shift directly — a
   stray click would sign the irreversible Z-report. It now opens a confirm modal
   showing the live X-report (takings split by source + expected drawer) with
   Cancel / End-shift. Opening a shift stays immediate (no such risk).

2. Split takings by SOURCE. The report separates Tickets (transient) from
   Subscriptions (monthly sales + a subscriber's out-of-window charge), so the
   operator sees subscriber money apart from ticket money. Buckets are derived
   from the signed payment payload flags (subscriptionSale /
   subscriptionWindowCharge) and always reconcile to cash + card (a payment with
   neither flag is a ticket). Computed in #summariseWindow, carried on the signed
   shift_z_report payload, and shown in the X-report, the close modal, the shift
   history detail, and the printed Z-report. Reports predating the fields default
   subscription to 0 (ticket absorbs the whole take), so old shifts still
   reconcile.

3. Fix dark-theme native <select> popups rendering WHITE on WebKitGTK (the Tauri
   Linux WebView): set color-scheme dark/light on <html> per theme + explicit
   <option> colours, so the OS-drawn dropdown list follows the theme.

Verified the split on a read-only DB copy: tickets 0, subscriptions 10,200
(10,000 sale + 200 out-of-window), reconciles to cash+card. build+lint 14/14,
i18n parity (sq+en).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 14:30:14 +02:00
julian 78d1f6808a feat(subs): admin can correct a subscription's plan VERSION
A subscription froze its planVersionId at sale (reproducible pricing). There was
no way to move a sold sub onto a different VERSION of the SAME plan — needed when
an admin publishes v2 with different timeframes (e.g. mujor-naten-cdo-dite v1
"every day" → v2 "weekdays only") and wants an existing subscriber on it, or back
on v1.

Backend (PUT /api/subscriptions/:id):
- accept planVersionId; honored only with the subscription:plan permission
  (stronger than subscription:update — a plan-management action). Non-privileged
  caller sending a change → 403, not silently dropped.
- validated to belong to the sub's EXISTING planId (a different plan = a
  different price basis = a re-sale → 400).
- price/currency/period/planId stay frozen; only planVersionId moves. The swap is
  server-logged for audit (the row is mutable master data, not on the ledger).
  Past signed entry/exit events keep their own windowTariffVersionId, so history
  reprices identically — only future access uses the new version's windows.

Frontend (SubscriptionManager):
- pass the session user through the route (like RolesManager).
- admin-only "Versioni" picker in the edit modal: lists every version of the
  sub's plan by effective date + a timeframe summary (days + window, or 24/7),
  current pre-selected. The plan itself stays read-only. Sends planVersionId only
  when it changed.
- i18n: subs.version/versionHint/versionCurrent/versionOnlyOne/everyDay/allDay
  in both sq + en.

Verified on a writable DB copy: version changed, price + planId frozen,
cross-plan version rejected. Live DB untouched. build+lint 14/14.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 14:08:58 +02:00
julian 8acef0464c fix(subs): price out-of-window charge from minutes actually parked, not a fixed entry stamp
An out-of-window subscriber entry stamped a FIXED windowOwedMinor = the whole
gap to window-open (e.g. 800 ALL for a 13:21 arrival to a 20:00 window) and
deferred it to exit. That over-charged anyone who left before the window
opened — a 1-hour visit was billed as 6.5 hours.

The amount isn't knowable at entry: a subscriber may enter early, leave after
an hour, come and go several times before the window opens, and linger past
window-close. They should pay only for the minutes actually parked outside the
window (capped at the window edges) — exactly what minutesOutsideWindow already
computes.

So the entry now stamps a MARKER only (outOfWindow: true + windowTariffVersionId
for reproducible pricing), no fixed amount. The exit gate and booth quote price
it live via windowOwedBetween(entry → settle-time), which already caps at the
window edges (early entry stops accruing at window-open; the in-window portion
of a crossing stay is free; the late-exit tail keeps accruing until payment).
Both already called that one function, so they agree.

- subscription-flow: entry stamps outOfWindow marker; the advisory slip is now a
  scannable out-of-window TICKET (Code128 + QR of the occurrence id).
- shared LedgerPayload: add outOfWindow; mark windowOwedMinor/windowGap*/
  windowCurrency deprecated read-only (historic signed events still type-check).
- BoothScreen: window-charge badge keys on outOfWindow (or the old stamp).
- ActiveSessions: drop the always-on "Open barrier" for subscribers — the
  assist-open / window-charge payment live in the pay modal, so the list can't
  one-click past an unpaid out-of-window charge.

Verified the live model on a DB copy: 13:21→14:30 = 200 ALL; 19:55(in grace)→
23:00 = 0; 19:00→21:30 (crosses into window) = 100 ALL. Existing signed
occurrences left untouched (immutable). build+lint 14/14, shared 87/87.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 13:34:35 +02:00
julian df5caf8d87 feat(subs): scannable out-of-window slip + two-step booth flow
The advisory out-of-window slip for a subscriber had two problems:

1. Faulty character codes. It rendered via the generic text printReport,
   which has no CP852 mapping for the em dash, ellipsis, or warning sign in
   the composed strings — so they printed as "?" ("PARKIM ? JASHTE ORARIT").
   Added ASCII transliterations for that typographic punctuation in the
   ESC/POS encoder (— → -, ⚠ → !, … → ..., curly quotes/bullet), so they
   degrade to a readable glyph instead of "?".

2. Not scannable. The slip printed only "Nr: SUBSESS-…" as plain text, so
   the operator had to hand-key it. Gave the notice its own render function
   (renderWindowChargeNotice) + a printWindowChargeNotice device method that
   prints the occurrence id as a Code128 AND a QR — the same scan path as a
   transient ticket, so the operator scans it straight into the booth pay
   modal, which then quotes the combined window charge. Implemented on both
   the rongta and cashino drivers.

Also fixed the booth pay modal: "Open barrier" no longer shows by default
for a subscriber. A prepaid subscriber with nothing owed sees only a small
"assist open" reveal (the audited manual open for a faulty reader / lost
card stays available, just not the default). A subscriber owing an
out-of-window charge is now two steps — take payment first, then "Open
barrier" appears — instead of an always-on open button.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 13:12:05 +02:00
julian 0cbae94842 feat(desktop): wire updater endpoint to self-hosted Gitea + document Tauri WS origin
Point the Tauri updater at the real self-hosted Gitea "latest release" path:
https://git.infra.msai.al/mca/parking_solution/releases/latest/download/latest.json
— redirects to the newest tag's latest.json published by release.yml. Verified
against tauri-plugin-updater: it GETs the endpoint (200 + manifest / 204 = up to
date) and reads platforms.linux-x86_64.{signature,url}.

Document the desktop WS origin: the Tauri window loads from tauri://localhost
(Linux may also send http://tauri.localhost), which is NOT same-origin with the
backend, so WS_ALLOWED_ORIGINS must include both or the live feed won't connect.
Added both to apps/server/.env.example.

Updated the as-built in wiki/decisions/desktop-shell-tauri.md. Also carries an
unrelated plans.namePlaceholder copy tweak already in the tree. turbo build lint
14/14 green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 12:51:11 +02:00
julian d0536da3d7 feat(desktop): Tauri v2 kiosk shell — maximized window, prod right-click block, auto-update + code-signing
Add apps/desktop, a thin Tauri v2 shell wrapping the SAME @parking/web SPA so
the desktop and browser UIs never drift: dev loads the Vite dev server (HMR),
prod bundles the web app's dist/. No business logic in the shell (device/auth/
ledger stay in @parking/server); deny-by-default capabilities.

apps/web (single UI source of truth):
- lib/origin.ts: centralize the backend origin (API_BASE/apiUrl/wsUrl from
  VITE_API_BASE); no-op in the browser, lets the desktop build target Fastify.
- lib/kiosk.ts: block the right-click context menu in PROD only (dev keeps it +
  devtools).
- lib/desktop-updater.ts: prompt-on-update auto-update (no-op in browser/offline)
  → downloadAndInstall + relaunch; i18n update.* keys (sq+en).
- .env.production: VITE_API_BASE wired to the Fastify origin for the bundle.

Desktop:
- window starts maximized (not fullscreen — operator keeps OS access).
- auto-update via tauri-plugin-updater + -process; self-hosted endpoint is a
  PLACEHOLDER to fill in. Updater keypair: pubkey embedded in tauri.conf.json;
  private key + password kept OUTSIDE the repo (~/.parking-updater-keys) and as
  TAURI_SIGNING_* build secrets.
- Turbo build is a no-op; the real signed bundle is `pnpm --filter
  @parking/desktop bundle` (verified → .deb/.rpm/.AppImage + .sig signatures).

Verified: cargo check clean; turbo run build lint 14/14 green; i18n parity holds;
no key/sig/bundle artifacts in the repo.

Wiki (security + desktop analysis recorded alongside):
- new concepts/tpm.md (TPM 2.0: how it works, sealed-LUKS auto-unlock + non-
  extractable signing key, limits — live-root, bus-sniff — TPM-vs-ATECC608 by
  platform).
- new decisions/desktop-shell-tauri.md (Tauri v2 over Electron; best-case Ubuntu
  26.04 LTS, worst-case Windows+WSL → kiosk browser; full as-built).
- pull-the-disk attack trace on append-only-event-chain; ATECC608 not-in-a-PC
  caveat; cross-links from disk-os-hardening / threat-model.
- open-questions #11 (appliance WebKitGTK), #12 (TPM hardening impl), #13
  (startup verifyChain self-check); index/overview/log/standing-decisions.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 12:21:49 +02:00
julian ae736a9e3e feat(shift): current shift in the list + modal actions; full-width layout everywhere
Shift screen:
- The standalone ShiftControl block is gone from /shift. The open/CURRENT shift now
  appears at the TOP of the shift list (CURRENT badge, live figures synthesized from
  the X-report), unified with history. Selecting it shows its live activity log.
- Shift ACTIONS moved into the current shift's detail pane, each opening a MODAL:
  End shift (confirm → signed Z-report result), drawer voucher (Mandat in/out),
  takings-so-far (X-report). When no shift is open, a Start-shift button shows.
- The current shift's log auto-refreshes (5s); a closed shift is bounded by its
  window. /setup/shifts stays read-only history (no manage props). Deleted the now-
  orphaned ShiftControl.tsx.

Layout:
- Every screen is now full-width like /booth — stripped the per-screen
  `mx-auto max-w-*` caps (Logs, Subscriptions, Plans, Tariff, Users, Roles, Setup
  layout, Shifts). The shell <main> already provides padding.

Build+lint 12/12 (i18n parity). Verified a live open shift surfaces as the CURRENT
list entry.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 23:44:27 +02:00
julian 1b54775b4d feat(shift): two-pane shift history — list + per-shift activity log, timeframe presets
Rework the shift screen into a master/detail view on /shift: the shift CONTROL
(open/close, drawer vouchers, X-report) on top, then a two-pane history below —
shift list on the LEFT, the selected shift's signed activity log on the RIGHT.

- Timeframe presets replace the bare from/to inputs: Yesterday / Last week /
  Last month / All / Custom (custom reveals the date pickers). Filters the shift
  list by start time.
- Activity log = every ledger event in the selected shift's [start, end] window
  (entries, exits, payments, vouchers, anomalies, the Z-report), rendered like the
  booth live feed (same EVENT_STYLE), with the shift's drawer reconciliation in the
  pane header.
- Scope unchanged + enforced SERVER-SIDE: an operator sees only their own shifts
  (no operator filter); an admin (shift:cash) sees all + the operator filter. The
  list auto-selects the newest shift.

API: /api/events gains an optional `until` (ISO) upper bound so a shift's window
can be fetched ([start,end]); fetchEvents passes it. Verified on live data: a
closed shift window returns just its 20 events out of 260.

Build+lint 12/12 (i18n parity). The same component also backs /setup/shifts.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 21:56:34 +02:00
julian f2734641b2 feat(subs): print an advisory "out-of-window" slip at early entry
A subscriber entering outside their plan's window owes a deferred charge, but
nothing printed — they had no paper proof a fee was pending. Print a best-effort
ADVISORY slip at entry ("PARKIM — JASHTË ORARIT"): holder, entry time, "entered
out-of-window (window opens HH:MM)", and the key line "⚠ fee computed at exit"
+ the occurrence number. It is NOT a payable ticket and carries NO amount — the
total is computed at the booth on settlement, combining early-entry AND any
late-exit time into one number (windowOwedBetween over the whole stay).

Best-effort like the Z-report / subscription card: printed AFTER the barrier
opens and fully swallowed, so a missing/failed printer never blocks entry. New
printWindowChargeNotice (booth-print.ts) via the generic printReport; wired into
the subscription entry flow when an out-of-window entry charge applies.

(The "both charges at the booth" requirement was already satisfied by the
windowOwedBetween fix — verified: early-entry + late-exit minutes combine in one
calc at lookup/exit. This commit only adds the entry paper trail.) Build+lint 12/12.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 20:47:45 +02:00
julian de858e91f4 i18n: translate sub.refused.unpaidWindow reason (sq + en)
The exit-gate refusal for an unpaid out-of-window subscriber charge rendered as
the raw code `reason.sub.refused.unpaidWindow` — the code + English fallback
existed in @parking/shared but the reason.* catalogs had no entry. Add it to
both catalogs with the {{amount}}/{{currency}} params the gate passes.

EN: "Exit refused — out-of-window charge unpaid ({{amount}} {{currency}}); pay at the booth"
SQ: "Dalja u refuzua — detyrim jashtë orarit i papaguar ({{amount}} {{currency}}); paguaje në kabinë"

Build+lint 12/12 (i18n parity).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 20:39:53 +02:00
julian 294ca85ded fix(subs): out-of-window charge was a phantom 12h span (4,100 ALL bug)
The tariff-bridge owed amount summed TWO charges — the early-entry gap +
a "late-exit" gap — and the exit gap (outOfWindowGap edge:"exit") always
measured back to the PREVIOUS window close, even for a subscriber still BEFORE
their window. So a car that entered ~30 min early showed ~12h owed (4,100 ALL)
the moment it was looked up, instead of ~100 ALL.

Replace the two-gap sum with a single correct primitive,
minutesOutsideWindow(timeframes, tz, from, to): the minutes within the actual
stay [entry, now] that fall outside the allowed window (covering early entry AND
late exit, bounded by the stay, weekend/off-days free). windowOwedBetween prices
those minutes once as a transient stay (so increments + daily cap apply) against
the tariff in force at entry. Both the exit gate (subscription-flow) and the
booth quote (pay-station) now use this one source of truth — they can't disagree.

Verified on the live occurrence: was 4,100 ALL, now 100 ALL (9 min outside →
one increment). 87 shared tests (6 new regression cases incl. the phantom span).
Build+lint 12/12.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 20:37:57 +02:00
julian eafbc3ddbb feat(booth): badge subscriber out-of-window entries in the live feed
A subscriber entering outside their plan's allowed window gets a deferred
transient charge (windowOwedMinor, collected/gated at exit) — but it was
SILENT at the booth: the entry showed as a plain subscriber pass with no hint
money is owed, so the operator only discovers it at exit.

Surface it: add a "out-of-window — owes fee" badge on any entry/exit event
carrying windowOwedMinor > 0, so the operator sees immediately that this
subscriber owes a fee. Also type the window-charge fields on LedgerPayload
(were riding the open-ended index signature).

Behaviour is otherwise unchanged and correct — verified the live "Mon Kukaleshi"
entry: entered 20:29 local (before the 21:00 Mon–Sat window, grace 5m), owes
100 ALL for 18:29–18:55Z, stamped on the signed entry, still owed, gated at
exit. Subscribers get no ticket by design. Build+lint 12/12.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 20:32:12 +02:00
julian 36f30d39ff feat(plans): reactivate + delete-when-unused; card layout fixes overlap
Addresses three issues with the plan catalog screen:

1. Retired plans had NO actions (the action cell was gated on "current
   version", which a retired plan lacks) — so there was no way to make one
   in-force again. Add POST /:planId/reactivate (inverse of retire) + a
   Reactivate button on retired plans.

2. No delete. Add DELETE /:planId, allowed ONLY when zero subscriptions
   reference the planId (any version) — a referenced plan version must survive
   for reproducible repricing/audit, so an in-use delete returns 409 and the UI
   says "retire it instead". The Delete button only shows when the plan has 0
   subscribers.

3. The 6-column table overflowed max-w-3xl: action buttons overlapped and the
   status badges wrapped to a second line. Replace it with a CARD list (one card
   per planId, grouped across versions): name + status on top, price · hours ·
   effective on a wrap row, "used by N" expandable to holder names, and actions
   on their own bordered row — nothing overlaps, badges stay inline.

Build+lint 12/12 (i18n parity). Verified on a DB copy: unused plans report
deletable; retire→reactivate flips active back.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 20:27:36 +02:00
julian 488dcb5e4e feat(plans): show hours, period/currency, and subscriber dependencies in the plan list
Deleting versioned plans is unsafe (a plan version referenced by a subscription's
planVersionId must survive for reproducible repricing/audit) — so instead of
delete, give the admin the VISIBILITY they actually needed:

- Hours column: a compact timeframes summary ("Hën–Pre 21:00–08:00" / "24/7"),
  so two same-priced plans are distinguishable at a glance.
- Period + currency are already in the price cell; the hours column removes the
  remaining ambiguity between night/day plans.
- "Used by" column: a count of subscriptions on each (current) plan (active /
  total), expandable to the holder names — so you can see what depends on a plan
  before retiring or replacing it. Computed client-side from the existing
  subscriptions list (both screens are admin-grade; no new endpoint).

Build+lint 12/12 (i18n parity).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 20:14:30 +02:00
julian c64457020f fix(subs): resolve the plan version at the SALE instant, not validFrom
Selling/quoting a subscription resolved the plan version against `validFrom`,
but validFrom is a DATE (midnight UTC for "starts today"). A plan published
later the same day (effectiveFrom 15:22) then failed `effectiveFrom ≤ validFrom`
(00:00), so resolvePlanVersion returned null → "no active plan for that planId",
and the form's selectedPlan went null (hiding the new count field too).

The plan/price in force is determined by WHEN THE SALE HAPPENS, not by the
coverage start — like a tariff, the customer buys today's published rate. Resolve
at new Date() in all three sites (validate, priceSale, /quote); validFrom is kept
only for span pricing. Verified the two live plans now resolve.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 19:55:29 +02:00
julian ff04ec10be feat(subs): add a "how many periods" count that drives the end date
When subscriptions moved to the plan model the span became start + end dates,
which lost the simple "renew for N months/weeks/days" input — the operator had
to hand-compute the end date. (quantity is CARS, a separate axis, not periods.)

Add a count field to the sell form: the operator types e.g. 3, and validTo is
auto-derived as validFrom + count × the plan's period (day/week/month), with the
same month-overflow clamp the server uses (Jan 31 +1mo → Feb 28) so the preview
matches what's stored + charged. The end-date field stays directly editable for
an irregular span (the hotel checkout case), and editing it isn't overwritten by
the count effect. The count row shows the plan's unit ("× month").

Build+lint 12/12 (i18n parity).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 19:46:06 +02:00
julian e0e218fa61 refactor: plan timeframes use a per-day-of-week picker (like the V2 tariff)
The timeframes model was a coarse weekday/weekend split, which couldn't express
"open Saturdays" or different rules on a specific day — and it didn't match the
V2 tariff, which already has a proper per-day-of-week picker (Hën–Die).

Replace PlanTimeframes { weekday, weekend } with { days[], fromMin, toMin }: the
allowed window applies only on the selected days (0=Sun..6=Sat; empty = every
day); on unselected days the subscriber parks free. A "night plan, free
weekends" is just days [Mon..Fri] with a 20:00→08:00 window — the exact case
from before, now expressible alongside any other day combination.

outOfWindowGap reworked to the days model (per-day membership test instead of
the weekend helper); the plans editor reuses the tariff composer's Mon-first
checkbox row and the shared tariff.dow0..6 labels. No production plans carry
timeframes yet (feature shipped today), so the shape changed directly with no
migration. Unit tests updated + extended (Saturday-only, every-day, weekday
night); 81 shared tests pass. Build+lint 12/12.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 18:43:21 +02:00
julian 53e1e7b25c feat: subscription v2 — quantity pricing, plan timeframes (tariff bridge), reserved spots
Three subscriber enhancements driven by real scenarios (migration 0011, all
additive columns — backward-compatible).

1. QUANTITY. One subscription covers N cars (a family pays once for two). Sale
   amount = span price × quantity; maxConcurrent defaults to the quantity so all
   N cars can be inside. Quantity rides in the payment payload.

2. PLAN TIMEFRAMES → TARIFF BRIDGE. A plan may restrict WHEN a subscriber may
   park (e.g. weekday 20:00→08:00, weekend all-day). A scan outside the window is
   NOT refused — the out-of-window minutes are charged at the normal TRANSIENT
   tariff (the subscriber is a transient for that time):
     - early entry: arrival → window-open, DEFERRED (signed as windowOwedMinor on
       the vehicle_entry payload), collected at exit;
     - late exit: window-close → departure, and exit is GATED
       (sub.refused.unpaidWindow) until paid at the booth.
   Pure, tz-aware outOfWindowGap in @parking/shared (12 unit tests); pricing
   reuses computeFee + the active tariff version
   (apps/server/src/subscription-window.ts). The exit refusal is a host-ONLINE
   business gate — the fail-open rule still governs the offline path.

3. RESERVED SPOTS. Site toggle reserve_subscriber_spots: occupancy holds
   max(0, quantity − itsCarsInside) per active subscription, so transients see
   "full" sooner; effectiveFree = capacity − count − reserved. Subscribers are
   never gated by full.

UI: quantity field + ×N quote (SubscriptionManager); timeframes editor
(SubscriptionPlansManager); reserve checkbox (SiteSettings); booth pay modal
shows an "OUT-OF-WINDOW" charge and takes payment to clear the exit gate.

Verified on a copy of the live DB: qty 2 = 2× price; a night-plan 19:30 entry →
30min/15,000 ALL owed, stamped + paid → gate clears, chain verifies; the reserve
toggle holds a qty-2 sub's 2 spots. Build+lint 12/12; 80 shared tests pass.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 18:22:50 +02:00
julian fd4608a8f1 feat: subscription plan catalog — config-defined pricing, dated spans, no typed amounts
Re-model subscription pricing from per-row, operator-typed prices into an
admin-composed, versioned PLAN CATALOG (the tariff pattern). The operator now
SELLS by picking a plan over a date span; the price is LOOKED UP, never typed —
removing the fat-finger risk on a money field — and day/week/month periods make
the hotel "guest stays 1–N days" case a daily plan over a check-in→check-out span.

- Schema/migration 0010: new `subscription_plans` (immutable, effective-dated,
  keyed by a stable planId; period day/week/month + per-period price + active
  flag). `subscriptions` gains planId/planVersionId; period enum widened. Seeds a
  "Monthly" plan from the existing site default price (no data loss).
- Pricing (pure, unit-tested in @parking/shared): periods = ceil(span / period),
  amount = periods × per-period price. Ceil = any started period is full (hotel
  practice). `resolvePlanVersion` picks the latest active version ≤ sale instant.
- Backend: new admin-only plan CRUD (`subscription:plan` permission); reworked
  sell path derives the amount from the plan; `POST /api/subscriptions/quote`
  returns a server-computed quote so the operator can't override it. The
  signed-payment sale fix is unchanged — only the amount SOURCE moved; payload
  now carries planId/planVersionId/periods. Updates never re-sell (price frozen).
- Frontend: SubscriptionManager sell form swaps the price field for a plan
  picker + start/end dates + a live quote line. New SubscriptionPlansManager
  (Setup tab) for the admin catalog. i18n (sq+en) for both.

Verified on a copy of the live DB: 0010 applies (existing subs intact), a
3-night hotel sale prices to 2,400 ALL, appends one signed payment with
planVersionId, chain verifies. Build+lint 12/12; 68 shared tests pass.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 17:13:42 +02:00
julian 052da8c3a7 i18n: relabel X/Z report UI to plain language (keep X/Z in code)
The "X-REPORT / Z-REPORT" labels are till-accounting jargon operators don't
recognize. Relabel the user-facing strings to plain wording in both catalogs —
SQ: "ARKËTIMET DERI TANI" / "MBYLLJA E TURNIT"; EN: "TAKINGS SO FAR" /
"SHIFT CLOSE". The X/Z naming stays in code (xReport/zReport keys, the
shift_z_report event, currentReport) and the wiki.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 16:51:54 +02:00
julian cb68cbafdb feat: mid-shift X-report (read-only takings-so-far)
Let the operator see, on demand during an open shift, the opening float
inherited, cash/card collected so far, pay-ins/pay-outs, and the current
expected drawer balance — without closing.

GET /api/shift/report (shift:read; 204 when no shift is open) returns the same
drawer projection the Z-report computes. Factored that math into a shared
ShiftService.#summariseWindow(open, asOf) used by BOTH the X-report (asOf=now,
read-only) and close()'s Z-report (asOf=endedAt, signed), so the two can't
drift. The X-report appends NOTHING — it's a snapshot, not an accountability
mark; the Z-report at close remains the signed record.

UI: a "Takings so far" button on the shift control reveals a cyan X-report
panel; the header still shows the live drawer total for the at-a-glance figure.

Verified against a copy of the live DB: X figures match drawerBalance(), the
drawer identity holds, zero events appended, chain still verifies.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 16:22:55 +02:00