--- type: entity tags: [parking, domain, business, subscriptions, identity, pricing] sources: [] updated: 2026-06-20 aliases: [subscription-plan] 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 — config-defined PLAN catalog (re-modelled 2026-06-20) A subscription is **a priced product like a [[tariff]]**, not a hand-typed number. The operator **SELECTS an admin-defined plan over a date span**; the price is **looked up** (never typed). This fixed two flaws in the original per-row model: (1) the operator keyed the price by hand — a fat-finger (a dropped/extra zero) on a money field; (2) only `"monthly"` was expressible, so a **hotel** buying parking for a guest staying **1–N days** couldn't be priced. **The plan catalog** (`subscription_plans`, mirrors `tariff_versions` — immutable, effective-dated, admin-only): - `planId` — stable identity across versions (e.g. `"hotel-daily"`); a price change = a NEW row. - `name`, `period` (**`"day" | "week" | "month"`**), `pricePerPeriodMinor`, `currency`. - `effectiveFrom` — the latest active version with `effectiveFrom ≤ sale instant` prices a sale (the tariff-resolve rule). `active` — soft-retire (0) without deleting history. **Pricing a span** (`priceSubscriptionSpan`, pure + unit-tested in `@parking/shared`): ``` periods = ceil( (validTo − validFrom) / one plan period ) // any STARTED period is full amountMinor = periods × pricePerPeriodMinor ``` **Ceil** matches hotel/parking practice — a guest checking out mid-day still owes that day (Mon 14:00 → Wed 10:00 on a daily plan = **2** days). The hotel case is just a `"day"` plan over a check-in→check-out span. **`POST /api/subscriptions/quote`** returns this server-computed quote so the sell form shows "3 × day · 2,400 ALL" live — **the operator can't override the amount**. **Authority (admin-only):** composing the catalog needs the new **`subscription:plan`** permission (admin-grade); **selling** stays `subscription:create` (operator-grade). The operator picks; only an admin defines/edits prices. Editing a plan **publishes a new version** (new `effectiveFrom`), never mutates an old one — past sales keep their recorded `planVersionId` and reprice identically. **On the subscription row:** `priceMinor`/`currency`/`period` are now **derived from the plan** at sale, plus `planId` + `planVersionId` (which version priced it — reproducible, like a payment's `tariffVersionId`). An **update never re-sells** (price/plan frozen); a new price = a new sale. > **Version correction (admin, built 2026-06-21).** The one update that may move `planVersionId`: > an admin can re-point a sub to a **different VERSION of its SAME plan** — e.g. v2 changed the > timeframes (`days [0–6]` → weekdays-only) and an existing subscriber should be on it, or back on v1. > `PUT /api/subscriptions/:id` accepts `planVersionId`, **gated on `subscription:plan`** (plan-mgmt, > stronger than `subscription:update`; a non-privileged caller is 403'd, not silently ignored). It is > validated to belong to the sub's existing `planId` (a different plan = a different price basis = a > re-sale, refused with 400). **Price/currency/period stay frozen** — only the access rules change, > and only going forward (past signed `vehicle_entry`/`exit` events keep their own frozen > `windowTariffVersionId`, so history reprices identically). The swap is server-logged for audit (the > `subscriptions` row is mutable master data, not on the signed ledger). UI: an admin-only "Version" > picker in the edit modal, listing every version of that plan by effective date + timeframe summary. > **Superseded — per-row typed price (built 2026-06-18).** Originally each subscription stored its own > `priceMinor` + `period:"monthly"`, typed by the operator and pre-filled from > `site_config.subscription_monthly_price_minor`. That column is **kept only to seed a "Monthly" plan** > in migration `0010`; the sell path no longer reads it. The signed-`payment` sale fix (below) is > unchanged — only the *amount source* moved from "typed × months" to "plan quote". ### 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`), and that **full N-month amount is now collected as one signed `payment` at sale time** (see "Collecting the fee" below — built 2026-06-20). - `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. ### v2 — quantity, plan timeframes (tariff bridge), reserved spots (built 2026-06-20) Three enhancements driven by real scenarios (migration `0011`): **Quantity (`subscriptions.quantity`, default 1).** One subscription can cover **N cars** — a family where the husband pays once for two cars. The sale amount is `priceSubscriptionSpan(...) × quantity`; `maxConcurrent` defaults to the quantity (so both cars can be inside). The payment payload carries `quantity`. Credentials/plates for all N cars live on the one subscription. **Plan timeframes → the TARIFF BRIDGE (`subscription_plans.timeframes`).** A plan may restrict WHEN a subscriber may park (e.g. weekday allowed 20:00→08:00, weekend all-day). Instead of **refusing** out-of-window scans, the system **charges the out-of-window minutes at the normal transient [[tariff]]** — the subscriber becomes a transient customer for the time outside their window: - `PlanTimeframes` = `{ days[], fromMin, toMin, graceMin?, tz }`. The allowed window `[fromMin, toMin)` (minutes-of-local-midnight; `toMin ≤ fromMin` wraps past midnight for a night window) applies ONLY on the selected **`days`** (0=Sun..6=Sat — the **same per-day-of-week picker as the V2 [[tariff]]**, Hën–Die; empty = every day). On a day NOT in the set the subscriber parks free. `tz` is frozen in the plan version (like a V2 tariff's tz). null timeframes = 24/7, no charge ever. *(A "night plan, free weekends" is just `days:[Mon..Fri], 20:00→08:00`.)* - **An out-of-window subscriber is a transient ONLY for the minutes actually parked outside the window — the amount is NOT knowable at entry.** A night-plan subscriber (window opens 20:00) who arrives at 13:21 and leaves at 14:30 parked **~1 hour** out-of-window and owes **one hour's transient fee** — NOT the whole 13:21→20:00 gap. They may come and go several times before the window opens; each parked interval is its own short transient charge. So nothing fixed can be billed at entry. - **The owed amount is ONE live computation over the whole stay** (`windowOwedBetween` → `minutesOutsideWindow(timeframes, tz, entry, settle-time)`): the minutes within `[entry, settle]` that fall outside the allowed window, **capped at the window edges** — covering early entry AND late exit together, off-days free. Priced once as a transient duration with `computeFee` (so increments + the daily cap apply) at the active tariff version (`apps/server/src/subscription-window.ts`). Both the exit gate and the booth quote call this one function against the **current time**, so they agree and the amount reflects exactly the out-of-window minutes parked. `settle-time` is the exit-scan at the gate, and the pay-time at the booth; the **late-exit tail keeps accruing until payment** (it doesn't stop at the refused scan), so a subscriber who lingers past window-close pays for that time. - **Capping is automatic:** once a subscriber crosses INTO the window (e.g. parked 19:00→21:30 with a 20:00 open), only the 19:00→20:00 portion is charged; the in-window time is free. An early arrival who is still parked when the window opens stops accruing at window-open. - This corrected the earlier model that **stamped a FIXED `windowOwedMinor` = full gap-to-window-open at entry** (e.g. 800 ALL for 13:21→20:00) and deferred it — which over-charged anyone who left before the window opened. The fixed stamp is gone; see [[#tariff-bridge-history]]. - **Out-of-window entry opens the barrier and prints a window-bounded TICKET.** The `vehicle_entry` carries only a **marker** (`outOfWindow: true` + `windowTariffVersionId` for reproducible pricing), NO fixed amount. A **best-effort ticket slip prints** ("PARKIM — JASHTË ORARIT": entered out-of-window, *fee computed at exit*, occurrence no.) carrying the occurrence id as a **scannable Code128 + QR** — the operator scans it straight into the booth pay modal at settlement, the same scan path as a transient ticket. A missing/failed printer NEVER blocks the barrier (`printWindowChargeNotice`, fully swallowed, after the open). - **Late exit is GATED:** at exit, `owed = windowOwedBetween(entry, now) − payments`. If `> 0`, the exit is **REFUSED** with the signed reason `sub.refused.unpaidWindow`; the subscriber settles at the booth (a signed `payment` keyed to the occurrence — folds into the shift/drawer/Z-report like any taking) and re-scans. The booth pay modal surfaces it as an "OUT-OF-WINDOW" charge (`PayStation.lookup`/`pay`). So an early-entry-then-late-exit subscriber pays **both** portions in a single amount, computed when they reach the booth. > ⚠ **Exit gate vs. "never trap a vehicle."** This refusal is a **host-ONLINE business gate**, > identical in kind to the existing transient `exit.refused.unpaid`/overstay gate — a working host > *choosing* to refuse an unpaid car. The standing **fail-open** rule governs the *can't-decide* > (power/host/network loss) path, which still opens. The two are not in conflict; don't conflate them. ##### tariff-bridge history The out-of-window charge has had two superseded models, both over-charging: 1. **entry-gap + exit-gap sum** whose exit gap reached back to a *previous* day's close → a phantom ~12h on a car that had just entered early (the 4,100 ALL bug, fixed 2026-06-20 by switching to the single `windowOwedBetween(entry, now)` computation). 2. **a FIXED `windowOwedMinor` stamped at entry** = the whole gap-to-window-open (e.g. 800 ALL for a 13:21 arrival to a 20:00 window), deferred and billed at exit → over-charged anyone who left before the window opened (a 1-hour visit billed as 6.5 hours). Fixed 2026-06-21: the entry stamp is now a **marker only** (`outOfWindow` + `windowTariffVersionId`); the amount is priced live from the minutes **actually** parked out-of-window, capped at the window edges. `windowOwedMinor` and the `windowGap*`/`windowCurrency` fields remain in the `LedgerPayload` type as **deprecated, read-only** so historic signed events still type-check; they are never produced or read for pricing. **Reserved subscriber spots** — see [[capacity-occupancy]] (an admin toggle that holds a spot per active subscriber's car in the [[occupancy]] full-gate). The subscriber flow itself is never gated by "full"; reservation only tightens the *transient* gate. ### Collecting the fee is a SHIFT transaction — BUILT 2026-06-20 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 lands in **that operator's shift**: their drawer (if cash) and their [[shift|Z-report]]. > ⚠ **Why this got built — an off-book accountability hole ([[threat-model]] core path).** Until > 2026-06-20, creating a priced subscription wrote **only** the mutable `subscriptions` master row > and **appended nothing to the signed ledger**. The operator collected real cash (e.g. 10,000 ALL), > and it appeared in the live feed: **no**; the drawer: **no**; the Z-report: **no**; left any signed > trace: **no**. The `subscriptions` row records the *plan price*, not that *money changed hands* — > and it's a table the operator could even edit. So a booth operator could sell subscriptions and > pocket the money untraceably — exactly the **operator-as-adversary** path the > [[append-only-event-chain|signed append-only ledger]] exists to close. Found live: three priced > subscriptions on the appliance (27,000 ALL sold) had **zero** payment events. This is the canonical > reason "store the price" is not the same as "account for the sale." **As built** (chosen of the two options below): a subscription **sold with a price** appends a signed **`payment`** ledger event — the same shape the transient pay-station uses — at create time: - **Amount = the full sale, from the PLAN quote.** `ceil(periods) × pricePerPeriodMinor` for the selected plan over the span (e.g. 3 nights × 800 = 2,400 ALL) — looked up, never typed (re-modelled 2026-06-20; was `priceMinor × months`). The payload also carries `planId`/`planVersionId`/`periods` for audit + reproducible repricing. The ledger matches what's actually in the drawer. - **Tender is operator-chosen** (cash/card) on the create form, defaulting to cash. Cash enters the drawer; card settles to the bank — identical to the parking pay path. - **Folds into the shift automatically** — no new summing logic. The Z-report sums `payment` events in `[start, end]` by payment time; the drawer fold adds **cash** tenders. The fee lands in **whichever shift was open when taken**, attributed to that operator (recorded `operator` on the payload). - **Identifiable as subscription revenue.** The payload carries **`subscriptionSale: true`** + the subscription id (as both `identity` and `permitId`, so [[booth-console|the feed]] resolves the holder name and badges it **"subscription sale"**) + `months` for audit. - **Free/comp = no event.** A subscription with no price appends nothing (nothing was collected). - 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. - **Admin** still edits subscription master data; the act of **selling** writes the money event. **Decision on event type (resolved):** modelled as a **plain `payment` + `subscriptionSale` flag**, not a distinct `subscription_payment` type. Reusing `payment` means the existing shift/drawer/Z-report folds count it with **zero** new summing surface; the flag is enough for the feed/reports to label it. **Not hard-gated on an open shift** (deliberate — differs from the booth pay path). A subscription can be sold outside the booth money flow, so `recordSale` does **not** refuse when no shift is open; it still appends the signed payment (operator recorded) and the UI **warns** "no shift was open — open one so the takings land in a Z-report." The payment folds into any shift whose window later covers its timestamp. *(If a site wants subscription sales to be impossible without an open shift, add the `requireOpenShift` gate the `/api/pay` path uses — flagged, not done.)* > **Historical gap is not back-fillable.** The append-only ledger means the three pre-2026-06-20 > off-book sales can't be retroactively turned into dated payment events (forging back-dated signed > events is exactly what the chain forbids). Reconcile them via an operator `cash_movement` (drawer > adjustment with a reason) or a note on the next Z-report — not by inserting fake history. Verified 2026-06-20 against a copy of the live DB with the real signing modules: the sale appends a signed `payment` (30,000 ALL, 3-month, `subscriptionSale`), the **hash-chain still verifies**, and a shift window covering it picks the amount up in cash takings. ## 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 [[dingtian-dt008-reader|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 Dingtian DT-008 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) — matching is BUILT, the live SOURCE is the one missing wire.** When plate-bound (below), a matching plate read is an accepted identity — and `subscription-flow.ts` `match()` + `read-dispatch.ts` already handle a `via:"plate"` read end to end (gate + entry/exit). What's missing is the thing that EMITS a plate read from the lane camera: the **ANPR "bridge"** (a small handler in `apps/server`, not a new service) that snapshots on a camera vehicle event, runs [[opencv-anpr-service|ANPR]], and on a high-confidence match emits the plate onto the read bus. PLANNED, scoped to subscribers only. See [[lane-presence-and-anpr-entry]] for the full design + decisions. 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 Dingtian DT-008 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--`), 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` | **derived from the plan** at sale; null = comp | | `subscriptions.planId` / `planVersionId` | which plan + immutable version priced the sale (null = comp/legacy) | | `subscription_plans[]` | admin-composed plan catalog: `{ planId, name, period(day/week/month), pricePerPeriodMinor, currency, effectiveFrom, active }` — immutable versions | | `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--`, 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`. **Selling a priced subscription now appends a signed `payment`** (`subscriptionSale: true`, amount = `priceMinor × months`, operator-chosen tender) so it flows into the drawer/Z-report — built 2026-06-20 (see "Collecting the fee" above). The create response returns the recorded `{ sale }`; `subscriptionRoutes(...)` now takes the `EventLog` + `ShiftService`. ## 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**~~ — **RESOLVED + BUILT 2026-06-20.** Selling a priced subscription appends a signed `payment` (`subscriptionSale` flag, `priceMinor × months`, operator-chosen tender) that folds into the drawer/Z-report. Remaining sub-question: should a sale be **hard-blocked without an open shift** (it isn't today — it warns instead)? See "Collecting the fee". 4. **Time-of-day access windows** (overnight subscribers) — design + build; boundary-case policy above (see the design note).