feat(subscription): RFID enrollment, any-credential exit, prepaid booth handling

Rounds out subscriptions across enrollment, the barrier flow, and the booth.

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

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

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-18 16:26:48 +02:00
parent bba988c4e8
commit b8ddda86e7
16 changed files with 663 additions and 143 deletions
+13
View File
@@ -85,6 +85,7 @@ export const en: Catalog = {
badgeExiting: "exiting",
badgePaid: "paid",
badgeUnpaid: "unpaid",
badgeSubscription: "subscription",
evtEntry: "ENTRY",
evtExit: "EXIT",
evtPay: "PAY",
@@ -156,6 +157,13 @@ export const en: Catalog = {
credentialsCardQr: "Credentials (card / QR)",
rfCardTag: "RF card/tag",
rfCardTagSoon: "RF card/tag (soon)",
rfPlaceholder: "card number (or read the card)",
readCard: "Read card",
captureChooseReader: "Choose a reader, then present the card:",
captureNoReaders: "No readers configured.",
captureWaiting: "Present the card to the reader…",
captureTimeout: "Timed out with no card read. Try again.",
captured: "Card read: {{value}}",
qr: "QR",
qrAutoGen: "QR code is auto-generated on save",
credentialValue: "credential value",
@@ -268,6 +276,11 @@ export const en: Catalog = {
lookingUp: "looking up…",
paidBarrierOpened: "Paid — barrier opened. Car may exit.",
paidExitRecorded: "Paid and exit recorded, but the barrier did not open: {{reason}}.",
subscription: "SUBSCRIPTION",
plan: "Plan",
prepaid: "PREPAID",
subAssistHint: "Prepaid subscription. Open the barrier to assist the exit (faulty reader / missing card). No payment.",
subBarrierOpened: "Barrier opened for the subscriber (intervention recorded).",
voucherPrinted: "Exit voucher printed on {{printer}}. Customer self-exits at the exit.",
noSnapshots: "no snapshots",
loadingSnapshots: "loading snapshots…",