Files
parking_solution/wiki/concepts/parking-session.md
T
julian 5697137c52 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
2026-06-18 13:15:04 +02:00

7.9 KiB

type, tags, sources, updated, status
type tags sources updated status
concept
parking
domain
business
anti-fraud
2026-06-15 open

Parking Session

The core business-domain entity: one vehicle's stay, from entry to exit, plus the money owed and paid for it. Everything on the business side — tariff, payment, reconciliation, revenue reporting — hangs off the session. This page defines what a session is and, just as importantly, what it is not.

Scope decision (2026-06-15): build the transient (casual, pay-for-duration) session first; layer permit holders on top as a second identity source that short-circuits payment. Mixed site, transient-first — see entry-exit-readers ("two populations, one shared relay") and session-model.

A session is a PROJECTION over the signed event log — not a mutable table

This is the single most important rule, and it falls straight out of the threat-model (the adversary is the insider who can edit the database) and the append-only-event-chain:

  • The events table is the ledger and the only source of truth. vehicle_entry, vehicle_exit, payment, void are all appended + signed, never updated or deleted.
  • A session is a read-model folded from those events — open when an entry has no matching exit, paid when a payment event references it, closed when an exit lands. It MAY be cached in a table for query speed (dashboards, "cars currently in"), but that cache is always rebuildable from the chain and never authoritative (append-only-event-chain), scaled to the business domain.
  • Why this matters: a mutable sessions row that stored "amount owed / paid" would reopen exactly the fraud hole the whole system exists to close (operator marks a session paid, pockets the cash). With sessions as a projection, "paid" is a signed payment event an operator can't forge or silently delete — a deletion breaks the chain visibly. See session-model for the rejected mutable-table alternative.

Identity — how an entry is tied to its exit

A session needs a key that survives from entry to exit. Two populations, two keys (entry-exit-readers):

  • Transient: a ticket id (printed, ideally on pre-numbered stock — see reconciliation) or a plate read by lpr-camera. This id is carried in the event's identity field.
  • 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)

Payment is decoupled from exit (decision 2026-06-15, matching the [[autonomous-direction| unmanned]] roadmap): the customer pays at a central station before walking back to the car; the exit lane only validates that the session is settled.

ENTRY (lane)      vehicle_entry event              → session OPEN
                  (ticket printed / plate read; barrier opens)
PAY  (pay station) payment event {sessionRef, fee, paidAt}
                                                    → session PAID  (grace window starts)
EXIT (lane)       validate: PAID && now ≤ paidAt + graceMinutes ?
                    yes → vehicle_exit event        → session CLOSED → pulseOpen
                    no  → reject → re-pay overstay top-up at station, then exit

States, as derived from events:

State Condition (over the event chain)
OPEN a vehicle_entry with no later matching vehicle_exit
PAID OPEN + a payment event covering the fee due, within its grace window
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 subscription at exit is itself the authorization to close.

Edge cases the model must name (not yet designed in full)

  • Overstay after payment — exited the grace window; needs a top-up payment. The one genuinely stateful rule; handled as a second payment event, fee = f(time since paid).
  • Lost ticket — no entry id to match. A default flat "lost ticket" fee (see tariff), or an amount the admin sets at the moment (operator judgement — e.g. they can establish entry time from opencv-anpr-service capture or CCTV and charge accordingly, or apply a fixed penalty). Recorded as a payment (with the chosen amount + a reason) + a void/annotation so it reconciles; the admin-set amount is captured in the signed event, attributed.
  • Manual override — an operator/admin opens the barrier for a stuck or disputed car, or writes off a session, as a deliberate act. Each is a signed, reason-coded event (barrier_open_command / a void with reason) — so an override is authorized and logged, while an open with no such signed event remains the fraud signal (append-only-event-chain). The override is the legitimate counterpart to the out-of-band-open anomaly.
  • Forced / fail-open exit — barrier failed open (fail-state-safety): the vehicle leaves with no vehicle_exit. This is an open session that never closes — a reconciliation anomaly by design (append-only-event-chain's "physical open with no signed command"), not something to paper over. (A manual override above is the signed, non-anomalous version.)
  • Re-entry / never-exited — stale open sessions (drove out tailgating, sensor missed). Surface as anomalies; never auto-close silently.

What this unblocks (build order)

The device layer left the entry flow dangling — the session domain is that next step. Schema + code follow this page and tariff; the decision is recorded in session-model.

As-built (2026-06-15)

  • Entry flow (apps/server/src/entry-flow.ts): access-device input edge → print ticket (failover) → signed vehicle_entry → pulseOpen. Holds (anomaly, no open, no entry) if printing 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 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 stays closed. Validation folds the ledger (authoritative), then updates the sessions cache.
    • Not a fail-state: an unpaid reject keeps the barrier closed deliberately (driver returns to the pay station); "exit fails open" (fail-state-safety) is about the system being unable to decide (host/power loss), not an unpaid car.
  • Pay station (apps/server/src/pay-station.ts, routes GET /api/pay/quote + POST /api/pay): look up the open session → resolve the active tariff version (latest effectiveFrom ≤ entry) → computeFee → append a signed payment event (amount, currency, tender, tariffVersionId, graceExitMin). An operator overrideMinor covers lost-ticket/dispute (recorded as the charged amount + the quoted amount). Pay-on-foot: payment is decoupled from the exit lane. PCI scope stays out of the app — tender only records cash/card; card capture is the standalone P2PE terminal.
    • The full transient loop now passes end to end (verified): entry → quote → pay → exit opens, session closed, verifyChain ok.

Resolved (2026-06-16): the earlier "no entry/exit direction" gap is closed by the entry-exit-points model. Direction lives on each access relay; readers/cameras bind to a relay and inherit it. The "lane" concept was dropped entirely (pool-of-spaces) — separate in/out readers are distinguished by their relay binding, not a lane.