A subscription froze its planVersionId at sale (reproducible pricing). There was no way to move a sold sub onto a different VERSION of the SAME plan — needed when an admin publishes v2 with different timeframes (e.g. mujor-naten-cdo-dite v1 "every day" → v2 "weekdays only") and wants an existing subscriber on it, or back on v1. Backend (PUT /api/subscriptions/:id): - accept planVersionId; honored only with the subscription:plan permission (stronger than subscription:update — a plan-management action). Non-privileged caller sending a change → 403, not silently dropped. - validated to belong to the sub's EXISTING planId (a different plan = a different price basis = a re-sale → 400). - price/currency/period/planId stay frozen; only planVersionId moves. The swap is server-logged for audit (the row is mutable master data, not on the ledger). Past signed entry/exit events keep their own windowTariffVersionId, so history reprices identically — only future access uses the new version's windows. Frontend (SubscriptionManager): - pass the session user through the route (like RolesManager). - admin-only "Versioni" picker in the edit modal: lists every version of the sub's plan by effective date + a timeframe summary (days + window, or 24/7), current pre-selected. The plan itself stays read-only. Sends planVersionId only when it changed. - i18n: subs.version/versionHint/versionCurrent/versionOnlyOne/everyDay/allDay in both sq + en. Verified on a writable DB copy: version changed, price + planId frozen, cross-plan version rejected. Live DB untouched. build+lint 14/14. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
31 KiB
type, tags, sources, updated, aliases, status
| type | tags | sources | updated | aliases | status | |||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| entity |
|
2026-06-20 |
|
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 itspermitIdpayload 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 stayspermitId. See the schema note inschema.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 witheffectiveFrom ≤ sale instantprices 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/:idacceptsplanVersionId, gated onsubscription:plan(plan-mgmt, stronger thansubscription:update; a non-privileged caller is 403'd, not silently ignored). It is validated to belong to the sub's existingplanId(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 signedvehicle_entry/exitevents keep their own frozenwindowTariffVersionId, so history reprices identically). The swap is server-logged for audit (thesubscriptionsrow 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 fromsite_config.subscription_monthly_price_minor. That column is kept only to seed a "Monthly" plan in migration0010; the sell path no longer reads it. The signed-paymentsale 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).
monthsis input-only — it's not stored; the stored truth isvalidFrom/validTo. Renewing for more months is just editing the window (set a newmonthsor an explicitvalidTo).- 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
validTooverride is still accepted (manual end date) whenmonthsisn'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 ≤ fromMinwraps past midnight for a night window) applies ONLY on the selecteddays(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.tzis frozen in the plan version (like a V2 tariff's tz). null timeframes = 24/7, no charge ever. (A "night plan, free weekends" is justdays:[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 withcomputeFee(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-timeis 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_entrycarries only a marker (outOfWindow: true+windowTariffVersionIdfor 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 reasonsub.refused.unpaidWindow; the subscriber settles at the booth (a signedpaymentkeyed 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:
- 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). - a FIXED
windowOwedMinorstamped 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.windowOwedMinorand thewindowGap*/windowCurrencyfields remain in theLedgerPayloadtype 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.
⚠ 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
subscriptionsmaster 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. Thesubscriptionsrow 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 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) × pricePerPeriodMinorfor the selected plan over the span (e.g. 3 nights × 800 = 2,400 ALL) — looked up, never typed (re-modelled 2026-06-20; waspriceMinor × months). The payload also carriesplanId/planVersionId/periodsfor 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
paymentevents 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 (recordedoperatoron the payload). - Identifiable as subscription revenue. The payload carries
subscriptionSale: true+ the subscription id (as bothidentityandpermitId, so booth-console resolves the holder name and badges it "subscription sale") +monthsfor audit. - Free/comp = no event. A subscription with no price appends nothing (nothing was collected).
- A subscription's own parking-session 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 [[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 viaPOST /api/subscriptions/:id/print) for a failed print / lost card / re-hand. The QR is rendered by the printer firmware via ESC/POSGS ( k(model-2, error-correction M) — added to the Rongta driver (printSubscriptionCard), no image/bitmap dependency (same approach as the Code128 ticket).
- The code is AUTO-GENERATED server-side (
- 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 checkstryConsume(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); oncapturedit drops the value into the RFID field.POST …/capture/canceldisarms. - Verified end-to-end (12/12): captured-not-dispatched (no ledger write), single-shot, the other
reader still drives a live
vehicle_exitwhile 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)? Leaning entry-time decides for simplicity, but confirm.
- Day-of-week scope (weekdays vs. weekends), holidays.
- Interaction with
maxConcurrentand plate binding (orthogonal — should still apply).
- Data: a child table (e.g.
subscription_windows) or a JSON column onsubscriptions; TBD with the implementation. Legacy precedent exists — the ParkSQL2017 schema hadMembershipPlansTime/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
< maxConcurrentif car-bound) → signedvehicle_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 ledgeridentity) withpayload.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). maxConcurrentenforced as a fold over the signed ledger by occurrence (payload.permitIdmatch). Refusals (revoked / out-of-window / at-capacity / exit-with-nothing-open) are signedanomalyevents.- 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:maxConcurrentpositive int ornull;priceMinornon-negative int (currency required when set); at least one credential or one bound plate. - Pricing stored on each subscription (
priceMinor/period/currency), pre-filled fromsite_config.subscription_monthly_price_minor. Selling a priced subscription now appends a signedpayment(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 theEventLog+ShiftService.
Open questions
- Reader hardware — confirm the RF reader and QR/optical reader models (procurement; bom, open-questions).
- Lapsed-mid-stay & revoked policy (fall back to transient tariff vs. refuse) — confirm.
Subscription-fee collection— RESOLVED + BUILT 2026-06-20. Selling a priced subscription appends a signedpayment(subscriptionSaleflag,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".- Time-of-day access windows (overnight subscribers) — design + build; boundary-case policy above (see the design note).