--- 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. ### 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. Two kinds, mapping to the two identity paths, and **either can be combined with LPR/ANPR plate identity** (the plate binding below): - **RF tag / chip / card.** An RFID/proximity credential, read **host-side** (reader → host → `pulseOpen`). A Wiegand-out reader keeps a future autonomous path open ([[entry-exit-readers]]) but isn't required (the [[dingtian-relay]] has no onboard card list). - **QR code.** 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**. - **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. ## 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. - **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. - **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).