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
11 KiB
type, tags, sources, updated, status
| type | tags | sources | updated | status | ||||||
|---|---|---|---|---|---|---|---|---|---|---|
| entity |
|
2026-06-18 | 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 — 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 ofpriceMinor(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.
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
paymentevents 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 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 plainpayment(simplest, folds today) or a distinctsubscription_paymenttype (clearer in reports, but the shift/drawer fold would need to count it too). Leaning plainpayment+subscriptionIdtag. (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)? 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 |
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
< 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.
- Direction inferred from session state for that car — no open session → ENTRY (check
maxConcurrent, signvehicle_entry, open); an open session → EXIT (signvehicle_exit, open, close). A fleet has one session per car; anti-passback falls out. maxConcurrentenforced as a fold over the signed ledger (the on-chainpermitIdpayload is the match key). Refusals (revoked / out-of-window / at-capacity) 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; fee collection into the ledger is deferred (see Pricing above).
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 — a shift transaction (operator takes the monthly fee at the
booth → signed
payment→ folds into their drawer/Z-report). Deferred build; see Pricing. - Time-of-day access windows (overnight subscribers) — design + build; boundary-case policy above (see the design note).