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
18 KiB
type, tags, sources, updated, status
| type | tags | sources | updated | status | ||||||
|---|---|---|---|---|---|---|---|---|---|---|
| entity |
|
2026-06-18 | open |
Subscription
A subscriber: a known holder who parks on a recurring plan (e.g. 10,000 ALL / month) instead of paying per stay. The second of the "two populations" (entry-exit-readers); a valid subscription short-circuits the payment step of a parking-session (session-model). Transient is built first; subscriptions layer on top.
Renamed 2026-06-18 (was "Permit"). The operator term is subscription / abonim, not "permit / lejet". The master-data tables/routes/UI/types were renamed permit→subscription (migration
0004). The signed ledger keeps itspermitIdpayload field — that is immutable, hash-chained history, so renaming it would break verification of past events. So: code & data = "subscription"; the on-chain field name stayspermitId. See the schema note inschema.ts.
Pricing — recurring monthly plan (built 2026-06-18)
Each subscription records its own price, so an individual and a company fleet can differ:
priceMinor— the recurring price in minor units (integer; e.g.1000000= 10,000.00).null= no price set (a comp / legacy subscription).period— the billing period."monthly"only today (the enum is widened later if a site ever needs weekly/annual).currency— ISO-4217 ofpriceMinor(e.g."ALL"); required when a price is set.
A site default monthly price lives in site_config.subscription_monthly_price_minor — it
merely pre-fills the new-subscription form; each subscription still stores its own value and may
override.
Multi-month: pay N months → extend validTo (built 2026-06-18)
A customer paying for more than one month is handled by the coverage window, not by separate
records. The form takes a months count; with validFrom set, the server computes validTo = validFrom + N months (whole-month add, with day-overflow clamp — e.g. Jan 31 + 3mo → Apr 30). One
subscription row, one window. The amount the operator should collect is N × the monthly price
(the form previews end date · total); collection into the ledger is still deferred (below).
monthsis input-only — it's not stored; the stored truth isvalidFrom/validTo. Renewing for more months is just editing the window (set a newmonthsor an explicitvalidTo).- The validity check is unchanged: a session is allowed while the subscription is active and
now∈ [validFrom, validTo] — so a 3-month window simply stays valid for three months. - An explicit
validTooverride is still accepted (manual end date) whenmonthsisn't used.
Collecting the fee is a SHIFT transaction (decided 2026-06-18, deferred build)
Selling/renewing a subscription is a financial transaction a common operator makes during their shift — the subscriber pays the monthly fee at the booth like any other customer. So it is not an admin-only master-data edit; the money must land in that operator's shift: their drawer (if cash) and their shift.
The clean way (the model already supports it): collection writes a signed payment ledger event
— same shape the transient pay-station uses ({ amountMinor, currency, tender }) — at collection
time, tagged with { subscriptionId } so it's identifiable as subscription revenue.
- It folds into the shift automatically: the Z-report sums
paymentevents in[start, end]by payment time, and the drawer fold adds cash tenders (card settles to the bank) — no new summing logic needed. The fee lands in whichever shift was open when it was taken, attributed to that operator. (See shift "drawer balance".) - Admin still edits the subscription master data (price, window, credentials); the operator takes the money. Two different acts.
- A subscription's own parking-session events stay free (no per-stay
payment) — only the plan fee is a payment, decoupled from any individual stay.
Deferred build. Today we only record the agreed price + coverage window (
validFrom/validTo); no collection event is written yet, so subscription revenue does not flow into the drawer/Z-report or reconciliation. Open detail when built: whether to model it as a plainpayment(simplest, folds today) or a distinctsubscription_paymenttype (clearer in reports, but the shift/drawer fold would need to count it too). Leaning plainpayment+subscriptionIdtag. (Decision 2026-06-18: store price now, collect-in-shift later.)
Credentials (how a subscription is presented) — confirmed 2026-06-15
Recognized by a credential read at the barrier. The operator chooses the credential type per subscription. Two kinds, mapping to the two identity paths, and either can be combined with LPR/ANPR plate identity (the plate binding below):
- QR code — the only type live today (2026-06-18). Read by the optical reader — inherently
host-side (entry-exit-readers). Host decodes the QR → looks up the subscription → decides.
A subscription's QR can be printed. The new-subscription form defaults to QR.
- The code is AUTO-GENERATED server-side (
SUB-<15× base32>, crypto-random, checked globally-unique). The operator never types it and the customer can't pick it — anti-fraud (a chosen value could be guessable or collide). The UI sends a blank QR credential; the server mints the value and returns it (so the UI can print it). An RF credential, by contrast, carries the physical card id, so it is operator-entered. - Reader output = TCP/IP full string (decided 2026-06-18, the [[gee-qr-er80|host-in-the-loop QR reader]] path): the reader delivers the whole decoded string, so the code length is free (unguessable token). If a site ever wires the reader as Wiegand 26/34 instead, a scanned QR truncates to a 24-/32-bit number — the generated code would then have to be a numeric id in that range. Not our path today. (Manufacturer reader: ID/IC/NFC + QR/barcode; Wiegand 26/34 / TCP/IP / USB / RS485; 125 kHz + 13.56 MHz — one device covers QR and future RFID.)
- The card is PRINTED so the operator can hand it over. On creation the server auto-prints
a subscription card on the booth printer (rongta-printer, role
booth-receipt, failing over to the dispenser): park header → a real scannable QR of the code → the code as text (hand-key fallback) → holder + validity window. Printing is best-effort — a print failure never fails the create (the subscription + code are saved); the response returns{ printed, printError }and the UI warns + offers "Print code" (reprint viaPOST /api/subscriptions/:id/print) for a failed print / lost card / re-hand. The QR is rendered by the printer firmware via ESC/POSGS ( k(model-2, error-correction M) — added to the Rongta driver (printSubscriptionCard), no image/bitmap dependency (same approach as the Code128 ticket).
- The code is AUTO-GENERATED server-side (
- RF tag / chip / card — selectable later, NOT live yet. An RFID/proximity credential, read
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.
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 checkstryConsume(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); oncapturedit drops the value into the RFID field.POST …/capture/canceldisarms. - Verified end-to-end (12/12): captured-not-dispatched (no ledger write), single-shot, the other
reader still drives a live
vehicle_exitwhile 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 neither.
1. Car-count binding (default: 1)
- Optional. By default bound to 1 car at a time. The admin may raise the limit (a household, a company fleet) or unbind it entirely (no cap).
- The limit is on cars inside at once (
maxConcurrent), enforced over the parking-session projection: at entry, count the subscription's currently-open sessions; if< maxConcurrent(or unbound) allow, else reject. A fold over the signed ledger, not a counter someone can edit.
2. Plate binding (default: off)
- Optional. By default not plate-bound — any car may use it (identity is the card/QR). The admin may bind it to a set of specific plates; a matching plate then is an accepted identity (card/QR OR plate, not a second factor).
- Accepted tradeoff: card-OR-plate doesn't prevent card-sharing; the signed append-only-event-chain records exactly which credential/plate entered, so abuse is visible to reconciliation. Plate-spoofing (a printed plate on a different car) is caught by the opencv-anpr-service's vehicle-attribute verification, not here.
Time-of-day access windows — DESIGN NOTE, NOT YET IMPLEMENTED (2026-06-18)
A subscription may be valid only during certain hours of the day, behaving as a normal transient customer outside them. The motivating case: an overnight subscriber allowed in on their subscription 19:00 → 07:00, but charged the normal tariff if they park during the day.
Intended behaviour (to design + build later):
- The subscription carries one or more recurring daily time windows (e.g.
[{ from: "19:00", to: "07:00", days: [...] }]). Windows may wrap past midnight (19:00→07:00 spans two calendar days) — the check must handle the wrap. - At ENTRY, evaluate the window against the host clock (clock-integrity):
- inside the window → subscription entry (no ticket, no fee), exactly as today;
- outside the window → the car is treated as a normal transient: it takes a ticket and pays the tariff on the way out. The subscription is simply not used for this stay.
- The boundary cases need a decision (flagged, not resolved):
- Enters inside the window, exits outside it (parks past 07:00): is the whole stay free (entry-time decides), or is the over-window time charged transient (like tariff)? Leaning entry-time decides for simplicity, but confirm.
- Day-of-week scope (weekdays vs. weekends), holidays.
- Interaction with
maxConcurrentand plate binding (orthogonal — should still apply).
- Data: a child table (e.g.
subscription_windows) or a JSON column onsubscriptions; TBD with the implementation. Legacy precedent exists — the ParkSQL2017 schema hadMembershipPlansTime/ActiveDays(parksql2017-legacy-schema §"time-/day-restricted memberships"), confirming this is a real market need.
Explicitly postponed. For now this is documentation only — no schema, no enforcement. A subscription is valid whenever it is active and within
validFrom/validTo, all day.
Data model (as-built 2026-06-18)
Tables (mutable master data; every use still produces a signed vehicle_entry/vehicle_exit):
| Table / field | Notes |
|---|---|
subscriptions.id, holderName, contact |
the subscriber |
subscriptions.priceMinor / period / currency |
recurring plan (monthly); null price = unset |
subscriptions.maxConcurrent |
car-count binding; default 1, raise for fleets, null = unbound |
subscriptions.validFrom / validTo / status |
coverage window; active / suspended / revoked |
subscription_credentials[] |
{ kind: 'rf' | 'qr', value } |
subscription_plates[] |
bound plates (accepted identities when set) |
Interaction with the session model
- Entry: credential read → subscription lookup → valid (active, in window, plate allowed if
plate-bound, concurrent cars
< maxConcurrentif car-bound) → signedvehicle_entry(source = wiegand/qr/lpr), open barrier. No ticket, no fee. - Exit: credential/plate read → matching open subscription session → signed
vehicle_exit, open. - Lapsed mid-stay: subscription expires while parked → uncovered time falls back to the transient tariff (edge case to design — and the same question the time-window boundary raises above).
- Revoked: a revoked subscription fails the entry check → treated as transient or refused (OPEN).
As-built (2026-06-15, renamed + priced 2026-06-18)
apps/server/src/subscription-flow.ts (was permit-flow.ts), reached via the read dispatcher
(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.
- 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 ledgeridentity) withpayload.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). maxConcurrentenforced as a fold over the signed ledger by occurrence (payload.permitIdmatch). Refusals (revoked / out-of-window / at-capacity / exit-with-nothing-open) are signedanomalyevents.- 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]+POST /api/subscriptions/:id/revoke(admin only). Validation:maxConcurrentpositive int ornull;priceMinornon-negative int (currency required when set); at least one credential or one bound plate. - Pricing stored on each subscription (
priceMinor/period/currency), pre-filled fromsite_config.subscription_monthly_price_minor; fee collection into the ledger is deferred (see Pricing above).
Open questions
- Reader hardware — confirm the RF reader and QR/optical reader models (procurement; bom, open-questions).
- Lapsed-mid-stay & revoked policy (fall back to transient tariff vs. refuse) — confirm.
- Subscription-fee collection — a shift transaction (operator takes the monthly fee at the
booth → signed
payment→ folds into their drawer/Z-report). Deferred build; see Pricing. - Time-of-day access windows (overnight subscribers) — design + build; boundary-case policy above (see the design note).