import { eq, ledgerEvents, siteConfig, type Db } from "@parking/db"; import { printWithFailover, registry, type PrinterDevice, type PrinterInstance, type ReceiptData, type TicketHeader, printerRoleOf, } from "@parking/devices"; import type { FastifyBaseLogger } from "fastify"; import { devicesByDirection } from "./device-resolve.js"; // Booth-side printing for the EXIT VOUCHER ("biletë dalje"). When the booth is far // from the exit, the customer pays at the booth and walks a printed voucher to the // exit, where they self-scan it. The voucher reprints the SAME ticket id as a // Code128 barcode (now a paid session) — so the exit reader runs the normal exit // validation and opens. See wiki/concepts/booth-exit-flow.md, ticket-encoding.md. // // This mirrors the entry flow's printer selection + header build, but prints on the // BOOTH printer (role "booth-receipt") since that's where the operator stands. /** Park identity for the voucher header, from site_config (all fields optional). */ function ticketHeader(db: Db): TicketHeader | undefined { const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get(); if (!row) return undefined; return { parkName: row.parkName, operatorName: row.operatorName, nius: row.nius, address: row.address, phone: row.phone, }; } /** Build live printer instances for failover selection (entry direction covers the * booth-receipt role too — the booth printer is configured on the entry side). */ function loadPrinters(db: Db): PrinterInstance[] { const rows = devicesByDirection(db, "printer", "entry"); const out: PrinterInstance[] = []; for (const row of rows) { const driver = registry.get(row.driverId); if (!driver) continue; const cfg = row.config as Record; const role = printerRoleOf(cfg); try { out.push({ id: row.id, role, failoverRank: typeof cfg.failoverRank === "number" ? cfg.failoverRank : 0, device: driver.create(cfg as never) as PrinterDevice, }); } catch { // skip a printer whose config won't build } } return out; } /** The receipt figures for a paid session, folded from the SIGNED ledger * (authoritative). Null if there's no entry or no payment for this id — the * caller should have validated paid + open before printing. */ function receiptFigures( db: Db, ticketId: string, ): Omit | null { const rows = db .select() .from(ledgerEvents) .where(eq(ledgerEvents.identity, ticketId)) .orderBy(ledgerEvents.index) .all(); const entry = rows.find((r) => r.type === "vehicle_entry"); if (!entry) return null; // The LATEST payment is the one we receipt (an overstay top-up re-pays). let payment: (typeof rows)[number] | undefined; for (const r of rows) if (r.type === "payment") payment = r; if (!payment) return null; const p = (payment.payload ?? {}) as { amountMinor?: number; currency?: string; tender?: "cash" | "card"; graceExitMin?: number; grossMinor?: number; validationLines?: { label: string; discountMinor: number }[]; }; return { ticketId, enteredAt: entry.occurredAt, paidAt: payment.occurredAt, amountMinor: typeof p.amountMinor === "number" ? p.amountMinor : 0, currency: p.currency ?? "ALL", tender: p.tender === "card" ? "card" : "cash", graceExitMin: typeof p.graceExitMin === "number" ? p.graceExitMin : null, // Merchant validations, as settled on the signed payment (gross → lines → net). grossMinor: typeof p.grossMinor === "number" ? p.grossMinor : null, validationLines: Array.isArray(p.validationLines) ? p.validationLines : undefined, }; } /** * Print a PAYMENT RECEIPT for a paid session on the booth printer (failing over * to the entry dispenser). The receipt is the customer's transparency record: * entry time, payment time, duration, amount + tender — folded from the signed * ledger. In VOUCHER mode it also carries the scannable ticket-id barcode + the * walk-back grace, so the one slip both proves payment AND self-exits at a * distant exit reader (this replaces the old barcode-only voucher). In standalone * mode (`voucher:false`) it is detail-only, printed at payment when the booth is * at the exit. Returns the id of the printer that printed it. * Throws NoPrinterAvailableError if none can; throws if the session isn't payable. */ export async function printPaymentReceipt( db: Db, ticketId: string, opts: { voucher: boolean }, logger: FastifyBaseLogger, ): Promise { const figures = receiptFigures(db, ticketId); if (!figures) { throw new Error(`no paid session to receipt for ${ticketId}`); } const printers = loadPrinters(db); const data: ReceiptData = { ...figures, voucher: opts.voucher, header: ticketHeader(db), }; // Prefer the booth printer (operator is at the booth); fall back to the dispenser. const printedBy = await printWithFailover(printers, "booth-receipt", (d: PrinterDevice) => d.printReceipt(data), ); logger.info( `${opts.voucher ? "exit voucher" : "payment receipt"} 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; } /** * Print an ADVISORY "out-of-window" slip when a subscriber enters (or exits) outside * their plan's allowed hours. It is NOT a payable ticket and carries NO final amount — * the total is computed at the booth on settlement (early-entry AND any late-exit time * combined). It just gives the subscriber paper proof that a fee is pending against this * occurrence. Albanian (like every customer-facing slip — see i18n.md). Best-effort: * the caller swallows failures so a missing printer never blocks the barrier. */ export async function printWindowChargeNotice( db: Db, notice: { occurrenceId: string; holderName?: string | null; at: string; windowOpensMin?: number | null; edge: "entry" | "exit" }, logger: FastifyBaseLogger, ): Promise { const printers = loadPrinters(db); const printedBy = await printWithFailover(printers, "booth-receipt", (d: PrinterDevice) => d.printWindowChargeNotice({ occurrenceId: notice.occurrenceId, holderName: notice.holderName ?? null, at: notice.at, edge: notice.edge, windowOpensMin: notice.windowOpensMin ?? null, header: ticketHeader(db), }), ); logger.info(`out-of-window notice printed for ${notice.occurrenceId} on ${printedBy}`); return printedBy; }