b8ddda86e7
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
269 lines
18 KiB
Markdown
269 lines
18 KiB
Markdown
---
|
||
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).
|