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
@@ -7,6 +7,7 @@ import type {
PrinterDevice,
PrinterStatus,
PrintReport,
SubscriptionCardData,
TicketData,
} from "../interfaces.js";
import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
@@ -108,6 +109,37 @@ function code128(data: string): Buffer {
]);
}
// --- 2D QR symbol (printer-generated via ESC/POS GS ( k) -----------------------
// A true QR for the SUBSCRIPTION card — the subscriber scans it at the reader (which
// reads QR + 1D barcode) every entry/exit for the coverage period. The board renders
// the QR from these GS ( k commands (no bitmap, no dependency), same approach as
// code128. We also print the code as text below as the hand-key fallback. The QR
// "model 2" sequence: set model → set module size → set error-correction → store the
// data in symbol storage → print it. See ESC/POS GS ( k (function 165/167/169/180/181).
/** A QR code via ESC/POS `GS ( k`. `size` = module dot size (1–16; 6 ≈ readable on
* 80mm at short range). Error-correction level M (15%) — robust to a smudged print. */
function qrCode(data: string, size = 6): Buffer {
const bytes = Buffer.from(data, "ascii");
// pL/pH encode the data length + 3 (the cn,fn,m header bytes) for function 180.
const store = bytes.length + 3;
const pL = store & 0xff;
const pH = (store >> 8) & 0xff;
return Buffer.concat([
// fn 165: select QR model — 1d 28 6b 04 00 31 41 <model=50(2)> 00
Buffer.from([GS, 0x28, 0x6b, 0x04, 0x00, 0x31, 0x41, 0x32, 0x00]),
// fn 167: module size — 1d 28 6b 03 00 31 43 <size>
Buffer.from([GS, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x43, size]),
// fn 169: error correction level — 1d 28 6b 03 00 31 45 <49=M>
Buffer.from([GS, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x45, 0x31]),
// fn 180: store the symbol data — 1d 28 6b pL pH 31 50 30 <data>
Buffer.from([GS, 0x28, 0x6b, pL, pH, 0x31, 0x50, 0x30]),
bytes,
// fn 181: print the stored symbol — 1d 28 6b 03 00 31 51 30
Buffer.from([GS, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x51, 0x30]),
]);
}
// Ticket/receipt strings — Albanian (the site prints in Albanian for now). Kept in
// one place so a real i18n layer (per-locale tables + a t() helper) can replace this
// later without touching the render functions. See wiki/concepts/site-metadata.md.
@@ -118,6 +150,12 @@ const STR = {
issuedAt: (v: string) => `Printuar më: ${v}`,
/** "Lost your ticket? <phone>" footer; printed only when a phone is set. */
lostTicket: (phone: string) => `Keni humbur biletën? ${phone}`,
/** Subscription-card title. */
subscription: "ABONIM",
/** "Holder: <name>" line on the card. */
holder: (name: string) => `Mbajtësi: ${name}`,
/** "Valid: <from> – <to>" line on the card. */
validity: (from: string, to: string) => `Vlen: ${from} – ${to}`,
} as const;
/** Build the ESC/POS byte stream for a free-form text report (e.g. shift Z-report). */
@@ -178,6 +216,35 @@ function renderTicket(data: TicketData): Buffer {
]);
}
/** Build the ESC/POS byte stream for a SUBSCRIPTION CARD: park header → a scannable
* QR of the code → the code in text (hand-key fallback) → holder + validity window.
* The subscriber keeps this and scans the QR at the reader every entry/exit. */
function renderSubscriptionCard(data: SubscriptionCardData): Buffer {
const parts: Buffer[] = [
INIT,
SELECT_CP852,
renderHeader(data.header),
line(),
BOLD_ON,
line(STR.subscription),
BOLD_OFF,
line(),
ALIGN_CENTER,
qrCode(data.code),
line(),
// The code in text, as the fallback if the QR won't scan.
line(data.code),
ALIGN_LEFT,
line(),
];
if (data.holderName) parts.push(line(STR.holder(data.holderName)));
if (data.validFrom || data.validTo) {
parts.push(line(STR.validity(data.validFrom ?? "—", data.validTo ?? "—")));
}
parts.push(FEED_AND_CUT);
return Buffer.concat(parts);
}
/** Open a TCP socket, write the bytes, wait for flush, then close. */
function sendRaw(host: string, port: number, payload: Buffer, timeoutMs: number): Promise<void> {
return new Promise((resolve, reject) => {
@@ -320,6 +387,11 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
stubLog(this.driverId, `printed report "${report.title}" (${report.lines.length} lines)`);
}
async printSubscriptionCard(data: SubscriptionCardData): Promise<void> {
await sendRaw(this.#host, this.#port, renderSubscriptionCard(data), this.#timeout);
stubLog(this.driverId, `printed subscription card ${data.code}`);
}
/**
* Live operator-actionable status, scraped from the device's own status page.
* The board decodes the ESC/POS status bits itself, so we trust its Yes/No
+16
View File
@@ -209,12 +209,28 @@ export interface TicketData {
readonly header?: TicketHeader;
}
/** A subscription card: the customer's keepsake, printed at the booth on creation
* (and re-printable). The driver renders the `code` as a SCANNABLE QR (the
* subscriber scans it every entry/exit) plus the code as text + the holder/validity.
* See wiki/entities/subscription.md. */
export interface SubscriptionCardData {
/** The credential value to encode in the QR (e.g. "SUB-…"). */
readonly code: string;
readonly holderName?: string | null;
/** Coverage window, for the printed card (human-readable already, or ISO). */
readonly validFrom?: string | null;
readonly validTo?: string | null;
readonly header?: TicketHeader;
}
export interface PrinterDevice extends Device {
printTicket(data: TicketData): Promise<void>;
/** Print a free-form text report (a shift Z-report, a receipt). `lines` are
* printed as-is; the driver adds a header/cut. Kept generic so the business
* layer composes the content. See wiki/concepts/shift.md. */
printReport(report: PrintReport): Promise<void>;
/** Print a subscription card: a scannable QR of the code + holder/validity. */
printSubscriptionCard(data: SubscriptionCardData): Promise<void>;
}
export interface PrintReport {