feat(booth): refusal snapshots, subscriber access medium, one-car-one-ticket entry

Three booth-integrity improvements that share the entry/exit flows and activity log.

Refusal snapshots: previously only an accepted open captured a camera image; now
every refusal/hold anomaly fires the directional camera too (a turned-away car is
exactly the evidence wanted) — entry refused-full/held, exit refused
closed/no-session/unpaid/grace-expired (booth + reader paths), refused subscription.
A refused entry has no ticket id, so a synthetic REFUSED- ref keys the anomaly + photo
together. Same fire-and-forget contract; failed captures still show as tiles.

Subscriber access medium: the subscription flow already signed `via`
(qr|card|plate) into entry/exit payloads; surface it as a typed LedgerPayload.via, a
cyan chip in the ticker, and an "Entry medium" modal row (sq+en). Display-only.

One car = one ticket: the entry button could be mashed to mint many tickets per car
(corrupting occupancy + enabling ticket-shopping at exit) — the old #inFlight guard
only blocked overlapping presses. Add a per-relay guard configured on the relay spec:
PRESENCE mode (presenceInput ties ticketing to a vehicle loop on a Dingtian input —
one ticket per car, re-armed when the loop clears) or COOLDOWN fallback
(entryCooldownSec) when there's no barrier feedback. A suppressed press is unsigned
device_events telemetry, not a signed anomaly. SetupWizard exposes both fields.
Fail-closed entry and barrier-is-not-a-door invariants untouched; guard state is
in-memory/rebuildable, starts armed after restart.

Wiki: new entry-double-press; updated entry-exit-points, booth-console, index.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-19 12:54:54 +02:00
parent bfb6ab0b36
commit 30e7fe85de
14 changed files with 436 additions and 28 deletions
+22 -1
View File
@@ -91,6 +91,16 @@ function eventBadges(p: LedgerEvent["payload"]): string[] {
return keys;
}
/** The i18n key for a subscriber's access medium (`via`), or null. Lets the activity
* log show HOW a subscriber entered/left — QR code, RFID card/chip, or plate. */
function viaKey(p: LedgerEvent["payload"]): string | null {
if (!p) return null;
if (p.via === "qr") return "booth.viaQr";
if (p.via === "card") return "booth.viaCard";
if (p.via === "plate") return "booth.viaPlate";
return null;
}
/** A short money summary for payment events (e.g. "350.00 ALL"). */
function paymentSummary(p: LedgerEvent["payload"]): string | null {
if (!p || typeof p.amountMinor !== "number" || !p.currency) return null;
@@ -115,8 +125,9 @@ function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEvent) => v
const reason = renderReason(p, t);
const amount = paymentSummary(p);
const badges = eventBadges(p);
const via = viaKey(p);
const detail = reason ?? amount ?? (isAnomaly ? t("booth.evtNoReason") : null);
const showDetail = detail != null || badges.length > 0;
const showDetail = detail != null || badges.length > 0 || via != null;
// The whole row is a button → opens the event-detail modal (full payload + the
// session's entry/exit snapshots). A grid keeps the time/label/identity/index
@@ -144,6 +155,11 @@ function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEvent) => v
{t(k)}
</span>
))}
{via && (
<span className="rounded-sm bg-term-cyan/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-cyan">
{t(via)}
</span>
)}
{detail && (
<span className={`text-[11px] ${isAnomaly ? "text-term-red/90" : "text-term-muted"}`}>{detail}</span>
)}
@@ -238,6 +254,11 @@ function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () => void
</DetailRow>
)}
{typeof p?.tender === "string" && <DetailRow label={t("booth.edTender")}>{p.tender}</DetailRow>}
{viaKey(p) && (
<DetailRow label={t("booth.edVia")}>
<span className="text-term-cyan">{t(viaKey(p)!)}</span>
</DetailRow>
)}
{category && <DetailRow label={t("booth.edCategory")}>{category}</DetailRow>}
{plate && <DetailRow label={t("booth.edPlate")}>{plate}</DetailRow>}
{operator && <DetailRow label={t("booth.edOperator")}>{operator}</DetailRow>}