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:
@@ -95,12 +95,23 @@ For an active session, the operator can open the barrier as a **human interventi
|
||||
> case. The [[#a-session-is-active|paid age-out]] above is the belt-and-braces safety net for any
|
||||
> paid session that still slips through.
|
||||
|
||||
**Guard — no payment, no button.** The "Open barrier" action is shown/active **only for sessions that
|
||||
have a payment** (paid, or paid-and-exited-in-grace). An **unpaid** open session has **no barrier-open
|
||||
affordance at all** — the row routes to the [[#operator-flow|pay/exit modal]] instead. The
|
||||
no-unpaid-bypass rule is enforced structurally: the button simply does not exist for an unpaid car.
|
||||
(A future reason-required *force exit* for genuine disputes would be a separately-audited path — see
|
||||
Open.)
|
||||
**Guard — paid OR subscription, else no button.** The "Open barrier" action is shown/active for a
|
||||
session that **has a payment** (paid, or paid-and-exited-in-grace) **OR is a [[subscription]]
|
||||
occurrence** (prepaid — the operator must be able to assist a subscriber when the exit reader / card
|
||||
fails). An **unpaid TRANSIENT** open session has **no barrier-open affordance** — the row routes to
|
||||
the [[#operator-flow|pay/exit modal]] instead. The no-unpaid-bypass rule is enforced structurally
|
||||
(server-side in `reopenBarrier`: `paidAt != null || subscription`). A future reason-required *force
|
||||
exit* for genuine disputes would be a separately-audited path — see Open.
|
||||
|
||||
### Subscription occurrences in the booth (built 2026-06-18)
|
||||
|
||||
A subscriber's car shows in Active Sessions as a **subscription** session (badge "abonim"; labelled by
|
||||
the **holder name**, not the raw `SUBSESS-…` key). Opening it shows the **pay/exit modal in
|
||||
subscription mode**: entry/duration + **PREPAID** (no amount — it is **never quoted or charged**),
|
||||
the entry/exit **snapshots**, and a single **Open barrier** action (the audited re-pulse). This is
|
||||
exactly the assist path for a **faulty exit reader or a missing/forgotten card/QR**. The session
|
||||
view (`lookup` / `activeSessions` in `pay-station.ts`) carries `subscription`, `subscriptionId`,
|
||||
`subscriptionHolder`, derived from the entry payload's `permit:true` / `permitId`.
|
||||
|
||||
This single mechanism covers both edge cases: a **damaged ticket / dead scanner** (find the still-open
|
||||
session in the list → pay/exit modal, or if already paid → Open barrier, no scan needed), and a
|
||||
|
||||
@@ -104,10 +104,11 @@ LPR/ANPR plate identity** (the plate binding below):
|
||||
**`GS ( k`** (model-2, error-correction M) — added to the Rongta driver
|
||||
(`printSubscriptionCard`), no image/bitmap dependency (same approach as the Code128 ticket).
|
||||
- **RF tag / chip / card — selectable later, NOT live yet.** An RFID/proximity credential, read
|
||||
**host-side** (reader → host → `pulseOpen`). The data model + backend **already accept `kind:'rf'`**
|
||||
(no migration needed to enable it); only the UI constrains the operator to QR for now — the RFID
|
||||
option is shown **disabled ("soon")** so the choice is visible. A Wiegand-out reader keeps a future
|
||||
autonomous path open ([[entry-exit-readers]]); the [[dingtian-relay]] has no onboard card list.
|
||||
**host-side** (reader → host → `pulseOpen`). **LIVE since 2026-06-18** — the operator selects RFID
|
||||
and **reads the card off a physical reader** (see "Enrolling a card" below) rather than typing the
|
||||
number. The GEE readers are combo QR + RFID (ID/IC/NFC), so the same device captures both. A
|
||||
Wiegand-out reader keeps a future autonomous path open ([[entry-exit-readers]]); the
|
||||
[[dingtian-relay]] has no onboard card list.
|
||||
- **Plate (LPR/ANPR) — NOT YET IMPLEMENTED.** When plate-bound (below), a matching plate read is an
|
||||
accepted identity too. The vision/ANPR service that produces plate reads is future work
|
||||
([[opencv-anpr-service]] / [[lpr-camera]]); until it exists, plate binding has no live source.
|
||||
@@ -115,6 +116,41 @@ LPR/ANPR plate identity** (the plate binding below):
|
||||
Both feed the host as a reader event whose `source` is `wiegand` / `qr` (the `IdentitySource`
|
||||
already in the model) and whose value is the credential id.
|
||||
|
||||
### Enrolling a card — "Read card" capture (built 2026-06-18)
|
||||
|
||||
RFID values are awkward to type, so the operator **presents the physical card to a chosen reader**
|
||||
and the system captures it into the credential. The catch is that the readers are also serving live
|
||||
traffic, so capture must **borrow one reader briefly without blocking the other**:
|
||||
|
||||
- **`CredentialCapture`** (in-memory, single-site): the operator picks a reader and **arms** it
|
||||
(`POST /api/subscriptions/capture/arm {deviceId}`). It is **single-shot + a ~30 s TTL**.
|
||||
- In the reader route (`qr-reader.ts`), each read first checks `tryConsume(deviceId, value)`: if
|
||||
**this** reader is the armed one, the value is captured and the read is **NOT dispatched to the
|
||||
access flow** (no barrier opens for a card being enrolled), then capture auto-disarms. A read on
|
||||
**any other reader dispatches normally** — the live entry/exit flow on the other barrier is never
|
||||
blocked. (Accepted trade: while armed, a real customer at the *armed* reader is captured instead of
|
||||
admitted — kept tiny by single-shot + TTL.)
|
||||
- The booth form **polls** `GET /api/subscriptions/capture` (idle | armed | captured | expired);
|
||||
on `captured` it drops the value into the RFID field. `POST …/capture/cancel` disarms.
|
||||
- Verified end-to-end (12/12): captured-not-dispatched (no ledger write), single-shot, the other
|
||||
reader still drives a live `vehicle_exit` while armed, value retrievable, cancel/expiry.
|
||||
|
||||
> The same mechanism would work to capture a **QR** too, but QR codes are server-generated + printed,
|
||||
> so capture is RFID-only in practice (QR has nothing to read off a card).
|
||||
|
||||
### Multiple credentials, and entry decoupled from exit (2026-06-18)
|
||||
|
||||
A subscription is a one-to-many aggregate: it may hold **several credentials at once** — e.g. a QR
|
||||
**and** an RFID card (and later NFC). Each is its own `subscription_credentials` row; any of them
|
||||
resolves the same subscription at the barrier. (NFC works today as an `rf` credential on the combo
|
||||
GEE reader; a distinct `nfc` `kind` is a small future labelling-only addition.)
|
||||
|
||||
Crucially, **entry and exit are NOT bound to the same credential.** Originally the session was keyed
|
||||
by the exact credential value read, so you had to leave with whatever you arrived with — an
|
||||
*accidental* coupling. Now sessions are keyed by a **subscription occurrence** (`SUBSESS-<subId>-<uuid>`),
|
||||
so you can **enter with the QR and exit with the card**. The mechanics (barrier-decides-direction,
|
||||
FIFO close, fleet support) are in "As-built" below.
|
||||
|
||||
## Two optional, independent bindings — confirmed 2026-06-15
|
||||
|
||||
A subscription has **two constraints the admin may or may not apply**, orthogonally. Either, both, or
|
||||
@@ -196,11 +232,21 @@ Tables (mutable master data; every *use* still produces a signed `vehicle_entry`
|
||||
(`read-dispatch.ts`): a credential read routes to the subscription flow if it **matches a
|
||||
subscription** (card/QR credential, or a bound plate) — otherwise to the transient exit flow.
|
||||
|
||||
- **Direction inferred from session state for that car** — no open session → ENTRY (check
|
||||
`maxConcurrent`, sign `vehicle_entry`, open); an open session → EXIT (sign `vehicle_exit`, open,
|
||||
close). A fleet has one session per car; anti-passback falls out.
|
||||
- **`maxConcurrent`** enforced as a fold over the signed ledger (the on-chain `permitId` payload is
|
||||
the match key). Refusals (revoked / out-of-window / at-capacity) are signed `anomaly` events.
|
||||
- **Any credential opens/closes — sessions keyed by SUBSCRIPTION, not credential** (changed
|
||||
2026-06-18). A subscriber can **enter with their QR and exit with their RFID card** (or any mix).
|
||||
Entry mints a fresh **per-occurrence** session id (`SUBSESS-<subId>-<uuid>`, the ledger `identity`)
|
||||
with `payload.permitId = subId`; the credential read is decoupled from the session key. See "Entry
|
||||
decoupled from exit" below.
|
||||
- **Direction = the BARRIER the reader sits at.** An entry-lane read is an ENTRY, an exit-lane read
|
||||
is an EXIT; a `"both"` barrier infers from open state (open occurrence → exit, else entry). This is
|
||||
what lets a **fleet** (`maxConcurrent > 1`) admit several cars (each entry-lane read is an entry)
|
||||
*and* exit any of them with any credential.
|
||||
- **Exit closes the OLDEST open occurrence (FIFO).** Per-car identity within a fleet isn't tracked
|
||||
(it never was, once credentials are shared) — a read closes one occurrence, oldest first. An exit
|
||||
read with nothing open is a no-op anti-passback signal (signed `anomaly`).
|
||||
- **`maxConcurrent`** enforced as a fold over the signed ledger by occurrence (`payload.permitId`
|
||||
match). Refusals (revoked / out-of-window / at-capacity / exit-with-nothing-open) are signed
|
||||
`anomaly` events.
|
||||
- **Admin CRUD** (`apps/server/src/routes/subscriptions.ts` + `apps/web/src/SubscriptionManager.tsx`):
|
||||
a subscription is an **aggregate** (row + credentials + bound plates + price). `GET
|
||||
/api/subscriptions` (any signed-in role — for lookup), `POST/PUT/DELETE /api/subscriptions[/:id]` +
|
||||
|
||||
+12
@@ -832,3 +832,15 @@ QR credentials are now AUTO-GENERATED server-side (`SUB-<15×base32>`, crypto-ra
|
||||
## [2026-06-18] feat | Subscription QR card — printed on creation + reprint, real QR rendering
|
||||
|
||||
The auto-generated subscription QR is now PRINTED so the operator can hand it to the customer. Added real 2D QR rendering to the [[rongta-printer]] driver via ESC/POS `GS ( k` (model 2, EC level M; firmware-rendered, no bitmap dep) — new `PrinterDevice.printSubscriptionCard(SubscriptionCardData)`; the card is park header → scannable QR of the code → code text (hand-key fallback) → holder + validity. Server: `printSubscriptionCard()` in booth-print.ts (booth-receipt printer, failover to dispenser); create AUTO-PRINTS best-effort (a print failure never fails the create — response returns `{printed, printError}`); new `POST /api/subscriptions/:id/print` reprint (operator-or-admin; 409 if no QR credential, 503 if no printer). Web: SubscriptionManager surfaces the print outcome on save and a "Print code" button per QR subscription; api types + i18n (sq/en). Verified on the wire via buildServer+inject + a TCP capture (9/9: auto-print, well-formed GS ( k QR bytes with the embedded code, reprint re-sends, no-QR→409). Updated [[subscription]] + [[rongta-printer]]. No migration.
|
||||
|
||||
## [2026-06-18] feat | Subscription RFID enrollment — "Read card" capture on a chosen reader
|
||||
|
||||
Enabled RFID subscription credentials with a card-enrollment flow. The operator picks a reader and presents the physical card; the value is captured into the credential instead of being typed. New in-memory `CredentialCapture` (single-shot + ~30s TTL): `arm(deviceId)`; `routes/qr-reader.ts` checks `tryConsume()` on each read — an armed reader's read is captured and NOT dispatched (no barrier for an enrolled card), then auto-disarms; reads on the OTHER reader dispatch normally, so its live entry/exit flow is never blocked. Routes (operator/admin): `GET /api/subscriptions/readers` (picker), `POST /capture/arm`, `GET /capture` (poll: idle|armed|captured|expired), `POST /capture/cancel`. Web: RFID re-enabled in the form (was disabled "soon"); "Read card" → reader picker → arm → poll → fills the value; i18n (sq/en). The GEE readers are combo QR+RFID (ID/IC/NFC), same endpoint, so one device captures both. Verified via buildServer+inject + reader-scan simulation (12/12: captured-not-dispatched, single-shot, other reader still drives a live vehicle_exit while armed, value retrievable, cancel). Updated [[subscription]]. No migration.
|
||||
|
||||
## [2026-06-18] feat | Subscriptions — enter with one credential, exit with another (+ FIFO fleets)
|
||||
|
||||
Decoupled subscription exit from the entry credential. Previously the session was keyed by the exact credential value read (an accidental coupling → must exit with the same QR/RFID you entered with). Now sessions are keyed by a per-occurrence id (`SUBSESS-<subId>-<uuid>`, the ledger `identity`; `payload.permitId`=subId), so ANY of a subscription's credentials (QR/RFID/NFC/plate) opens or closes. Direction is now decided by the BARRIER the reader sits at (entry-lane→entry, exit-lane→exit; a "both" barrier infers from open state) — this lets a FLEET (maxConcurrent>1) admit several cars (each entry-lane read is an entry) yet exit any of them with any credential; exit closes the OLDEST open occurrence (FIFO). Per-car identity within a fleet isn't tracked (never was once credentials are shared). `#openOccurrences()` replaced `#carHasOpenSession`/`#subscriptionOpenCount`. Exit with nothing open → signed anomaly (anti-passback). Verified 11/11 (enter-QR/exit-RFID + reverse, fleet 2-in mixed-credential FIFO out, capacity, anti-passback, chain intact). Updated [[subscription]] (multi-credential + entry-decoupled-from-exit). No migration.
|
||||
|
||||
## [2026-06-18] fix | Subscription occurrences in the booth — prepaid, barrier-open assist (not transient)
|
||||
|
||||
A subscription occurrence (SUBSESS-…) showed in Active Sessions but was wrongly treated as an unpaid transient: the modal tried to quote/charge it and the "open barrier" button only appeared for PAID sessions, so a subscriber with a faulty exit reader / missing card couldn't be assisted. Fix: `pay-station.ts` lookup/activeSessions now flag `subscription`/`subscriptionId`/`subscriptionHolder` from the entry payload (permit:true/permitId) and DON'T quote a subscription (amountMinor null). `exit-flow.ts` reopenBarrier now authorizes `paidAt != null || subscription` (prepaid). UI: the pay modal renders a SUBSCRIPTION mode (PREPAID badge, snapshots, single Open-barrier action, no tender/voucher) and the active row badges "abonim" + shows the holder name; both labelled by holder, not the raw key. Also SHORTENED the occurrence id (was SUBSESS-<subId>-<uuid>, ~80 chars) to `SUBSESS-<12hex>` — the subscriptionId lives in the payload (which every fold matches on), so it needn't be embedded in the key. Verified 9/9 (subscription flagged + not charged in lookup/active, reopen works without payment, unpaid-transient guard intact). Updated [[booth-exit-flow]]. No migration.
|
||||
|
||||
Reference in New Issue
Block a user