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
+26
View File
@@ -79,3 +79,29 @@ export async function printExitVoucher(
logger.info(`exit voucher for ${ticketId} printed on ${printedBy}`); logger.info(`exit voucher for ${ticketId} printed on ${printedBy}`);
return printedBy; return printedBy;
} }
/**
* Print a SUBSCRIPTION CARD on the booth printer (failing over to the dispenser):
* a scannable QR of the credential code + holder/validity, so the operator can hand
* it to the customer. Used on subscription creation and on a "reprint" action.
* Returns the printer that printed it; throws NoPrinterAvailableError if none can.
*/
export async function printSubscriptionCard(
db: Db,
card: { code: string; holderName?: string | null; validFrom?: string | null; validTo?: string | null },
logger: FastifyBaseLogger,
): Promise<string> {
const printers = loadPrinters(db);
const data = {
code: card.code,
holderName: card.holderName ?? null,
validFrom: card.validFrom ?? null,
validTo: card.validTo ?? null,
header: ticketHeader(db),
};
const printedBy = await printWithFailover(printers, "booth-receipt", (d: PrinterDevice) =>
d.printSubscriptionCard(data),
);
logger.info(`subscription card ${card.code} printed on ${printedBy}`);
return printedBy;
}
+137 -9
View File
@@ -1,7 +1,9 @@
import { randomUUID } from "node:crypto"; import { randomBytes, randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { eq, subscriptionCredentials, subscriptionPlates, subscriptions, type Db } from "@parking/db"; import { eq, subscriptionCredentials, subscriptionPlates, subscriptions, type Db } from "@parking/db";
import { NoPrinterAvailableError } from "@parking/devices";
import { requireRole } from "../auth.js"; import { requireRole } from "../auth.js";
import { printSubscriptionCard } from "../booth-print.js";
// Subscription admin CRUD. A subscription is mutable master data — admins // Subscription admin CRUD. A subscription is mutable master data — admins
// grant/edit/revoke — but every USE of it is a signed ledger event, so the audit // grant/edit/revoke — but every USE of it is a signed ledger event, so the audit
@@ -15,7 +17,9 @@ import { requireRole } from "../auth.js";
interface Credential { interface Credential {
kind: "rf" | "qr"; kind: "rf" | "qr";
value: string; /** For RF: the physical card/tag id (required). For QR: optional — left blank, the
* server AUTO-GENERATES an unguessable code (the customer never picks it). */
value?: string;
} }
interface SubscriptionBody { interface SubscriptionBody {
holderName?: string; holderName?: string;
@@ -29,12 +33,37 @@ interface SubscriptionBody {
maxConcurrent?: number | null; maxConcurrent?: number | null;
validFrom?: string | null; validFrom?: string | null;
validTo?: string | null; validTo?: string | null;
/** Months paid for. When set (with validFrom), validTo = validFrom + months — the
* multi-month case (e.g. 3 months). Takes precedence over an explicit validTo. */
months?: number | null;
status?: "active" | "suspended" | "revoked"; status?: "active" | "suspended" | "revoked";
credentials?: Credential[]; credentials?: Credential[];
/** Plate binding (optional): bound plates that also serve as identity. */ /** Plate binding (optional): bound plates that also serve as identity. */
plates?: string[]; plates?: string[];
} }
/** Mint an unguessable QR credential value. Namespaced + crypto-random; the reader
* delivers the full string over TCP/IP (the host-in-the-loop path), so length is
* free. base32 (Crockford-ish, no 0/1/O/I ambiguity), uppercased. */
function newQrCode(): string {
const alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
const bytes = randomBytes(15);
let out = "";
for (const b of bytes) out += alphabet[b % 32];
return `SUB-${out}`;
}
/** Add whole months to an ISO datetime, clamping day overflow (e.g. Jan 31 +1mo →
* Feb 28/29). Returns ISO. */
function addMonths(iso: string, months: number): string {
const d = new Date(iso);
const day = d.getUTCDate();
d.setUTCMonth(d.getUTCMonth() + months);
// If the month rolled past (e.g. day 31 → next month had fewer days), clamp back.
if (d.getUTCDate() < day) d.setUTCDate(0);
return d.toISOString();
}
export async function subscriptionRoutes(app: FastifyInstance, db: Db): Promise<void> { export async function subscriptionRoutes(app: FastifyInstance, db: Db): Promise<void> {
// Admin manages subscriptions; operator/cashier/readonly may LIST (to look one up). // Admin manages subscriptions; operator/cashier/readonly may LIST (to look one up).
const readGuard = requireRole("admin", "operator", "cashier", "readonly"); const readGuard = requireRole("admin", "operator", "cashier", "readonly");
@@ -59,12 +88,25 @@ export async function subscriptionRoutes(app: FastifyInstance, db: Db): Promise<
if (b.period != null && b.period !== "monthly") { if (b.period != null && b.period !== "monthly") {
errs.push("period must be 'monthly' (the only period supported today)"); errs.push("period must be 'monthly' (the only period supported today)");
} }
if (b.months != null) {
if (!Number.isInteger(b.months) || b.months < 1) {
errs.push("months must be a positive integer");
}
if (!b.validFrom?.trim()) {
errs.push("validFrom is required when months is set (validTo = validFrom + months)");
}
}
if (b.status && !["active", "suspended", "revoked"].includes(b.status)) { if (b.status && !["active", "suspended", "revoked"].includes(b.status)) {
errs.push("status must be active|suspended|revoked"); errs.push("status must be active|suspended|revoked");
} }
for (const c of b.credentials ?? []) { for (const c of b.credentials ?? []) {
if ((c.kind !== "rf" && c.kind !== "qr") || !c.value?.trim()) { if (c.kind !== "rf" && c.kind !== "qr") {
errs.push("each credential needs kind (rf|qr) and a non-empty value"); errs.push("each credential needs kind (rf|qr)");
break;
}
// RF must carry the physical card id; QR may be blank (server auto-generates).
if (c.kind === "rf" && !c.value?.trim()) {
errs.push("an RF credential needs a non-empty value (the card/tag id)");
break; break;
} }
} }
@@ -86,18 +128,47 @@ export async function subscriptionRoutes(app: FastifyInstance, db: Db): Promise<
}; };
} }
// Replace a subscription's child rows (credentials + plates) from the body. /** Is this credential value already used by ANY subscription? (Global uniqueness —
* a value is the lane identity, so it must resolve to one subscription.) */
function valueTaken(value: string): boolean {
return db.select().from(subscriptionCredentials).where(eq(subscriptionCredentials.value, value)).get() != null;
}
/** A fresh, collision-free QR code (retries on the astronomically unlikely clash). */
function mintQrCode(): string {
for (let i = 0; i < 5; i += 1) {
const code = newQrCode();
if (!valueTaken(code)) return code;
}
throw new Error("could not mint a unique QR code");
}
// Replace a subscription's child rows (credentials + plates) from the body. QR
// credentials with no value are SERVER-GENERATED here (the customer never picks the
// code). The generated value is returned via loadAggregate so the UI can print it.
function writeChildren(id: string, b: SubscriptionBody) { function writeChildren(id: string, b: SubscriptionBody) {
db.delete(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, id)).run(); db.delete(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, id)).run();
db.delete(subscriptionPlates).where(eq(subscriptionPlates.subscriptionId, id)).run(); db.delete(subscriptionPlates).where(eq(subscriptionPlates.subscriptionId, id)).run();
for (const c of b.credentials ?? []) { for (const c of b.credentials ?? []) {
db.insert(subscriptionCredentials).values({ id: randomUUID(), subscriptionId: id, kind: c.kind, value: c.value.trim() }).run(); const supplied = c.value?.trim();
// QR + blank → auto-generate; otherwise use the supplied value (RF card id, or a
// QR being preserved on edit).
const value = supplied && supplied.length > 0 ? supplied : c.kind === "qr" ? mintQrCode() : "";
if (!value) continue; // guarded by validate(); defensive
db.insert(subscriptionCredentials).values({ id: randomUUID(), subscriptionId: id, kind: c.kind, value }).run();
} }
for (const p of b.plates ?? []) { for (const p of b.plates ?? []) {
if (p.trim()) db.insert(subscriptionPlates).values({ id: randomUUID(), subscriptionId: id, plate: p.trim() }).run(); if (p.trim()) db.insert(subscriptionPlates).values({ id: randomUUID(), subscriptionId: id, plate: p.trim() }).run();
} }
} }
/** Resolve the coverage end: months (validFrom + months) wins over an explicit validTo. */
function resolveValidTo(b: SubscriptionBody, fallback: string | null): string | null {
if (b.months != null && b.validFrom?.trim()) return addMonths(b.validFrom.trim(), b.months);
if (b.validTo !== undefined) return b.validTo ?? null;
return fallback;
}
// List all subscriptions (with their credentials + plates). // List all subscriptions (with their credentials + plates).
app.get("/api/subscriptions", { preHandler: readGuard }, async () => { app.get("/api/subscriptions", { preHandler: readGuard }, async () => {
const rows = db.select().from(subscriptions).all(); const rows = db.select().from(subscriptions).all();
@@ -120,14 +191,46 @@ export async function subscriptionRoutes(app: FastifyInstance, db: Db): Promise<
currency: b.priceMinor != null ? (b.currency ?? null) : null, currency: b.priceMinor != null ? (b.currency ?? null) : null,
maxConcurrent: b.maxConcurrent === undefined ? 1 : b.maxConcurrent, maxConcurrent: b.maxConcurrent === undefined ? 1 : b.maxConcurrent,
validFrom: b.validFrom ?? null, validFrom: b.validFrom ?? null,
validTo: b.validTo ?? null, validTo: resolveValidTo(b, null),
status: b.status ?? "active", status: b.status ?? "active",
}) })
.run(); .run();
writeChildren(id, b); writeChildren(id, b);
return reply.code(201).send(loadAggregate(id)); const sub = loadAggregate(id);
// Auto-print the QR card so the operator can hand it to the customer. Best-effort:
// a print failure NEVER fails the create (the subscription + its code are saved);
// the response carries { printed, printError } so the UI can warn + offer reprint.
const printResult = await tryPrintCard(sub);
return reply.code(201).send({ ...sub, ...printResult });
}); });
/** The first QR credential's code for a subscription aggregate, or null. */
function qrCodeOf(sub: ReturnType<typeof loadAggregate>): string | null {
const cred = sub?.credentials.find((c) => c.kind === "qr");
return cred?.value ?? null;
}
/** Best-effort print of a subscription's QR card. Returns a flag + optional error
* (never throws). No QR credential → nothing to print (printed:false, no error). */
async function tryPrintCard(
sub: ReturnType<typeof loadAggregate>,
): Promise<{ printed: boolean; printedBy?: string; printError?: string }> {
const code = qrCodeOf(sub);
if (!sub || !code) return { printed: false };
try {
const printedBy = await printSubscriptionCard(
db,
{ code, holderName: sub.holderName, validFrom: sub.validFrom, validTo: sub.validTo },
app.log,
);
return { printed: true, printedBy };
} catch (err) {
const printError = err instanceof NoPrinterAvailableError ? err.message : (err as Error).message;
app.log.warn(`subscription card print failed for ${sub.id}: ${printError}`);
return { printed: false, printError };
}
}
// Update a subscription (replaces fields + child sets). // Update a subscription (replaces fields + child sets).
app.put<{ Params: { id: string }; Body: SubscriptionBody }>( app.put<{ Params: { id: string }; Body: SubscriptionBody }>(
"/api/subscriptions/:id", "/api/subscriptions/:id",
@@ -152,7 +255,7 @@ export async function subscriptionRoutes(app: FastifyInstance, db: Db): Promise<
: null, : null,
maxConcurrent: b.maxConcurrent === undefined ? existing.maxConcurrent : b.maxConcurrent, maxConcurrent: b.maxConcurrent === undefined ? existing.maxConcurrent : b.maxConcurrent,
validFrom: b.validFrom ?? null, validFrom: b.validFrom ?? null,
validTo: b.validTo ?? null, validTo: resolveValidTo(b, existing.validTo),
status: b.status ?? existing.status, status: b.status ?? existing.status,
}) })
.where(eq(subscriptions.id, req.params.id)) .where(eq(subscriptions.id, req.params.id))
@@ -162,6 +265,31 @@ export async function subscriptionRoutes(app: FastifyInstance, db: Db): Promise<
}, },
); );
// Re-print the subscription's QR card (failed auto-print, lost card, re-hand to the
// customer). Operator-or-admin (it's a booth action, not a master-data edit). 404 if
// the subscription is gone; 409 if it has no QR credential; 503 if no printer.
app.post<{ Params: { id: string } }>(
"/api/subscriptions/:id/print",
{ preHandler: readGuard },
async (req, reply) => {
const sub = loadAggregate(req.params.id);
if (!sub) return reply.code(404).send({ error: "subscription not found" });
const code = qrCodeOf(sub);
if (!code) return reply.code(409).send({ error: "subscription has no QR credential to print" });
try {
const printedBy = await printSubscriptionCard(
db,
{ code, holderName: sub.holderName, validFrom: sub.validFrom, validTo: sub.validTo },
app.log,
);
return reply.code(200).send({ ok: true, printedBy });
} catch (err) {
if (err instanceof NoPrinterAvailableError) return reply.code(503).send({ error: err.message });
return reply.code(500).send({ error: (err as Error).message });
}
},
);
// Revoke (soft): the common case — keeps the subscription + its history, just bars // Revoke (soft): the common case — keeps the subscription + its history, just bars
// it. A revoked subscription fails the entry check (see subscription-flow.ts). Use // it. A revoked subscription fails the entry check (see subscription-flow.ts). Use
// DELETE only to fully remove one created in error. // DELETE only to fully remove one created in error.
+106 -13
View File
@@ -6,6 +6,7 @@ import {
deleteSubscription, deleteSubscription,
fetchSiteConfig, fetchSiteConfig,
fetchSubscriptions, fetchSubscriptions,
printSubscription,
revokeSubscription, revokeSubscription,
updateSubscription, updateSubscription,
type Subscription, type Subscription,
@@ -28,11 +29,17 @@ interface FormState {
carBound: boolean; // false = unbound (maxConcurrent null) carBound: boolean; // false = unbound (maxConcurrent null)
maxConcurrent: string; maxConcurrent: string;
validFrom: string; validFrom: string;
months: string; // months paid for; "" = none (use explicit validTo / open-ended)
validTo: string; validTo: string;
credentials: SubscriptionCredential[]; credentials: SubscriptionCredential[];
platesText: string; // comma/space separated platesText: string; // comma/space separated
} }
/** Today (UTC date, yyyy-mm-dd) for a sensible default validFrom on new subs. */
function todayISODate(): string {
return new Date().toISOString().slice(0, 10);
}
function emptyForm(defaultPriceMajor = "", currency = DEFAULT_CURRENCY): FormState { function emptyForm(defaultPriceMajor = "", currency = DEFAULT_CURRENCY): FormState {
return { return {
holderName: "", holderName: "",
@@ -41,9 +48,10 @@ function emptyForm(defaultPriceMajor = "", currency = DEFAULT_CURRENCY): FormSta
currency, currency,
carBound: true, carBound: true,
maxConcurrent: "1", maxConcurrent: "1",
validFrom: "", validFrom: todayISODate(),
months: "1",
validTo: "", validTo: "",
credentials: [{ kind: "rf", value: "" }], credentials: [{ kind: "qr", value: "" }],
platesText: "", platesText: "",
}; };
} }
@@ -56,11 +64,23 @@ function formFrom(s: Subscription): FormState {
carBound: s.maxConcurrent != null, carBound: s.maxConcurrent != null,
maxConcurrent: s.maxConcurrent != null ? String(s.maxConcurrent) : "1", maxConcurrent: s.maxConcurrent != null ? String(s.maxConcurrent) : "1",
validFrom: s.validFrom ?? "", validFrom: s.validFrom ?? "",
months: "", // on edit, default to leaving the window as-is (explicit validTo below)
validTo: s.validTo ?? "", validTo: s.validTo ?? "",
credentials: s.credentials.length ? s.credentials : [{ kind: "rf", value: "" }], credentials: s.credentials.length ? s.credentials : [{ kind: "qr", value: "" }],
platesText: s.plates.join(", "), platesText: s.plates.join(", "),
}; };
} }
/** Add whole months to a yyyy-mm-dd (clamps day overflow), → yyyy-mm-dd. Mirrors the
* server's addMonths so the form can preview the coverage end. */
function addMonthsDate(date: string, months: number): string | null {
const d = new Date(`${date}T00:00:00Z`);
if (Number.isNaN(d.getTime())) return null;
const day = d.getUTCDate();
d.setUTCMonth(d.getUTCMonth() + months);
if (d.getUTCDate() < day) d.setUTCDate(0);
return d.toISOString().slice(0, 10);
}
const STATUS_KEY: Record<Subscription["status"], string> = { const STATUS_KEY: Record<Subscription["status"], string> = {
active: "subs.statusActive", active: "subs.statusActive",
suspended: "subs.statusSuspended", suspended: "subs.statusSuspended",
@@ -70,6 +90,7 @@ const STATUS_KEY: Record<Subscription["status"], string> = {
function toInput(f: FormState): SubscriptionInput { function toInput(f: FormState): SubscriptionInput {
const major = Number(f.priceMajor); const major = Number(f.priceMajor);
const priceSet = f.priceMajor.trim() !== "" && Number.isFinite(major) && major >= 0; const priceSet = f.priceMajor.trim() !== "" && Number.isFinite(major) && major >= 0;
const monthsNum = f.months.trim() === "" ? null : Math.max(1, Math.round(Number(f.months) || 0));
return { return {
holderName: f.holderName.trim() || null, holderName: f.holderName.trim() || null,
contact: f.contact.trim() || null, contact: f.contact.trim() || null,
@@ -78,8 +99,14 @@ function toInput(f: FormState): SubscriptionInput {
currency: priceSet ? f.currency.trim() || DEFAULT_CURRENCY : null, currency: priceSet ? f.currency.trim() || DEFAULT_CURRENCY : null,
maxConcurrent: f.carBound ? Math.max(1, Math.round(Number(f.maxConcurrent) || 1)) : null, maxConcurrent: f.carBound ? Math.max(1, Math.round(Number(f.maxConcurrent) || 1)) : null,
validFrom: f.validFrom.trim() || null, validFrom: f.validFrom.trim() || null,
// months (with validFrom) drives validTo server-side; else send the explicit end.
months: monthsNum && f.validFrom.trim() ? monthsNum : null,
validTo: f.validTo.trim() || null, validTo: f.validTo.trim() || null,
credentials: f.credentials.filter((c) => c.value.trim()).map((c) => ({ kind: c.kind, value: c.value.trim() })), // A QR credential with a blank value is sent as { kind:'qr' } (no value) so the
// server auto-generates the code. RF (and pre-existing QR) keep their value.
credentials: f.credentials
.filter((c) => c.kind === "qr" || c.value.trim())
.map((c) => (c.value.trim() ? { kind: c.kind, value: c.value.trim() } : { kind: c.kind })),
plates: f.platesText.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean), plates: f.platesText.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean),
}; };
} }
@@ -128,8 +155,22 @@ export function SubscriptionManager() {
async function save() { async function save() {
setMsg(null); setMsg(null);
try { try {
if (editing === "new") await createSubscription(toInput(form)); if (editing === "new") {
else if (editing) await updateSubscription(editing, toInput(form)); const created = await createSubscription(toInput(form));
setEditing(null);
reload();
// Reflect the auto-print outcome: printed OK, or saved-but-print-failed (the
// operator can use "Print code" to retry).
if (created.printed) {
setMsg({ kind: "ok", text: t("subs.savedPrinted") });
} else if (created.printError) {
setMsg({ kind: "err", text: t("subs.savedPrintFailed", { error: created.printError }) });
} else {
setMsg({ kind: "ok", text: t("subs.saved") });
}
return;
}
if (editing) await updateSubscription(editing, toInput(form));
setEditing(null); setEditing(null);
reload(); reload();
setMsg({ kind: "ok", text: t("subs.saved") }); setMsg({ kind: "ok", text: t("subs.saved") });
@@ -138,6 +179,15 @@ export function SubscriptionManager() {
setMsg({ kind: "err", text: problems?.length ? `${(e as Error).message}: ${problems.join("; ")}` : (e as Error).message }); setMsg({ kind: "err", text: problems?.length ? `${(e as Error).message}: ${problems.join("; ")}` : (e as Error).message });
} }
} }
async function doPrint(s: Subscription) {
setMsg(null);
try {
const r = await printSubscription(s.id);
setMsg({ kind: "ok", text: t("subs.printedOn", { printer: r.printedBy }) });
} catch (e) {
setMsg({ kind: "err", text: (e as Error).message });
}
}
async function doRevoke(s: Subscription) { async function doRevoke(s: Subscription) {
if (!confirm(t("subs.confirmRevoke", { name: s.holderName ?? s.id }))) return; if (!confirm(t("subs.confirmRevoke", { name: s.holderName ?? s.id }))) return;
await revokeSubscription(s.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message })); await revokeSubscription(s.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
@@ -153,6 +203,19 @@ export function SubscriptionManager() {
setForm((f) => ({ ...f, credentials: f.credentials.map((c, j) => (j === i ? { ...c, ...patch } : c)) })); setForm((f) => ({ ...f, credentials: f.credentials.map((c, j) => (j === i ? { ...c, ...patch } : c)) }));
} }
// Live coverage preview: when months + validFrom are set, show the end date and
// (if priced) the N×monthly total the operator should collect.
const monthsN = form.months.trim() === "" ? 0 : Math.max(0, Math.round(Number(form.months) || 0));
const coverageEnd = monthsN >= 1 && form.validFrom.trim() ? addMonthsDate(form.validFrom.trim(), monthsN) : null;
const priceMajorN = form.priceMajor.trim() === "" ? null : Number(form.priceMajor);
const totalDue =
coverageEnd && priceMajorN != null && Number.isFinite(priceMajorN)
? `${(priceMajorN * monthsN).toLocaleString()} ${form.currency.trim() || DEFAULT_CURRENCY}`
: null;
const coverageHint = coverageEnd
? t("subs.coverageHint", { end: coverageEnd }) + (totalDue ? ` · ${t("subs.totalDue", { total: totalDue })}` : "")
: null;
if (!subs) return null; if (!subs) return null;
return ( return (
@@ -169,6 +232,10 @@ export function SubscriptionManager() {
{s.credentials.length} {t("subs.cred")} · {t("subs.plates", { count: s.plates.length })} {s.credentials.length} {t("subs.cred")} · {t("subs.plates", { count: s.plates.length })}
</span> </span>
<span style={{ flex: 1 }} /> <span style={{ flex: 1 }} />
{/* Print code — only when the subscription has a QR credential to encode. */}
{s.credentials.some((c) => c.kind === "qr") && (
<button type="button" onClick={() => doPrint(s)}>{t("subs.printCode")}</button>
)}
<button type="button" onClick={() => startEdit(s)}>{t("subs.edit")}</button> <button type="button" onClick={() => startEdit(s)}>{t("subs.edit")}</button>
{s.status !== "revoked" && <button type="button" onClick={() => doRevoke(s)}>{t("subs.revoke")}</button>} {s.status !== "revoked" && <button type="button" onClick={() => doRevoke(s)}>{t("subs.revoke")}</button>}
<button type="button" onClick={() => doDelete(s)}>{t("subs.delete")}</button> <button type="button" onClick={() => doDelete(s)}>{t("subs.delete")}</button>
@@ -209,25 +276,51 @@ export function SubscriptionManager() {
)} )}
</span> </span>
<label>{t("subs.validFrom")}</label> <label>{t("subs.validFrom")}</label>
<input value={form.validFrom} onChange={(e) => setForm((f) => ({ ...f, validFrom: e.target.value }))} placeholder={t("subs.isoDateOptional")} /> <input type="date" value={form.validFrom} onChange={(e) => setForm((f) => ({ ...f, validFrom: e.target.value }))} />
<label>{t("subs.validTo")}</label> <label>{t("subs.months")}</label>
<input value={form.validTo} onChange={(e) => setForm((f) => ({ ...f, validTo: e.target.value }))} placeholder={t("subs.isoDateOptional")} /> <span style={{ display: "flex", gap: "0.4rem", alignItems: "center", flexWrap: "wrap" }}>
<input
value={form.months}
onChange={(e) => setForm((f) => ({ ...f, months: e.target.value }))}
inputMode="numeric"
placeholder="1"
style={{ width: 50 }}
/>
<span style={{ color: "#888" }}>{t("subs.monthsHint")}</span>
{/* Live preview of the coverage end + the N×price total. */}
{coverageHint && <span style={{ color: "#0a7" }}>{coverageHint}</span>}
</span>
<label>{t("subs.validToOverride")}</label>
<input type="date" value={form.validTo} onChange={(e) => setForm((f) => ({ ...f, validTo: e.target.value }))} />
<label>{t("subs.boundPlates")}</label> <label>{t("subs.boundPlates")}</label>
<input value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder={t("subs.commaSeparatedOptional")} /> <input value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder={t("subs.commaSeparatedOptional")} />
</div> </div>
<h4 style={{ marginBottom: "0.25rem" }}>{t("subs.credentialsCardQr")}</h4> <h4 style={{ marginBottom: "0.25rem" }}>{t("subs.credentials")}</h4>
{form.credentials.map((c, i) => ( {form.credentials.map((c, i) => (
<div key={i} style={{ display: "flex", gap: "0.4rem", marginBottom: "0.3rem" }}> <div key={i} style={{ display: "flex", gap: "0.4rem", marginBottom: "0.3rem" }}>
{/* Operator chooses the credential type. Only QR is live today; RFID
is shown disabled ("soon") so the choice is visible — the backend
already accepts both, so re-enabling RFID is just dropping `disabled`. */}
<select value={c.kind} onChange={(e) => setCred(i, { kind: e.target.value as "rf" | "qr" })}> <select value={c.kind} onChange={(e) => setCred(i, { kind: e.target.value as "rf" | "qr" })}>
<option value="rf">{t("subs.rfCardTag")}</option>
<option value="qr">{t("subs.qr")}</option> <option value="qr">{t("subs.qr")}</option>
<option value="rf" disabled>{t("subs.rfCardTagSoon")}</option>
</select> </select>
<input value={c.value} onChange={(e) => setCred(i, { value: e.target.value })} placeholder={t("subs.credentialValue")} style={{ flex: 1 }} /> {c.kind === "qr" ? (
// QR codes are server-generated. Blank → "will be generated"; an
// existing code is shown read-only (it can be printed; never typed).
c.value.trim() ? (
<input value={c.value} readOnly style={{ flex: 1, fontFamily: "ui-monospace, monospace", background: "#f6f6f6" }} />
) : (
<span style={{ flex: 1, color: "#888", fontStyle: "italic", alignSelf: "center" }}>{t("subs.qrAutoGen")}</span>
)
) : (
<input value={c.value} onChange={(e) => setCred(i, { value: e.target.value })} placeholder={t("subs.credentialValue")} style={{ flex: 1 }} />
)}
<button type="button" onClick={() => setForm((f) => ({ ...f, credentials: f.credentials.filter((_, j) => j !== i) }))}>×</button> <button type="button" onClick={() => setForm((f) => ({ ...f, credentials: f.credentials.filter((_, j) => j !== i) }))}>×</button>
</div> </div>
))} ))}
<button type="button" onClick={() => setForm((f) => ({ ...f, credentials: [...f.credentials, { kind: "rf", value: "" }] }))}>{t("subs.addCredential")}</button> <button type="button" onClick={() => setForm((f) => ({ ...f, credentials: [...f.credentials, { kind: "qr", value: "" }] }))}>{t("subs.addCredential")}</button>
<p style={{ color: "#777", fontSize: "0.85em", margin: "0.5rem 0 0" }}> <p style={{ color: "#777", fontSize: "0.85em", margin: "0.5rem 0 0" }}>
{t("subs.needCredentialOrPlate")} {t("subs.needCredentialOrPlate")}
</p> </p>
+31 -2
View File
@@ -298,16 +298,45 @@ export interface Subscription {
credentials: SubscriptionCredential[]; credentials: SubscriptionCredential[];
plates: string[]; plates: string[];
} }
export type SubscriptionInput = Omit<Subscription, "id" | "status"> & { /** A credential as SENT to the server: a QR value may be omitted/blank → the server
* auto-generates an unguessable code. RF must carry the card id. */
export interface SubscriptionCredentialInput {
kind: "rf" | "qr";
value?: string;
}
export type SubscriptionInput = {
holderName: string | null;
contact: string | null;
priceMinor: number | null;
period: "monthly";
currency: string | null;
maxConcurrent: number | null;
validFrom: string | null;
validTo: string | null;
/** Months paid for: when set (with validFrom), validTo = validFrom + months. */
months?: number | null;
status?: Subscription["status"]; status?: Subscription["status"];
credentials: SubscriptionCredentialInput[];
plates: string[];
};
/** The create response = the saved subscription + the auto-print outcome. */
export type SubscriptionCreated = Subscription & {
printed: boolean;
printedBy?: string;
printError?: string;
}; };
export function fetchSubscriptions(): Promise<{ subscriptions: Subscription[] }> { export function fetchSubscriptions(): Promise<{ subscriptions: Subscription[] }> {
return apiFetch("/api/subscriptions"); return apiFetch("/api/subscriptions");
} }
export function createSubscription(body: SubscriptionInput): Promise<Subscription> { export function createSubscription(body: SubscriptionInput): Promise<SubscriptionCreated> {
return apiFetch("/api/subscriptions", { method: "POST", body: JSON.stringify(body) }); return apiFetch("/api/subscriptions", { method: "POST", body: JSON.stringify(body) });
} }
/** Re-print a subscription's QR card (failed auto-print / lost card). */
export function printSubscription(id: string): Promise<{ ok: boolean; printedBy: string }> {
return apiFetch(`/api/subscriptions/${id}/print`, { method: "POST" });
}
export function updateSubscription(id: string, body: SubscriptionInput): Promise<Subscription> { export function updateSubscription(id: string, body: SubscriptionInput): Promise<Subscription> {
return apiFetch(`/api/subscriptions/${id}`, { method: "PUT", body: JSON.stringify(body) }); return apiFetch(`/api/subscriptions/${id}`, { method: "PUT", body: JSON.stringify(body) });
} }
+12
View File
@@ -144,18 +144,30 @@ export const en: Catalog = {
limitCarsInAtOnce: "limit cars in at once", limitCarsInAtOnce: "limit cars in at once",
validFrom: "Valid from", validFrom: "Valid from",
validTo: "Valid to", validTo: "Valid to",
months: "Months",
monthsHint: "months paid",
coverageHint: "until {{end}}",
totalDue: "total {{total}}",
validToOverride: "Valid to (manual)",
isoDateOptional: "ISO date (optional)", isoDateOptional: "ISO date (optional)",
boundPlates: "Bound plates", boundPlates: "Bound plates",
commaSeparatedOptional: "comma-separated (optional)", commaSeparatedOptional: "comma-separated (optional)",
credentials: "Credentials",
credentialsCardQr: "Credentials (card / QR)", credentialsCardQr: "Credentials (card / QR)",
rfCardTag: "RF card/tag", rfCardTag: "RF card/tag",
rfCardTagSoon: "RF card/tag (soon)",
qr: "QR", qr: "QR",
qrAutoGen: "QR code is auto-generated on save",
credentialValue: "credential value", credentialValue: "credential value",
addCredential: "+ credential", addCredential: "+ credential",
needCredentialOrPlate: "A subscription needs at least one credential OR one bound plate.", needCredentialOrPlate: "A subscription needs at least one credential OR one bound plate.",
save: "Save", save: "Save",
cancel: "Cancel", cancel: "Cancel",
saved: "Subscription saved.", saved: "Subscription saved.",
savedPrinted: "Subscription saved — QR code printed.",
savedPrintFailed: "Subscription saved, but printing failed ({{error}}). Use \"Print code\".",
printCode: "Print code",
printedOn: "Code printed on {{printer}}.",
confirmRevoke: "Revoke subscription for {{name}}? It will be refused at the barrier.", confirmRevoke: "Revoke subscription for {{name}}? It will be refused at the barrier.",
confirmDelete: "Delete subscription for {{name}}? (Past events are kept.)", confirmDelete: "Delete subscription for {{name}}? (Past events are kept.)",
statusActive: "active", statusActive: "active",
+12
View File
@@ -146,18 +146,30 @@ export const sq = {
limitCarsInAtOnce: "kufizo makinat brenda njëkohësisht", limitCarsInAtOnce: "kufizo makinat brenda njëkohësisht",
validFrom: "Vlen nga", validFrom: "Vlen nga",
validTo: "Vlen deri", validTo: "Vlen deri",
months: "Muaj",
monthsHint: "muaj të paguar",
coverageHint: "deri më {{end}}",
totalDue: "gjithsej {{total}}",
validToOverride: "Vlen deri (manual)",
isoDateOptional: "Datë ISO (opsionale)", isoDateOptional: "Datë ISO (opsionale)",
boundPlates: "Targat e lidhura", boundPlates: "Targat e lidhura",
commaSeparatedOptional: "të ndara me presje (opsionale)", commaSeparatedOptional: "të ndara me presje (opsionale)",
credentials: "Kredencialet",
credentialsCardQr: "Kredencialet (kartë / QR)", credentialsCardQr: "Kredencialet (kartë / QR)",
rfCardTag: "Kartë/etiketë RF", rfCardTag: "Kartë/etiketë RF",
rfCardTagSoon: "Kartë/etiketë RF (së shpejti)",
qr: "QR", qr: "QR",
qrAutoGen: "kodi QR gjenerohet automatikisht në ruajtje",
credentialValue: "vlera e kredencialit", credentialValue: "vlera e kredencialit",
addCredential: "+ kredencial", addCredential: "+ kredencial",
needCredentialOrPlate: "Një abonim kërkon të paktën një kredencial OSE një targë të lidhur.", needCredentialOrPlate: "Një abonim kërkon të paktën një kredencial OSE një targë të lidhur.",
save: "Ruaj", save: "Ruaj",
cancel: "Anulo", cancel: "Anulo",
saved: "Abonimi u ruajt.", saved: "Abonimi u ruajt.",
savedPrinted: "Abonimi u ruajt — kodi QR u printua.",
savedPrintFailed: "Abonimi u ruajt, por printimi dështoi ({{error}}). Përdor \"Printo kodin\".",
printCode: "Printo kodin",
printedOn: "Kodi u printua te {{printer}}.",
confirmRevoke: "Të anulohet abonimi për {{name}}? Do të refuzohet te barriera.", confirmRevoke: "Të anulohet abonimi për {{name}}? Do të refuzohet te barriera.",
confirmDelete: "Të fshihet abonimi për {{name}}? (Ngjarjet e kaluara ruhen.)", confirmDelete: "Të fshihet abonimi për {{name}}? (Ngjarjet e kaluara ruhen.)",
statusActive: "aktiv", statusActive: "aktiv",
@@ -7,6 +7,7 @@ import type {
PrinterDevice, PrinterDevice,
PrinterStatus, PrinterStatus,
PrintReport, PrintReport,
SubscriptionCardData,
TicketData, TicketData,
} from "../interfaces.js"; } from "../interfaces.js";
import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.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 // 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 // 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. // later without touching the render functions. See wiki/concepts/site-metadata.md.
@@ -118,6 +150,12 @@ const STR = {
issuedAt: (v: string) => `Printuar më: ${v}`, issuedAt: (v: string) => `Printuar më: ${v}`,
/** "Lost your ticket? <phone>" footer; printed only when a phone is set. */ /** "Lost your ticket? <phone>" footer; printed only when a phone is set. */
lostTicket: (phone: string) => `Keni humbur biletën? ${phone}`, 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; } as const;
/** Build the ESC/POS byte stream for a free-form text report (e.g. shift Z-report). */ /** 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. */ /** Open a TCP socket, write the bytes, wait for flush, then close. */
function sendRaw(host: string, port: number, payload: Buffer, timeoutMs: number): Promise<void> { function sendRaw(host: string, port: number, payload: Buffer, timeoutMs: number): Promise<void> {
return new Promise((resolve, reject) => { 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)`); 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. * 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 * 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; 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 { export interface PrinterDevice extends Device {
printTicket(data: TicketData): Promise<void>; printTicket(data: TicketData): Promise<void>;
/** Print a free-form text report (a shift Z-report, a receipt). `lines` are /** 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 * printed as-is; the driver adds a header/cut. Kept generic so the business
* layer composes the content. See wiki/concepts/shift.md. */ * layer composes the content. See wiki/concepts/shift.md. */
printReport(report: PrintReport): Promise<void>; 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 { export interface PrintReport {
+8 -1
View File
@@ -38,7 +38,14 @@ many ESC/POS-compatible OEM clones that share its firmware). Driver `rongta` in
## Ticket rendering ## Ticket rendering
`printTicket(TicketData)` builds ESC/POS: `ESC @` init, centered/bold/double-size header, `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 ## 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 merely **pre-fills** the new-subscription form; each subscription still stores its own value and may
override. 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) ### 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 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 ## 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 Recognized by a credential read at the barrier. The operator **chooses the credential type** per
**either can be combined with LPR/ANPR plate identity** (the plate binding below): 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 → - **QR code — the only type live today (2026-06-18).** Read by the optical reader — inherently
`pulseOpen`). A Wiegand-out reader keeps a future autonomous path open ([[entry-exit-readers]]) but **host-side** ([[entry-exit-readers]]). Host decodes the QR → looks up the subscription → decides.
isn't required (the [[dingtian-relay]] has no onboard card list). A subscription's QR can be **printed**. The new-subscription form **defaults to QR**.
- **QR code.** Read by the optical reader — inherently **host-side** ([[entry-exit-readers]]). Host - **The code is AUTO-GENERATED server-side** (`SUB-<15× base32>`, crypto-random, checked
decodes the QR → looks up the subscription → decides. A subscription's QR can be **printed**. 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 - **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 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. ([[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 ## [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). 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.