diff --git a/apps/server/src/booth-print.ts b/apps/server/src/booth-print.ts index 3df806c..1c04675 100644 --- a/apps/server/src/booth-print.ts +++ b/apps/server/src/booth-print.ts @@ -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 { + 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; +} diff --git a/apps/server/src/routes/subscriptions.ts b/apps/server/src/routes/subscriptions.ts index 2078b69..6e19892 100644 --- a/apps/server/src/routes/subscriptions.ts +++ b/apps/server/src/routes/subscriptions.ts @@ -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 { // 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): 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, + ): 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. diff --git a/apps/web/src/SubscriptionManager.tsx b/apps/web/src/SubscriptionManager.tsx index 7b70421..bf8a288 100644 --- a/apps/web/src/SubscriptionManager.tsx +++ b/apps/web/src/SubscriptionManager.tsx @@ -6,6 +6,7 @@ import { deleteSubscription, fetchSiteConfig, fetchSubscriptions, + printSubscription, revokeSubscription, updateSubscription, type Subscription, @@ -28,11 +29,17 @@ interface FormState { carBound: boolean; // false = unbound (maxConcurrent null) maxConcurrent: string; validFrom: string; + months: string; // months paid for; "" = none (use explicit validTo / open-ended) validTo: string; credentials: SubscriptionCredential[]; 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 { return { holderName: "", @@ -41,9 +48,10 @@ function emptyForm(defaultPriceMajor = "", currency = DEFAULT_CURRENCY): FormSta currency, carBound: true, maxConcurrent: "1", - validFrom: "", + validFrom: todayISODate(), + months: "1", validTo: "", - credentials: [{ kind: "rf", value: "" }], + credentials: [{ kind: "qr", value: "" }], platesText: "", }; } @@ -56,11 +64,23 @@ function formFrom(s: Subscription): FormState { carBound: s.maxConcurrent != null, maxConcurrent: s.maxConcurrent != null ? String(s.maxConcurrent) : "1", validFrom: s.validFrom ?? "", + months: "", // on edit, default to leaving the window as-is (explicit validTo below) 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(", "), }; } + +/** 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 = { active: "subs.statusActive", suspended: "subs.statusSuspended", @@ -70,6 +90,7 @@ const STATUS_KEY: Record = { function toInput(f: FormState): SubscriptionInput { const major = Number(f.priceMajor); 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 { holderName: f.holderName.trim() || null, contact: f.contact.trim() || null, @@ -78,8 +99,14 @@ function toInput(f: FormState): SubscriptionInput { currency: priceSet ? f.currency.trim() || DEFAULT_CURRENCY : null, maxConcurrent: f.carBound ? Math.max(1, Math.round(Number(f.maxConcurrent) || 1)) : 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, - 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), }; } @@ -128,8 +155,22 @@ export function SubscriptionManager() { async function save() { setMsg(null); try { - if (editing === "new") await createSubscription(toInput(form)); - else if (editing) await updateSubscription(editing, toInput(form)); + if (editing === "new") { + 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); reload(); 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 }); } } + 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) { 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 })); @@ -153,6 +203,19 @@ export function SubscriptionManager() { 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; return ( @@ -169,6 +232,10 @@ export function SubscriptionManager() { {s.credentials.length} {t("subs.cred")} · {t("subs.plates", { count: s.plates.length })} + {/* Print code — only when the subscription has a QR credential to encode. */} + {s.credentials.some((c) => c.kind === "qr") && ( + + )} {s.status !== "revoked" && } @@ -209,25 +276,51 @@ export function SubscriptionManager() { )} - setForm((f) => ({ ...f, validFrom: e.target.value }))} placeholder={t("subs.isoDateOptional")} /> - - setForm((f) => ({ ...f, validTo: e.target.value }))} placeholder={t("subs.isoDateOptional")} /> + setForm((f) => ({ ...f, validFrom: e.target.value }))} /> + + + setForm((f) => ({ ...f, months: e.target.value }))} + inputMode="numeric" + placeholder="1" + style={{ width: 50 }} + /> + {t("subs.monthsHint")} + {/* Live preview of the coverage end + the N×price total. */} + {coverageHint && {coverageHint}} + + + setForm((f) => ({ ...f, validTo: e.target.value }))} /> setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder={t("subs.commaSeparatedOptional")} /> -

{t("subs.credentialsCardQr")}

+

{t("subs.credentials")}

{form.credentials.map((c, i) => (
+ {/* 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`. */} - 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() ? ( + + ) : ( + {t("subs.qrAutoGen")} + ) + ) : ( + setCred(i, { value: e.target.value })} placeholder={t("subs.credentialValue")} style={{ flex: 1 }} /> + )}
))} - +

{t("subs.needCredentialOrPlate")}

diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index cf77c8a..429c8aa 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -298,16 +298,45 @@ export interface Subscription { credentials: SubscriptionCredential[]; plates: string[]; } -export type SubscriptionInput = Omit & { +/** 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"]; + 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[] }> { return apiFetch("/api/subscriptions"); } -export function createSubscription(body: SubscriptionInput): Promise { +export function createSubscription(body: SubscriptionInput): Promise { 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 { return apiFetch(`/api/subscriptions/${id}`, { method: "PUT", body: JSON.stringify(body) }); } diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index 0f69137..c57f3f2 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -144,18 +144,30 @@ export const en: Catalog = { limitCarsInAtOnce: "limit cars in at once", validFrom: "Valid from", validTo: "Valid to", + months: "Months", + monthsHint: "months paid", + coverageHint: "until {{end}}", + totalDue: "total {{total}}", + validToOverride: "Valid to (manual)", isoDateOptional: "ISO date (optional)", boundPlates: "Bound plates", commaSeparatedOptional: "comma-separated (optional)", + credentials: "Credentials", credentialsCardQr: "Credentials (card / QR)", rfCardTag: "RF card/tag", + rfCardTagSoon: "RF card/tag (soon)", qr: "QR", + qrAutoGen: "QR code is auto-generated on save", credentialValue: "credential value", addCredential: "+ credential", needCredentialOrPlate: "A subscription needs at least one credential OR one bound plate.", save: "Save", cancel: "Cancel", 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.", confirmDelete: "Delete subscription for {{name}}? (Past events are kept.)", statusActive: "active", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index af58ff4..f19936b 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -146,18 +146,30 @@ export const sq = { limitCarsInAtOnce: "kufizo makinat brenda njëkohësisht", validFrom: "Vlen nga", validTo: "Vlen deri", + months: "Muaj", + monthsHint: "muaj të paguar", + coverageHint: "deri më {{end}}", + totalDue: "gjithsej {{total}}", + validToOverride: "Vlen deri (manual)", isoDateOptional: "Datë ISO (opsionale)", boundPlates: "Targat e lidhura", commaSeparatedOptional: "të ndara me presje (opsionale)", + credentials: "Kredencialet", credentialsCardQr: "Kredencialet (kartë / QR)", rfCardTag: "Kartë/etiketë RF", + rfCardTagSoon: "Kartë/etiketë RF (së shpejti)", qr: "QR", + qrAutoGen: "kodi QR gjenerohet automatikisht në ruajtje", credentialValue: "vlera e kredencialit", addCredential: "+ kredencial", needCredentialOrPlate: "Një abonim kërkon të paktën një kredencial OSE një targë të lidhur.", save: "Ruaj", cancel: "Anulo", 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.", confirmDelete: "Të fshihet abonimi për {{name}}? (Ngjarjet e kaluara ruhen.)", statusActive: "aktiv", diff --git a/packages/devices/src/drivers/printer-rongta.ts b/packages/devices/src/drivers/printer-rongta.ts index 6150c7b..4a98786 100644 --- a/packages/devices/src/drivers/printer-rongta.ts +++ b/packages/devices/src/drivers/printer-rongta.ts @@ -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 00 + Buffer.from([GS, 0x28, 0x6b, 0x04, 0x00, 0x31, 0x41, 0x32, 0x00]), + // fn 167: module size — 1d 28 6b 03 00 31 43 + 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 + 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? " footer; printed only when a phone is set. */ lostTicket: (phone: string) => `Keni humbur biletën? ${phone}`, + /** Subscription-card title. */ + subscription: "ABONIM", + /** "Holder: " line on the card. */ + holder: (name: string) => `Mbajtësi: ${name}`, + /** "Valid: – " 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 { 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 { + 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 diff --git a/packages/devices/src/interfaces.ts b/packages/devices/src/interfaces.ts index 94d31eb..4680d3f 100644 --- a/packages/devices/src/interfaces.ts +++ b/packages/devices/src/interfaces.ts @@ -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; /** 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; + /** Print a subscription card: a scannable QR of the code + holder/validity. */ + printSubscriptionCard(data: SubscriptionCardData): Promise; } export interface PrintReport { diff --git a/wiki/entities/rongta-printer.md b/wiki/entities/rongta-printer.md index 10e7440..7296864 100644 --- a/wiki/entities/rongta-printer.md +++ b/wiki/entities/rongta-printer.md @@ -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 diff --git a/wiki/entities/subscription.md b/wiki/entities/subscription.md index 7f4507f..efb20c7 100644 --- a/wiki/entities/subscription.md +++ b/wiki/entities/subscription.md @@ -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. diff --git a/wiki/log.md b/wiki/log.md index cc70d46..10ef13d 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -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.