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:
@@ -79,3 +79,29 @@ export async function printExitVoucher(
|
||||
logger.info(`exit voucher for ${ticketId} printed on ${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;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { eq, subscriptionCredentials, subscriptionPlates, subscriptions, type Db } from "@parking/db";
|
||||
import { NoPrinterAvailableError } from "@parking/devices";
|
||||
import { requireRole } from "../auth.js";
|
||||
import { printSubscriptionCard } from "../booth-print.js";
|
||||
|
||||
// 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
|
||||
@@ -15,7 +17,9 @@ import { requireRole } from "../auth.js";
|
||||
|
||||
interface Credential {
|
||||
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 {
|
||||
holderName?: string;
|
||||
@@ -29,12 +33,37 @@ interface SubscriptionBody {
|
||||
maxConcurrent?: number | null;
|
||||
validFrom?: 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";
|
||||
credentials?: Credential[];
|
||||
/** Plate binding (optional): bound plates that also serve as identity. */
|
||||
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> {
|
||||
// Admin manages subscriptions; operator/cashier/readonly may LIST (to look one up).
|
||||
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") {
|
||||
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)) {
|
||||
errs.push("status must be active|suspended|revoked");
|
||||
}
|
||||
for (const c of b.credentials ?? []) {
|
||||
if ((c.kind !== "rf" && c.kind !== "qr") || !c.value?.trim()) {
|
||||
errs.push("each credential needs kind (rf|qr) and a non-empty value");
|
||||
if (c.kind !== "rf" && c.kind !== "qr") {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
db.delete(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, id)).run();
|
||||
db.delete(subscriptionPlates).where(eq(subscriptionPlates.subscriptionId, id)).run();
|
||||
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 ?? []) {
|
||||
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).
|
||||
app.get("/api/subscriptions", { preHandler: readGuard }, async () => {
|
||||
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,
|
||||
maxConcurrent: b.maxConcurrent === undefined ? 1 : b.maxConcurrent,
|
||||
validFrom: b.validFrom ?? null,
|
||||
validTo: b.validTo ?? null,
|
||||
validTo: resolveValidTo(b, null),
|
||||
status: b.status ?? "active",
|
||||
})
|
||||
.run();
|
||||
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).
|
||||
app.put<{ Params: { id: string }; Body: SubscriptionBody }>(
|
||||
"/api/subscriptions/:id",
|
||||
@@ -152,7 +255,7 @@ export async function subscriptionRoutes(app: FastifyInstance, db: Db): Promise<
|
||||
: null,
|
||||
maxConcurrent: b.maxConcurrent === undefined ? existing.maxConcurrent : b.maxConcurrent,
|
||||
validFrom: b.validFrom ?? null,
|
||||
validTo: b.validTo ?? null,
|
||||
validTo: resolveValidTo(b, existing.validTo),
|
||||
status: b.status ?? existing.status,
|
||||
})
|
||||
.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
|
||||
// it. A revoked subscription fails the entry check (see subscription-flow.ts). Use
|
||||
// DELETE only to fully remove one created in error.
|
||||
|
||||
Reference in New Issue
Block a user