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
This commit is contained in:
2026-06-18 14:48:38 +02:00
parent 5697137c52
commit bba988c4e8
11 changed files with 477 additions and 32 deletions
+8 -1
View File
@@ -38,7 +38,14 @@ many ESC/POS-compatible OEM clones that share its firmware). Driver `rongta` in
## Ticket rendering
`printTicket(TicketData)` builds ESC/POS: `ESC @` init, centered/bold/double-size header,
lane, ticket id, issued-at, feed + partial cut (`GS V B`). CP437/ASCII subset.
lane, ticket id, issued-at, feed + partial cut (`GS V B`). CP437/ASCII subset. The entry ticket
encodes the id as a **1D Code128** barcode (`GS k`).
**`printSubscriptionCard(SubscriptionCardData)`** (added 2026-06-18) renders a **2D QR** of the
[[subscription]] code via ESC/POS **`GS ( k`** (model 2, EC level M) — firmware-rendered, no bitmap
dependency — plus the code as text + holder/validity. Used for the auto-printed + reprintable
subscription card. (Verified: the `GS ( k` store/print byte sequences + the embedded code appear on
the wire against a TCP capture.)
## Status
+45 -7
View File
@@ -33,6 +33,20 @@ A **site default monthly price** lives in `site_config.subscription_monthly_pric
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
@@ -62,14 +76,38 @@ time, tagged with `{ subscriptionId }` so it's identifiable as subscription reve
## 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):
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):
- **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**.
- **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.
+12
View File
@@ -820,3 +820,15 @@ Renamed the "permit" feature to "subscription" (operator term: abonim) and added
## [2026-06-18] note | Subscription-fee collection is a SHIFT transaction
Clarified (user): collecting/renewing a subscription's monthly fee is a financial transaction a common operator makes DURING their shift — it must reflect in THAT shift's drawer + Z-report, not be an admin-only edit. Updated [[subscription]] (Pricing → "Collecting the fee is a SHIFT transaction"): model it as a signed `payment` event (same `{amountMinor,currency,tender}` shape) tagged `{subscriptionId}` at collection time, so it folds into the open shift automatically (Z-report sums payments by time; drawer adds cash tenders) with no new summing logic. Admin edits the master data; operator takes the money. Subscription entry/exit stay free — only the plan fee is a payment. Still DEFERRED build; cross-linked from [[shift]] ("What End Shift does"). Open: plain `payment`+tag vs. a distinct `subscription_payment` type (leaning plain).
## [2026-06-18] note | Subscription credential type — operator chooses, QR-only for now
The subscription form lets the operator choose the credential type; for now only QR is live. UI change only: the new-credential default is now QR (was RF), and the RFID option is shown DISABLED ("soon", `subs.rfCardTagSoon`) so the choice is visible. Backend + schema keep accepting `kind:'rf'|'qr'` unchanged — re-enabling RFID later is just dropping `disabled` (no migration). Updated [[subscription]] Credentials section.
## [2026-06-18] feat | Subscription QR auto-generation + multi-month coverage
QR credentials are now AUTO-GENERATED server-side (`SUB-<15×base32>`, crypto-random, globally-unique-checked) — the operator/customer never picks the code (anti-fraud); the UI sends a blank QR credential and the server mints+returns the value to print. RF credentials still carry the operator-entered card id. Reader output decided = TCP/IP full string (host-in-the-loop), so the code length is free; noted the Wiegand-26/34 numeric-truncation alternative if ever wired that way (+ the manufacturer reader's ID/IC/NFC+QR / Wiegand/TCP/USB/RS485 / 125kHz+13.56MHz spec — one device covers QR and future RFID). Multi-month: the form takes a `months` count → server sets `validTo = validFrom + N months` (day-clamp), one record/one window, total = N×monthly (collection still deferred); explicit `validTo` override still works; `months` is input-only (truth is validFrom/validTo). Backend: routes/subscriptions.ts (newQrCode/addMonths/resolveValidTo, validate RF-needs-value + months-needs-validFrom). Web: SubscriptionManager (QR shown auto-gen/read-only, months field + live coverage+total preview), api types, i18n (sq/en). Verified via buildServer+inject 9/9 (autogen, uniqueness, RF-blank reject, Jan31+3mo→Apr30, supplied-value preserved). Updated [[subscription]]. No new migration (uses existing columns).
## [2026-06-18] feat | Subscription QR card — printed on creation + reprint, real QR rendering
The auto-generated subscription QR is now PRINTED so the operator can hand it to the customer. Added real 2D QR rendering to the [[rongta-printer]] driver via ESC/POS `GS ( k` (model 2, EC level M; firmware-rendered, no bitmap dep) — new `PrinterDevice.printSubscriptionCard(SubscriptionCardData)`; the card is park header → scannable QR of the code → code text (hand-key fallback) → holder + validity. Server: `printSubscriptionCard()` in booth-print.ts (booth-receipt printer, failover to dispenser); create AUTO-PRINTS best-effort (a print failure never fails the create — response returns `{printed, printError}`); new `POST /api/subscriptions/:id/print` reprint (operator-or-admin; 409 if no QR credential, 503 if no printer). Web: SubscriptionManager surfaces the print outcome on save and a "Print code" button per QR subscription; api types + i18n (sq/en). Verified on the wire via buildServer+inject + a TCP capture (9/9: auto-print, well-formed GS ( k QR bytes with the embedded code, reprint re-sends, no-QR→409). Updated [[subscription]] + [[rongta-printer]]. No migration.