feat(subscription): rename permit→subscription + monthly pricing

The "permit/lejet" feature is really a subscription. Full rename of the
mutable master data, plus a recurring monthly price.

- DB (migration 0004, data-preserving ALTER RENAME): permits→subscriptions,
  permit_credentials/_plates→subscription_*, sessions.permit_id→subscription_id.
- Pricing: per-subscription priceMinor + period(monthly) + currency, with a
  site default (site_config.subscription_monthly_price_minor) pre-filling the form.
- Server: subscription-flow.ts (SubscriptionFlow), routes/subscriptions.ts
  (/api/subscriptions). Web: SubscriptionManager, route, i18n (sq Abonimet/en).
- The signed ledger `permitId` payload is intentionally kept — immutable
  hash-chained history; renaming it would break verification of past events.

Deferred (wiki notes): fee collection into the ledger/shift (a shift-attributed
payment), LPR/ANPR plate source, time-of-day access windows (overnight subscriber).

Also carries the device-footer UI surface (api DeviceStatus, router mount,
i18n devices) due to shared-file overlap with the preceding footer commit.

Verified end-to-end on a fresh DB and migration on a live-DB copy (sessions
preserved). Live DB migrated. Full monorepo builds clean.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-18 13:15:04 +02:00
parent ca8c7f2fa2
commit 5697137c52
32 changed files with 1008 additions and 675 deletions
+3 -3
View File
@@ -14,7 +14,7 @@ session projection.
## The rule
An identity (ticket id, [[permit]] credential, or plate) **must not enter while it already has an
An identity (ticket id, [[subscription]] credential, or plate) **must not enter while it already has an
OPEN [[parking-session|session]].** At entry:
```
@@ -25,13 +25,13 @@ identify vehicle → is there already an OPEN session for this id?
This is a **fold over the signed [[append-only-event-chain]]** ("does an entry for this id exist
with no matching exit?") — not a mutable in/out flag that could be edited. Same projection that
powers [[capacity-occupancy]] and [[permit]] `maxConcurrent`.
powers [[capacity-occupancy]] and [[subscription]] `maxConcurrent`.
## Interaction with the limits already designed
- **Transient ticket** — a single ticket id is inherently one session; a second entry on the same
id is always a violation (or a re-print/duplication attempt).
- **Permit** — passback is the *per-car* case of the permit's `maxConcurrent` ([[permit]]): a
- **Permit** — passback is the *per-car* case of the permit's `maxConcurrent` ([[subscription]]): a
multi-car permit legitimately has several open sessions, but **the same car/credential** entering
twice is still a violation. So enforce per-identity, *under* the permit's concurrency allowance.
+1 -1
View File
@@ -18,7 +18,7 @@ editable and drifts; the chain is the truth). Spaces-free = `capacity − occupa
- **`capacity`** is admin-set per site (and per **zone/level** if the lot has sections — model a
`zone` on capacity + on the entry so multi-level is a later addition, not a rewrite).
- Permit concurrency (`maxConcurrent`, see [[permit]]) is the same kind of fold, scoped to one
- Permit concurrency (`maxConcurrent`, see [[subscription]]) is the same kind of fold, scoped to one
permit's open sessions.
## Full → refuse entry + FULL sign
+3 -3
View File
@@ -43,7 +43,7 @@ A session needs a key that survives from entry to exit. Two populations, two key
- **Transient:** a **ticket id** (printed, ideally on pre-numbered stock — see [[reconciliation]])
or a **plate** read by [[lpr-camera|LPR]]. This id is carried in the event's `identity` field.
- **Permit holder:** a **credential** (card / plate / QR) matched to a [[permit]] record. A valid
- **Permit holder:** a **credential** (card / plate / QR) matched to a [[subscription]] record. A valid
permit means the session owes nothing — the PAY step is skipped (see below).
## Lifecycle (pay-on-foot / pay station model)
@@ -71,7 +71,7 @@ States, as derived from events:
| **CLOSED** | a matching `vehicle_exit` event exists |
| **VOIDED** | a `void` event references the session (lost ticket written off, error correction) |
Permit sessions skip PAID: a valid [[permit]] at exit is itself the authorization to close.
Permit sessions skip PAID: a valid [[subscription]] at exit is itself the authorization to close.
## Edge cases the model must name (not yet designed in full)
@@ -106,7 +106,7 @@ follow this page and [[tariff]]; the decision is recorded in [[session-model]].
fails. See [[device-input-flow]].
- **Read dispatch** (`apps/server/src/read-dispatch.ts`): a credential read routes to the
**permit flow** if it matches a permit (card/QR/bound plate), else to the transient **exit flow**.
Lane resolved once (`readerLaneWithAccess`). See [[permit]] as-built.
Lane resolved once (`readerLaneWithAccess`). See [[subscription]] as-built.
- **Exit flow** (`apps/server/src/exit-flow.ts`): a credential **read** (the `read` bus channel) →
fold the signed ledger for that identity → validate **open + PAID + within `gracePeriodExitMin`**
→ signed `vehicle_exit` → `pulseOpen`. Unpaid / expired / unknown → signed `anomaly`, barrier
+1 -1
View File
@@ -17,7 +17,7 @@ derived and rebuildable, never a separate ledger.
- **Revenue** — by day/week/shift, by tender (cash vs. card), gross vs. discounts vs. net. Source:
`payment` events + [[validation-discounts|discount]] events + `shift_z_report` ([[shift]]).
- **Occupancy** — current ([[capacity-occupancy]]) and historical curve; peak times; turnover.
- **Stay analytics** — average/median duration, distribution; transient vs. [[permit]] split.
- **Stay analytics** — average/median duration, distribution; transient vs. [[subscription]] split.
- **Permit usage** — active permits, utilisation, concurrency vs. `maxConcurrent`.
- **Anomalies** — out-of-band opens, never-exited sessions, occupancy drift, over-validation —
the `anomaly` events + reconciliation findings ([[reconciliation]]).
+3 -1
View File
@@ -63,7 +63,9 @@ login ————————————————————————
## What End Shift does
1. Determine the shift's payment set: the signed `payment` events ([[parking-session]],
[[append-only-event-chain]]) between this shift's start mark and now.
[[append-only-event-chain]]) between this shift's start mark and now. This includes a
**[[subscription]] fee** an operator collects during the shift (sold/renewed at the booth → a
signed `payment`, deferred build) — it folds into this set like any transient taking.
2. Sum by **tender**: `cashTotal`, and `cardTotal` from the POS/terminal **if a POS is configured**
(the card line is omitted when there's no terminal).
3. Append a signed **`shift_z_report`** event (type already in `packages/shared`): `{ operator,
+4 -4
View File
@@ -10,7 +10,7 @@ status: open
How a [[parking-session]]'s fee is computed from its duration. A tariff is **admin-composed data,
not code** — the park owner builds and constantly edits the rate card at runtime (like a
[[permit]]), in a selectable currency, with **no numbers hard-coded anywhere** and no code change to
[[subscription]]), in a selectable currency, with **no numbers hard-coded anywhere** and no code change to
reprice. The computation is **pure and offline** ([[offline-first]]: no network, no clock authority
beyond the host).
@@ -144,9 +144,9 @@ production.
## Permit holders
A valid [[permit]] bypasses tariff computation entirely for the covered period (subscription
A valid [[subscription]] bypasses tariff computation entirely for the covered period (subscription
already paid out-of-band). A permit that has lapsed mid-stay falls back to the transient tariff for
the uncovered time — an edge case to design with [[permit]].
the uncovered time — an edge case to design with [[subscription]].
## Versioning — edits publish immutable, effective-dated versions
@@ -198,7 +198,7 @@ Two operator asks extend this engine; both have design pages (not yet built), gr
slicing a stay at window boundaries while keeping the block ladder + daily cap continuous.
- **Validation & sponsorship** (merchant comps, coupons, **postpaid B2B** "enter/exit free, bill the
business monthly") — see [[validation-sponsorship]]. A validation is a **typed modifier applied as a
signed event** on a transient session, distinct from a [[permit]]; postpaid sponsors accrue a
signed event** on a transient session, distinct from a [[subscription]]; postpaid sponsors accrue a
monthly-invoiced liability derivable from the chain.
## Open
+3 -3
View File
@@ -19,7 +19,7 @@ postpaid agreement whose customers enter and exit freely, billed to the business
## Why this is NOT a permit (the key distinction)
| | [[permit]] | Validation / sponsorship |
| | [[subscription]] | Validation / sponsorship |
| --- | --- | --- |
| Subject | Known in advance; carries a credential (card/QR/plate) | Anonymous walk-in; identified only by the **ticket they were issued** |
| When applied | At entry (credential opens the lane) | **After entry**, against an existing session — at a pay station, by a code, or by a sponsor rule |
@@ -74,7 +74,7 @@ validations id, session_id, sponsor_id?, type, amount_minor|minutes,
- A **postpaid** sponsor: each full-comp validation appends a row and accrues `amount` to the
sponsor; monthly invoice = sum over the period; exit is free at the lane.
- **Free entry/exit "freely"**: either the sponsor issues credentials (then it's closer to a
[[permit]] — pick that path), or customers take a normal ticket and a sponsor rule / merchant code
[[subscription]] — pick that path), or customers take a normal ticket and a sponsor rule / merchant code
comps it at exit. The agreement wording decides which; **both are expressible.**
## Reconciliation & settlement
@@ -83,7 +83,7 @@ validations id, session_id, sponsor_id?, type, amount_minor|minutes,
Statement lines trace to signed validation events → disputes resolvable against the chain.
## Open
- **"Enter/exit freely" mechanism**: sponsor-issued credentials ([[permit]]-like) vs. ticket +
- **"Enter/exit freely" mechanism**: sponsor-issued credentials ([[subscription]]-like) vs. ticket +
comp-at-exit. Likely offer both; confirm the operator's actual deal shape.
- Prepaid coupon format: printed codes (legacy) vs. QR vs. merchant web-validation portal.
- Who may apply a validation, and the **per-operator cap** (a comp is a fraud vector — bound it and
+2 -2
View File
@@ -19,7 +19,7 @@ The starting decision for the **business layer**, taken 2026-06-15 as the projec
events. A cache table is allowed for query speed but is always rebuildable and never
authoritative.
2. **Transient-first, mixed site.** Model the casual pay-for-duration session + [[tariff]] first;
layer [[permit]] holders on top as a second identity source that short-circuits payment
layer [[subscription]] holders on top as a second identity source that short-circuits payment
([[entry-exit-readers]]).
3. **Pay-on-foot / pay station.** Payment is **decoupled from exit**: the customer pays at a
central station; the exit lane only validates the session is paid and within the walk-back
@@ -48,7 +48,7 @@ pay-station and exit-validation flows. Schema (`packages/db`) + shared types fol
- Rate card, currency, grace windows, caps — operator/procurement input ([[tariff]]).
- Tariff versioning (effective-dated) for historical repricing.
- [[permit]] data model + lapsed-mid-stay handling.
- [[subscription]] data model + lapsed-mid-stay handling.
- Wire payment capture to a concrete pay-station terminal ([[open-questions]] #3) — kept abstract
(payment = an independent signed event referencing a session) until procurement settles.
- Reconciliation of sessions/payments against an external authority remains [[open-questions]] #4
+2 -2
View File
@@ -11,7 +11,7 @@ status: open
The project's **QR-code reader** (GEE NFC LIMITED). A static optical scanner for **QR /
DataMatrix / 1D barcode**, optional ID/IC card. This is the **[[ticket-encoding|QR ticket]]
scanner** the design called for — read at the pay station and exit lane — and a path for **QR
[[permit]]** credentials. On hand: variant **`-Q-W`** (QR scanner; Wiegand/RS-232/RS-485).
[[subscription]]** credentials. On hand: variant **`-Q-W`** (QR scanner; Wiegand/RS-232/RS-485).
(See [[gee-qr-er80|datasheet summary]] / `raw/`.)
## What it is (and isn't)
@@ -39,7 +39,7 @@ GET /qa/mcardsea.php?cardid=<QR>&mjihao=<devId>&cjihao=<devSN>&status=<2 chars>&
This is **host-in-the-loop and SYNCHRONOUS**: the GET *is* the access query and **our reply is the
decision** — it drives the reader's beep + output. So unlike a fire-and-forget reader, the endpoint
must decide (valid/invalid, direction from `status`) and reply, then also emit a `DeviceReadEvent`
on the `read` bus for the entry/exit/permit flows ([[parking-session]], [[permit]]) to open the
on the `read` bus for the entry/exit/permit flows ([[parking-session]], [[subscription]]) to open the
barrier. ([[device-input-flow]] is the analogous push pattern; this one also returns a verdict.)
> **This explains the "no beep":** feedback comes from the server's JSON reply, not locally. A
+3 -3
View File
@@ -17,12 +17,12 @@ recognition **host-side on ordinary IP-camera snapshots**, replacing the dedicat
1. **Identity (ANPR).** snapshot → `{ plate, confidence, bbox }`. Feeds the existing
`IdentitySource = "lpr"` ([[parking-session]]): the plate is a session/identity key and the way
a plate-bound [[permit]] is matched.
a plate-bound [[subscription]] is matched.
2. **Verification (anti-fraud witness).** snapshot → vehicle attributes — at minimum
`{ make?, model?, colour, bodyType }`, ideally a compact **visual fingerprint** (an embedding).
This is the answer to **plate-spoofing**: *a fraudster prints a registered/paid plate and drives
in with a different car.* Plate-reading alone can't catch that; comparing the **vehicle** seen at
entry vs. exit (and vs. the [[permit]]'s known car) can. A plate that entered on a red hatchback
entry vs. exit (and vs. the [[subscription]]'s known car) can. A plate that entered on a red hatchback
but exits on a black SUV is a **reconciliation anomaly** — exactly the independent-witness role
the [[append-only-event-chain]] flags as the unbuilt gap. See [[reconciliation]].
@@ -64,7 +64,7 @@ guarantee is preserved. Recorded as an explicit exception in [[standing-decision
## Anti-fraud / threat-model fit
- **Plate spoofing** (the motivating case): vehicle-attribute / fingerprint mismatch entry↔exit or
vs. a [[permit]]'s registered car → anomaly. Doesn't *block* on its own (recognition is
vs. a [[subscription]]'s registered car → anomaly. Doesn't *block* on its own (recognition is
probabilistic) — it **flags for [[reconciliation]]** and is captured in the signed record.
- The recognition result and the source image both attach to the signed [[append-only-event-chain]]
entry, so the *evidence* is tamper-evident even though recognition itself is host-side and
-149
View File
@@ -1,149 +0,0 @@
---
type: entity
tags: [parking, domain, business, subscriptions, identity]
sources: []
updated: 2026-06-15
status: open
---
# Permit (Subscription)
A **subscription**: a known holder authorized to enter/exit without paying per-stay, for a covered
period. The second of the "two populations" ([[entry-exit-readers]]); a valid permit
**short-circuits the payment step** of a [[parking-session]] ([[session-model]]). Transient is
built first; permits layer on top.
## Credentials (how a permit is presented) — confirmed with operator 2026-06-15
A permit is recognized by a credential read at the lane. Two kinds, mapping to the two identity
paths:
- **RF tag / chip / card.** An RFID/proximity credential. Read **host-side** (reader → host →
`pulseOpen`): autonomy isn't required (resolved below), and the [[dingtian-relay]] has no onboard
card list anyway, so there's no need to route RF into a controller. A Wiegand-out reader is still
fine and keeps a future autonomous path open ([[entry-exit-readers]]), but isn't required.
- **QR code.** Read by the **optical reader** — inherently **host-side** ([[entry-exit-readers]]:
pure optical/network readers are invisible to a controller). Host decodes the QR → looks up the
permit → decides.
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 permit has **two constraints the admin may or may not apply**, orthogonally. Either, both, or
neither — the four combinations are all valid.
### 1. Car-count binding (default: 1)
- **Optional.** By default a permit is bound to **1 car at a time**. The admin may raise the limit
(a household, a company fleet) or **unbind it entirely** (no cap on how many cars use it).
- The limit is on **cars inside at once** (`maxConcurrent`), enforced over the
[[parking-session]] projection: at entry, count the permit's currently-open sessions; if
`< maxConcurrent` (or unbound) allow, else reject (allowance full). This is exactly why
sessions-as-projection matters — "how many of this permit's cars are inside right now" is a fold
over open entry/exit events, **not a counter someone can edit**.
### 2. Plate binding (default: off)
- **Optional.** By default a permit is **not** plate-bound — any car may use it (identity is the
card/QR). The admin may bind it to a set of specific licence plates.
- When **bound**, an allowed plate is an **accepted identity in its own right** — a valid
**card/QR OR a matching plate** opens the lane (either, not a second factor):
```
entry: read card/QR → find permit → car-count ok → open
OR LPR plate ∈ permit's bound plates → find permit → car-count ok → open
```
- **Accepted tradeoff:** card-OR-plate is the most convenient but does **not** prevent
card-sharing (a lent card still opens). Fine for a trusted permit population; the signed
[[append-only-event-chain]] records exactly which credential/plate entered, so abuse is visible
to [[reconciliation]] after the fact.
- **Plate-spoofing defence:** a printed copy of a registered plate on a *different* car is caught
not here but by the [[opencv-anpr-service]]'s **vehicle-attribute verification** — the seen car
must reconcile with the permit's known car, not just the plate string.
> The two are independent: a plate-bound permit may have no car cap; a car-capped permit may accept
> any plate. The binding fields are simply absent/null when a constraint isn't applied.
## Data model (first cut — to firm up with [[session-model]])
A `permits` table (and supporting rows). Unlike the event log, reference/master data like permits
**is** mutable (an admin grants/revokes/renews) — but every *use* of a permit still produces a
signed `vehicle_entry`/`vehicle_exit` event in the [[append-only-event-chain]], so the audit trail
stays append-only even though the permit record itself is editable.
| Field | Notes |
| --- | --- |
| `id`, `holderName`/contact | the subscriber |
| `credentials[]` | one or more: `{ kind: 'rf' \| 'qr', value }` |
| `maxConcurrent` | car-count binding; **default 1**, raise for fleets, or `null` = unbound |
| `plates[]` | plate binding; **default empty/false** = any car; when set, these plates are accepted identities |
| `validFrom`, `validTo` | coverage window |
| `status` | active / suspended / revoked |
> Both bindings are nullable/empty by default — a bare permit is "1 car at a time, any plate,
> identified by its card/QR".
## Interaction with the session model
- **Entry:** credential read → permit 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. (A bare permit applies neither
extra check — just active + in window.)
- **Exit:** credential/plate read → matching open permit session → signed `vehicle_exit`, open. No
payment required.
- **Lapsed mid-stay:** permit expires while a car is parked → the uncovered time falls back to the
transient [[tariff]] (edge case to design).
- **Revoked:** a revoked permit fails the entry check → treated as transient (take a ticket) or
refused, per policy (OPEN).
## As-built (2026-06-15)
`apps/server/src/permit-flow.ts`, reached via the **read dispatcher**
(`read-dispatch.ts`): a credential read routes to the permit flow if it **matches a permit**
(card/QR credential, or a bound plate) — otherwise to the transient exit flow. So one read handler
serves both populations ([[entry-exit-readers]]), disambiguated by *what the credential is*.
- **Direction is inferred from session state for that car** — the read credential value is the
per-car session key. No open session for that car → **ENTRY** (check `maxConcurrent`, sign
`vehicle_entry`, open); an open session → **EXIT** (sign `vehicle_exit`, open, close). A fleet
permit thus has one session per car concurrently, and anti-passback falls out (a re-read of an
inside car is its exit, never a second entry).
- **`maxConcurrent`** is enforced as a **fold over the signed ledger** — count the permit's
`vehicle_entry` events whose car has no later exit; reject at the limit (`null` = unbound).
- **Validity** (active + within `validFrom`/`validTo`) and **plate-OR-card identity** as designed.
No ticket, no fee — the permit is the authorization; every use is still a signed ledger event
carrying `permitId`.
- Refusals (revoked / out-of-window / at-capacity) are signed `anomaly` events; the barrier stays
closed. Verified end to end (entry, inferred exit, fleet cap, plate-bound, revoked, dispatch).
**Admin CRUD** (`apps/server/src/routes/permits.ts` + `apps/web/src/PermitManager.tsx`): a permit is
an **aggregate** (the row + its credentials + bound plates); create/update treat it as one unit
(child sets are replaced on update). `GET /api/permits` (any signed-in role — for lookup),
`POST/PUT/DELETE /api/permits[/:id]` + `POST /api/permits/:id/revoke` (**admin only**). Validation:
`maxConcurrent` is a positive int or `null` (unbound); a permit must have **at least one credential
or one bound plate** (else nothing identifies it). Revoke is the soft, common case (keeps history,
barred at the barrier); DELETE hard-removes — past ledger events that reference the permit are
untouched (the audit trail is append-only and independent). Verified via inject (validation, child
replacement, RBAC, revoke/delete).
## Resolved (2026-06-15)
- **Two optional bindings, independent:** car-count (`maxConcurrent`, **default 1**, raisable or
unbound) and plate-binding (`plates[]`, **default off** = any car). Either, both, or neither.
- **Plate vs. credential:** when plate-bound, **card/QR OR matching plate** — either is accepted
identity (not a second factor); card-sharing not prevented by design, caught by
[[reconciliation]] after.
- **Autonomy:** **host-in-the-loop for everything** — no onboard card list needed, so the
[[dingtian-relay]] stays sufficient (no new controller). Permit entry **fails closed** if the
host is down ([[fail-state-safety]]). One code path for transient + permit.
## Open questions
1. **Reader hardware** — confirm the RF reader and the QR/optical reader models (procurement;
relates to [[bom]] and [[open-questions]]). RF need not be Wiegand now that autonomy isn't
required, but a Wiegand-out reader keeps options open.
2. **Lapsed-mid-stay & revoked** policy (fall back to transient [[tariff]] vs. refuse) — confirm
with operator.
+184
View File
@@ -0,0 +1,184 @@
---
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).
+5 -4
View File
@@ -1,13 +1,13 @@
---
type: overview
tags: [parking, index]
updated: 2026-06-14
updated: 2026-06-18
---
# Index
Content catalog for the wiki. Start at [[overview]]. Maintained on every ingest.
Counts: 4 sources · 19 entities · 41 concepts · 5 decision records.
Counts: 4 sources · 19 entities · 42 concepts · 5 decision records.
## Overview & navigation
- [[overview]] — the top-level synthesis and entry point.
@@ -64,6 +64,7 @@ Counts: 4 sources · 19 entities · 41 concepts · 5 decision records.
- [[barrier-not-a-door]] — never timed-close a barrier; safety lives in barrier firmware.
- [[printer-roles-failover]] — ≥2 printers by role; entry ticket falls back outside→booth.
- [[printer-status-monitoring]] — live poll of paper/cover/cutter/offline via the device's status page; SSE to the booth UI.
- [[device-status-monitoring]] — unified live status across ALL device categories (healthCheck + printer readStatus) → the booth footer over /api/ws.
- [[trust-boundary]] — the core fork: network vs. device; auditable vs. unforgeable.
- [[fail-state-safety]] — entry fails closed, exit fails open; manual override; watchdog.
@@ -92,12 +93,12 @@ Counts: 4 sources · 19 entities · 41 concepts · 5 decision records.
- [[ticket-encoding]] — transient ticket id as QR; printed at entry, scanned at pay station + exit; plate-as-ticket alt.
- [[anti-passback]] — block/flag one id entering twice without an exit; fold over open sessions.
- [[device-events]] — unsigned hardware telemetry (relay/printer/camera/reader/input); separate from the signed ledger.
- [[permit]] — subscription; RF/QR or plate identity, registered-cars + max-concurrent, host-in-loop; short-circuits payment.
- [[subscription]] — recurring plan (e.g. 10,000 ALL/month); RF/QR or plate identity, car-count + max-concurrent, host-in-loop; short-circuits payment. (Renamed from "permit"; time-of-day windows noted, deferred.)
- [[opencv-anpr-service]] — host-side vision microservice: ANPR (plate identity) + vehicle verification (anti-plate-spoofing witness).
- [[blocklist]] — barred plates/cards refused at entry (never at exit); signed, attributed.
## Concepts — frontend / operator UI
- [[booth-console]] — operator-UI architecture: TanStack Query/Router + Zustand + Tailwind terminal theme; one /api/ws live feed (anti-CSWSH).
- [[booth-console]] — operator-UI architecture: TanStack Query/Router + Zustand + Tailwind terminal theme; one /api/ws live feed (anti-CSWSH); shift control + device-status footer.
- [[i18n]] — Albanian default + English; per-user server-stored language preference (users.language), loaded on login; tickets stay Albanian.
## Dev environment (reference)
+20
View File
@@ -800,3 +800,23 @@ Audited wiki vs. the session: three major builds (live WebSocket, frontend found
## [2026-06-18] ingest | Shift gating — site-wide single-open, booth money-path gate, per-shift logs
Built the shift-enforcement model. A shift is now **site-wide single-open** (was per-operator): `ShiftService.currentOpenShift()` reads the most recent shift event on the whole chain; `open()` refuses if ANY shift is open and throws `ShiftAlreadyOpenError{heldBy}`. Login stays decoupled from shifts (operator can log in off-shift to review). The booth money path is **gated**: `/api/pay`, `/api/exit`, `/api/voucher`, `/api/barrier/reopen` get a `requireShift` preHandler → 409 `{code:"no_shift"}`; read-only lookups stay open so the modal can display + prompt. `GET /api/shift/current` now returns the site-wide `{open:{startedAt,operator},isMine}`. Logs are **per-shift** via `GET /api/events?since=<shiftStart>`. UI: header shift button (open / close-mine / disabled-when-other), pay-modal gate banner with one-click open, gated Active-Sessions re-open, shift-scoped live feed; shared `useShift()` Query invalidated by the WS on shift/cash events. Updated [[shift]] (new "Site-wide single-open + booth gate" section; superseded the per-operator as-built note) and [[booth-console]] (header control + gate). Verified the invariant + chain integrity on a fresh migrated DB (11/11 assertions). Builds clean across db/server/web.
## [2026-06-18] ingest | Device-status footer — unified monitor across all categories
Generalised printer-only status monitoring to a booth-wide DEVICE-STATUS FOOTER covering relays/readers/cameras/printers. New `DeviceMonitor` (`apps/server/src/device-monitor.ts`) polls every enabled device each tick (default 8s): printers via rich `readStatus()`, all others via the generic `healthCheck()` reachability probe, flattened to one traffic-light (ready/degraded/offline)+detail, deduped (emits on change only), fail-toward-offline (a throw/timeout → offline, never false-healthy). New `device-status` bus event + `GET /api/devices/status` snapshot; live updates ride the existing `/api/ws` (`hello` now carries the initial device set; `device-status` frame per change). Web: live-store `devices` map (setDevices/upsertDevice), WS handler wired, new `DeviceFooter` chip-per-device with an "all ready / N offline" roll-up, mounted in the app shell; `devices` i18n namespace (sq/en). The PrinterMonitor + its SSE stream stay as the printer-specific authority (both run — see the note in [[device-status-monitoring]]). Filed [[device-status-monitoring]] (resolves the code link), cross-linked [[printer-status-monitoring]] + [[booth-console]], indexed (concepts 41→42). Verified on a fresh DB (relay+reader → ready via healthCheck; unreachable printer → offline with detail, no throw; emit-once-then-silent) — 9/9; server+web build clean.
## [2026-06-18] refine | Device footer — role-only labels + click-to-see-issues
Two refinements to the device-status footer. (1) Chips label by ROLE, not vendor: the server sends a structured `roleKind` token per device (reader/camera → direction inherited from the bound relay via `directionOf()`; access → entry/exit/both, or "mixed" across relays; printer → lane/booth) and the client localises category+role → "Lexuesi hyrje", "Printer kabina", "Kamera dalje". Dropped `label`/`role`/driverId from the chip. (2) Fault detail no longer pollutes the footer: chips are compact (dot + label); a degraded/offline chip (or the "N with issues" roll-up) is clickable and opens a small issues panel above the footer listing only the problem devices with state/detail/checked-time (outside-click/Esc to close; no new dependency). i18n `devices.role.*` + issues keys (sq/en). Verified roleKind resolution on a fresh DB (access→mixed, reader(exit)→exit, camera(entry)→entry, printers→lane/booth) 7/7; server+web build clean. Updated [[device-status-monitoring]].
## [2026-06-18] fix | Stuck active session — paid ticket that never got a vehicle_exit (T-397815c0)
Investigated a paid ticket stuck forever in the Active Sessions tab. Root cause (confirmed from the live ledger): the car left via a **manual barrier re-open**, which by design signed an `anomaly` but **never a `vehicle_exit`** — so `activeSessions()` saw it as permanently `open` (the grace-expiry eviction only applied to *exited* sessions). The normal exit that would have signed the exit was refused because walk-back grace (5 min) had expired ~17h earlier. Two fixes: (1) `ExitFlow.reopenBarrier` now signs a `vehicle_exit` (`source:manual`) **when the session is still open**, closing it — while still NOT double-signing an already-exited session (phantom re-close). (2) `PayStation.activeSessions()` ages out a **paid** open session past grace even with no exit (unpaid open sessions never age out — a car owing money stays). Plus a one-off corrective: appended a signed `vehicle_exit` (index 68, `correction:true`) for T-397815c0 through EventLog (chain verified `{ok:true}`), clearing it from the list. Verified both fixes on a fresh DB (9/9; chain intact). Updated [[booth-exit-flow]] (active-session definition + the re-open rule, was "NEVER a vehicle_exit").
## [2026-06-18] ingest | Permit → Subscription rename + monthly pricing (timeframes deferred)
Renamed the "permit" feature to "subscription" (operator term: abonim) and added recurring monthly pricing. FULL rename of mutable master data: tables permits→subscriptions, permit_credentials→subscription_credentials, permit_plates→subscription_plates, sessions.permit_id→subscription_id (data-preserving ALTER RENAMEs, migration 0004); server permit-flow.ts→subscription-flow.ts (SubscriptionFlow), routes/permits.ts→routes/subscriptions.ts (/api/subscriptions), web PermitManager→SubscriptionManager, api types, i18n (sq "Abonimet"/en "Subscriptions"). The signed ledger `permitId` payload field is INTENTIONALLY kept (immutable hash-chained history — renaming would break verification of past events); code/data are "subscription", the on-chain field stays `permitId`. Pricing: per-subscription priceMinor + period("monthly") + currency, with a site default (site_config.subscription_monthly_price_minor) pre-filling the form; collecting the fee into the ledger/shift is DEFERRED (wiki note only). Time-of-day access windows (e.g. overnight subscriber 19:00–07:00, transient outside) documented as a design note in [[subscription]] — NOT implemented; legacy precedent in [[parksql2017-legacy-schema]] (MembershipPlansTime). Renamed [[entities/permit|permit]]→[[subscription]] and swept all [[permit]] wikilinks across the wiki (log.md historical entries left as-was). Verified end-to-end on a fresh migrated DB (schema+pricing, card entry/exit, maxConcurrent cap, on-chain permitId carries the sub id, chain verify) 6/6; migration also applied cleanly to a copy of the live DB (18 sessions preserved). Full monorepo builds clean.
## [2026-06-18] note | Subscription-fee collection is a SHIFT transaction
Clarified (user): collecting/renewing a subscription's monthly fee is a financial transaction a common operator makes DURING their shift — it must reflect in THAT shift's drawer + Z-report, not be an admin-only edit. Updated [[subscription]] (Pricing → "Collecting the fee is a SHIFT transaction"): model it as a signed `payment` event (same `{amountMinor,currency,tender}` shape) tagged `{subscriptionId}` at collection time, so it folds into the open shift automatically (Z-report sums payments by time; drawer adds cash tenders) with no new summing logic. Admin edits the master data; operator takes the money. Subscription entry/exit stay free — only the plan fee is a payment. Still DEFERRED build; cross-linked from [[shift]] ("What End Shift does"). Open: plain `payment`+tag vs. a distinct `subscription_payment` type (leaning plain).
+1 -1
View File
@@ -74,7 +74,7 @@ clear anti-patterns, e.g. money as `float`).
1. **Time-of-day + date windows on the rate card** (`ValidFromHour`/`ValidToHour`,
`ValidFrom`/`ValidTo`) — the shipped way to do **happy hour / seasonal**. See [[tariff-time-tiers]].
2. **Vehicle/customer category as a pricing axis** (`BA_TicketCategory`). See [[tariff-time-tiers]].
3. **Time-/day-restricted memberships** (`MembershipPlansTime`, `ActiveDays`) — a [[permit]] gap.
3. **Time-/day-restricted memberships** (`MembershipPlansTime`, `ActiveDays`) — a [[subscription]] gap.
## Anti-patterns to NOT copy
- **Money as `float`** everywhere (`Charge`, `Price`, `LostPenalty`) — drifts across a revenue