Files
parking_solution/wiki/entities/subscription.md
T
julian 4fd175e0e4 docs(wiki): subscription recurring billing + party ledger — design only
Subscription page: the one-window model can't express prepaid/postpaid, calendar or
anniversary anchoring, grace or an expiry notice; and renewal is OFF-BOOK today (a PUT
that appends no payment — the same hole closed for the first sale on 2026-06-20).
Designed: plan billing rule, per-day pricing so both anchors share one formula, open-
ended agreement, subscription_periods where each period is a ledger charge and a renewal
= paying the next period, one subscriptionAccess() gate function, expiry notice derived
not stored.

New decision page party-ledger: a counterparty sub-ledger for who-owes-whom across
modules — parties + signed charge / settlement / write_off events, balance derived never
stored, aging + statements + CSV; lands postpaid subscriptions, hotel guest-nights,
fleet washes on account, supplier/utility bills. Sub-ledger only: no bookkeeping, a
statement is not a fiscal invoice, parties per appliance. validation-sponsorship's
sponsor table marked superseded; open-questions #17; index.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-09 10:19:09 +02:00

544 lines
38 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-09-08
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.
> ⚠ **Renewal is OFF-BOOK (found 2026-09-08).** "Renewing is just editing the window" means a
> renewal goes through `PUT /api/subscriptions/:id`, which by design **never re-sells and appends
> nothing to the ledger**. The first sale was put on the chain on 2026-06-20 precisely because
> 27,000 ALL had gone off-book; **every renewal since takes the same off-book path** — the
> operator collects the next month's fee and moves `validTo`, with no `payment` event. The
> recurring-billing design below closes this: a renewal becomes *paying the next billing period*,
> a signed `payment`. Until then, a renewal should be taken as a **new sale** (new subscription
> row), not an edit.
### 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-<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.
## Recurring billing — prepaid / postpaid, calendar or anniversary — DESIGN 2026-09-08
**Design only, nothing built.** Captured from a design conversation with the user (2026-09-08):
"a subscriber should prepay or postpay every month, on the 1st or on the day the subscription
began; a subscription fixed by a daily tariff, e.g. 300 ALL/day; for prepaid, a notice that a
subscription is about to expire so the owner/operator warns the subscriber to pay or lose access."
The financial side of this grew into its own page — the [[party-ledger]] — because a postpaid
subscriber is a *debtor*, and the site has other debtors (hotels, fleets) and creditors
(suppliers). This section is the subscription-shaped part.
### What is wrong with the one-window model
A subscription today is **one coverage window** (`validFrom`/`validTo`) sold once: a hotel model.
There is no recurring agreement, no due date, no grace, no unpaid balance; prepaid vs postpaid is
not expressible, and calendar-anchored billing can only be faked with hand-picked dates. And
renewal is off-book (callout above).
### Split the one row into three concepts
**1. Plan** — the catalog and versioning stay; a plan version gains a **billing rule**:
```
billing: {
mode: "prepaid" | "postpaid",
cycle: "day" | "week" | "month", // how often a period is billed
anchor: "calendar" | "start", // the 1st of the month, or the sale's anniversary
graceDays: number, // access continues this long past due
noticeDays: number // "about to expire" window
}
```
Recurring plans are **priced per day** (`period: "day"`): a calendar month costs
`daysInMonth × 300 ALL`, a partial first month is simply the days left, and **calendar and
anniversary anchoring share one formula** (proration falls out for free). Fixed-price monthly
plans (`period: "month"`) stay for sites that want a flat number. The hotel "N nights" sale is
unchanged (a `"day"` plan over a span, no billing rule).
**2. Agreement** — the `subscriptions` row: holder, credentials, cars, `validFrom`; for a
recurring plan **no `validTo`** (open-ended, ends by revoke/suspend). Fixed spans keep `validTo`.
The holder is (or is linked to) a **party** ([[party-ledger]]) — the payer, which for a hotel is
the hotel, not the guest.
**3. Billing periods** — one row per cycle, and each is a **`charge`** on the party ledger:
```
subscription_periods id, subscriptionId, periodFrom, periodTo,
amountMinor (from the plan version), currency, dueAt,
status {due|paid|overdue|waived}, chargeEventId, paymentEventId?
```
- **Paying a period** = the existing signed **`payment`** with `subscriptionSale: true` plus the
period/charge reference — drawer and Z-report keep working with no new summing
(§Collecting the fee). **Renewal is just paying the next period.** This closes the off-book hole.
- **Waiving** a period is a signed **$0 payment with a reason** — the same rule the Car Wash uses
for a comp ([[venue-modules]]: a comp never opens the barrier, sign the $0 payment) — or a
`write_off` on the party ledger; admin-gated either way.
- The next period is **generated ahead** (prepaid: before the current one ends, so it can be paid
early; postpaid: at period end, due `dueAt`), by a daily tick or lazily on read.
### The gate asks one function
The entry flow stops reading `validTo` for recurring plans and asks
`subscriptionAccess(sub, periods, now) → { ok, reason, accessUntil, daysLeft }`:
- **prepaid** — allowed while `now ≤ paidThrough + graceDays` (the next period must be paid
before it starts, plus grace);
- **postpaid** — allowed while no period is unpaid past `dueAt + graceDays`;
- both collapse to one derived **`accessUntil`** and **`daysLeft`** per subscriber (never stored).
This also answers the long-open **lapsed-mid-stay** question for recurring subs: a period ending
while a car is parked falls into **grace**, so nobody is trapped; only a subscriber still parked
past grace becomes a transient at exit (the tariff-bridge machinery above already prices that).
Revoked/suspended behaviour is unchanged.
### "About to expire" — derived, not stored
One endpoint (e.g. `GET /api/subscriptions/attention`) lists subscribers whose `accessUntil` falls
within the plan's `noticeDays`, those in grace, and those overdue. Surfaced in three places:
1. a **counter on the booth console** ([[booth-console]]);
2. a **badge in the subscriber list**;
3. a **line in the live feed when such a subscriber scans in** — "expires in 3 days" at the moment
the person is at the gate (a slip can print, best-effort like the window-charge notice).
Contacting the subscriber stays with the operator/owner by phone (`contact` field). SMS/email
is off-appliance ([[cloud-service-saas]]) — a separate decision.
### Not built, deliberately
Automatic card charging, invoices, dunning, auto-suspension without grace. **The operator still
never types a price.**
### Build order (after [[party-ledger]] step 1)
1. Billing rule on the plan version + `subscription_periods` (migration); period generation.
2. Pay-period route (signed `payment` + charge reference) and the `subscriptionAccess` gate
function in `subscription-flow.ts`; `PUT` stops moving `validTo` on recurring subs.
3. Attention endpoint + the three UI surfaces.
4. Wiki + [[booth-console]] docs.
## 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-<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`. **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** for the *first* sale
(signed `payment`, `subscriptionSale` flag, operator-chosen tender, folds into the
drawer/Z-report). **REOPENED 2026-09-08 for RENEWALS**: a renewal is a `PUT` that appends
nothing (see the callout under "Multi-month"). Closed by the recurring-billing design (a renewal
= paying the next period). Remaining sub-question: should a sale be **hard-blocked without an
open shift** (it isn't today — it warns instead)?
4. **Time-of-day access windows** (overnight subscribers) — design + build; boundary-case policy
above (see the design note).
5. **Recurring billing** (prepaid/postpaid, calendar/anniversary anchor, grace, expiry notice) —
**designed 2026-09-08, not built**; see §Recurring billing and [[party-ledger]]. To refine: is
the next period generated by a daily tick or lazily; does a waived period sign a $0 `payment` or
a `write_off` (pick one); whether `noticeDays` is per plan or per site.