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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
Reference in New Issue
Block a user