Files
parking_solution/wiki/entities/subscription.md
T
julian b8ddda86e7 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
2026-06-18 16:26:48 +02:00

269 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
type: entity
tags: [parking, domain, business, subscriptions, identity, pricing]
sources: []
updated: 2026-06-18
status: 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 its `permitId` payload 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 stays `permitId`.* See the schema note in `schema.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 of `priceMinor` (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).
- `months` is **input-only** — it's not stored; the stored truth is `validFrom`/`validTo`. Renewing
for more months is just editing the window (set a new `months` or an explicit `validTo`).
- 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 **`validTo` override** is still accepted (manual end date) when `months` isn'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|Z-report]].
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 `payment` events 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|entry/exit]] 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
> plain `payment` (simplest, folds today) or a distinct `subscription_payment` type (clearer in
> reports, but the shift/drawer fold would need to count it too). Leaning **plain `payment` +
> `subscriptionId` tag**. (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 via `POST /api/subscriptions/:id/print`) for a
failed print / lost card / re-hand. The QR is rendered by the printer firmware via ESC/POS
**`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`). **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 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
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|lapsed-mid-stay]])? Leaning **entry-time decides** for simplicity, but confirm.
- *Day-of-week scope* (weekdays vs. weekends), holidays.
- Interaction with `maxConcurrent` and plate binding (orthogonal — should still apply).
- **Data:** a child table (e.g. `subscription_windows`) or a JSON column on `subscriptions`; TBD with
the implementation. Legacy precedent exists — the ParkSQL2017 schema had
`MembershipPlansTime` / `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 `< maxConcurrent` **if car-bound**) → signed `vehicle_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 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]` +
`POST /api/subscriptions/:id/revoke` (**admin only**). Validation: `maxConcurrent` positive int or
`null`; `priceMinor` non-negative int (currency required when set); at least one credential or one
bound plate.
- **Pricing** stored on each subscription (`priceMinor`/`period`/`currency`), pre-filled from
`site_config.subscription_monthly_price_minor`; **fee collection into the ledger is deferred**
(see Pricing above).
## Open questions
1. **Reader hardware** — confirm the RF reader and QR/optical reader models (procurement; [[bom]],
[[open-questions]]).
2. **Lapsed-mid-stay & revoked** policy (fall back to transient [[tariff]] vs. refuse) — confirm.
3. **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.
4. **Time-of-day access windows** (overnight subscribers) — design + build; boundary-case policy
above (see the design note).