Files
parking_solution/wiki/entities/subscription.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

11 KiB

type, tags, sources, updated, status
type tags sources updated status
entity
parking
domain
business
subscriptions
identity
pricing
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 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.

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 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)? 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).