Files
parking_solution/wiki/entities/subscription.md
T
julian bba988c4e8 feat(subscription): QR credentials — operator-choose (QR-only now), auto-generate, multi-month, printed card
Builds out subscription credentials on top of the rename.

- Operator chooses the credential type; only QR is live (RFID shown disabled
  "soon"). Backend/schema keep accepting both — re-enabling RFID is UI-only.
- QR codes are AUTO-GENERATED server-side (SUB-<base32>, crypto-random,
  globally-unique-checked) — the customer/operator never picks the value.
  RF stays operator-entered (the physical card id). Reader output decided =
  TCP/IP full string (Wiegand-numeric fallback noted).
- Multi-month: form takes a `months` count → server sets validTo =
  validFrom + N months (day-clamp); one record/one window; total = N×monthly.
- The QR card is PRINTED so the operator can hand it over: real ESC/POS 2D QR
  (GS ( k) added to the Rongta driver (printSubscriptionCard); auto-print on
  create (best-effort — never fails the create; returns {printed,printError})
  + reprint via POST /api/subscriptions/:id/print and a "Print code" button.

Verified via buildServer+inject incl. a TCP capture of the on-wire QR bytes
(autogen+uniqueness, Jan31+3mo→Apr30, auto-print, GS ( k QR with embedded
code, reprint, no-QR→409). Updated wiki (subscription, rongta-printer). No
migration.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-18 14:48:38 +02:00

15 KiB
Raw Blame History

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.

Multi-month: pay N months → extend validTo (built 2026-06-18)

A customer paying for more than one month is handled by the coverage window, not by separate records. The form takes a months count; with validFrom set, the server computes validTo = validFrom + N months (whole-month add, with day-overflow clamp — e.g. Jan 31 + 3mo → Apr 30). One subscription row, one window. The amount the operator should collect is N × the monthly price (the form previews end date · total); collection into the ledger is still deferred (below).

  • months is input-only — it's not stored; the stored truth is validFrom/validTo. Renewing for more months is just editing the window (set a new months or an explicit validTo).
  • The validity check is unchanged: a session is allowed while the subscription is active and now ∈ [validFrom, validTo] — so a 3-month window simply stays valid for three months.
  • An explicit validTo override is still accepted (manual end date) when months isn't used.

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. The operator chooses the credential type per subscription. Two kinds, mapping to the two identity paths, and either can be combined with LPR/ANPR plate identity (the plate binding below):

  • QR code — the only type live today (2026-06-18). 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. The new-subscription form defaults to QR.
    • The code is AUTO-GENERATED server-side (SUB-<15× base32>, crypto-random, checked globally-unique). The operator never types it and the customer can't pick it — anti-fraud (a chosen value could be guessable or collide). The UI sends a blank QR credential; the server mints the value and returns it (so the UI can print it). An RF credential, by contrast, carries the physical card id, so it is operator-entered.
    • Reader output = TCP/IP full string (decided 2026-06-18, the [[gee-qr-er80|host-in-the-loop QR reader]] path): the reader delivers the whole decoded string, so the code length is free (unguessable token). If a site ever wires the reader as Wiegand 26/34 instead, a scanned QR truncates to a 24-/32-bit number — the generated code would then have to be a numeric id in that range. Not our path today. (Manufacturer reader: ID/IC/NFC + QR/barcode; Wiegand 26/34 / TCP/IP / USB / RS485; 125 kHz + 13.56 MHz — one device covers QR and future RFID.)
    • The card is PRINTED so the operator can hand it over. On creation the server auto-prints a subscription card on the booth printer (rongta-printer, role booth-receipt, failing over to the dispenser): park header → a real scannable QR of the code → the code as text (hand-key fallback) → holder + validity window. Printing is best-effort — a print failure never fails the create (the subscription + code are saved); the response returns { printed, printError } and the UI warns + offers "Print code" (reprint via POST /api/subscriptions/:id/print) for a failed print / lost card / re-hand. The QR is rendered by the printer firmware via ESC/POS GS ( k (model-2, error-correction M) — added to the Rongta driver (printSubscriptionCard), no image/bitmap dependency (same approach as the Code128 ticket).
  • RF tag / chip / card — selectable later, NOT live yet. An RFID/proximity credential, read host-side (reader → host → pulseOpen). The data model + backend already accept kind:'rf' (no migration needed to enable it); only the UI constrains the operator to QR for now — the RFID option is shown disabled ("soon") so the choice is visible. A Wiegand-out reader keeps a future autonomous path open (entry-exit-readers); the dingtian-relay has no onboard card list.
  • 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).