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
15 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.
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).
monthsis input-only — it's not stored; the stored truth isvalidFrom/validTo. Renewing for more months is just editing the window (set a newmonthsor an explicitvalidTo).- 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
validTooverride is still accepted (manual end date) whenmonthsisn'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
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. 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 viaPOST /api/subscriptions/:id/print) for a failed print / lost card / re-hand. The QR is rendered by the printer firmware via ESC/POSGS ( k(model-2, error-correction M) — added to the Rongta driver (printSubscriptionCard), no image/bitmap dependency (same approach as the Code128 ticket).
- The code is AUTO-GENERATED server-side (
- RF tag / chip / card — selectable later, NOT live yet. An RFID/proximity credential, read
host-side (reader → host →
pulseOpen). The data model + backend already acceptkind:'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
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).