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:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user