From d71ba82999a328f972f45ddeebea0246d7962aa7 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Thu, 18 Jun 2026 20:46:38 +0200 Subject: [PATCH] =?UTF-8?q?feat(booth):=20payment=20receipt=20/=20exit=20v?= =?UTF-8?q?oucher=20=E2=80=94=20transparency=20slip=20+=20CP852=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a completed payment the customer always gets a transparency record: entry time, payment time, duration parked, amount + tender. One shared ESC/POS renderer (renderReceipt + ReceiptData in @parking/devices), two modes: VOUCHER = those figures PLUS the scannable Code128 barcode and an emphasised walk-back-grace line, so the one slip both proves payment and self-exits at a distant exit reader (replaced the old barcode-only voucher); STANDALONE = detail-only, auto-printed at payment when no voucher is issued. Figures fold from the SIGNED ledger (latest payment event); printed on the booth printer (failover to dispenser). Best-effort: a printer fault never blocks the exit that already happened — the modal shows a note and offers "Reprint receipt". Server: booth-print.ts printPaymentReceipt() + receiptFigures(); routes POST /api/voucher (voucher) + new POST /api/receipt (standalone/reprint). Both ESC/POS drivers gained printReceipt(). Web: BoothPayModal auto-prints after a non-voucher payment + reprint button; api.ts printReceipt(). CP852 fixes found on a real printout: (1) uppercase Ë was mapped to 0xEB (that's ű) — correct byte is 0xD3; (2) Intl.NumberFormat injects a NO-BREAK SPACE (U+00A0/U+202F) that isn't in CP852 and printed as "?" — line() now normalises it to a plain space ("1000 Lekë"); (3) grace line wrapped mid-word — split into two short lines. Full build green; both receipt modes render-verified; routes live. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V --- apps/server/src/booth-print.ts | 75 +++++++++-- apps/server/src/routes/pay.ts | 36 ++++- apps/web/src/BoothPayModal.tsx | 58 ++++++-- apps/web/src/api.ts | 11 +- apps/web/src/lib/i18n/en.ts | 5 + apps/web/src/lib/i18n/sq.ts | 5 + .../devices/src/drivers/printer-cashino.ts | 10 ++ .../devices/src/drivers/printer-escpos.ts | 127 +++++++++++++++++- .../devices/src/drivers/printer-rongta.ts | 9 ++ packages/devices/src/interfaces.ts | 28 ++++ wiki/concepts/booth-exit-flow.md | 18 ++- wiki/entities/rongta-printer.md | 15 +++ wiki/log.md | 8 ++ 13 files changed, 376 insertions(+), 29 deletions(-) diff --git a/apps/server/src/booth-print.ts b/apps/server/src/booth-print.ts index 1c04675..ede1355 100644 --- a/apps/server/src/booth-print.ts +++ b/apps/server/src/booth-print.ts @@ -1,10 +1,10 @@ -import { eq, siteConfig, type Db } from "@parking/db"; +import { eq, ledgerEvents, siteConfig, type Db } from "@parking/db"; import { printWithFailover, registry, type PrinterDevice, type PrinterInstance, - type TicketData, + type ReceiptData, type TicketHeader, } from "@parking/devices"; import type { FastifyBaseLogger } from "fastify"; @@ -56,27 +56,76 @@ function loadPrinters(db: Db): PrinterInstance[] { return out; } -/** - * Print an exit voucher for a paid session: the same ticket id reprinted as a - * barcode, on the booth printer (failing over to the entry dispenser). Returns the - * id of the printer that printed it. Throws NoPrinterAvailableError if none can. - */ -export async function printExitVoucher( +/** 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; + }; + 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, + }; +} + +/** + * 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 ticket: TicketData = { - ticketId, - issuedAt: new Date().toISOString(), + 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.printTicket(ticket), + d.printReceipt(data), + ); + logger.info( + `${opts.voucher ? "exit voucher" : "payment receipt"} for ${ticketId} printed on ${printedBy}`, ); - logger.info(`exit voucher for ${ticketId} printed on ${printedBy}`); return printedBy; } diff --git a/apps/server/src/routes/pay.ts b/apps/server/src/routes/pay.ts index 81dc05b..7c080f6 100644 --- a/apps/server/src/routes/pay.ts +++ b/apps/server/src/routes/pay.ts @@ -9,7 +9,7 @@ import { } from "../pay-station.js"; import type { ExitFlow } from "../exit-flow.js"; import { NoShiftOpenError, type ShiftService } from "../shift-service.js"; -import { printExitVoucher } from "../booth-print.js"; +import { printPaymentReceipt } from "../booth-print.js"; // Booth endpoints (pay-on-foot): look up a session, quote it, take payment, and — // when the booth is at/near the exit — open the barrier. The payment becomes a @@ -33,6 +33,9 @@ interface ExitBody { interface VoucherBody { identity: string; } +interface ReceiptBody { + identity: string; +} export async function payRoutes( app: FastifyInstance, @@ -172,7 +175,36 @@ export async function payRoutes( return reply.code(409).send({ error: "session not paid — take payment before printing a voucher" }); } try { - const printedBy = await printExitVoucher(db, identity, app.log); + const printedBy = await printPaymentReceipt(db, identity, { voucher: true }, 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 }); + } + }, + ); + + // Print a standalone PAYMENT RECEIPT (transparency: entry/paid/duration/amount, + // no barcode) on the booth printer. Used (a) auto, right after a payment when no + // voucher is issued, and (b) on-demand "reprint" if the slip jammed. Requires the + // session to be PAID. See wiki/concepts/booth-exit-flow.md. + app.post<{ Body: ReceiptBody }>( + "/api/receipt", + { preHandler: [guard, requireShift] }, + async (req, reply) => { + const identity = (req.body?.identity ?? "").trim(); + if (!identity) return reply.code(400).send({ error: "identity required" }); + const view = payStation.lookup(identity); + if (!view.found) { + return reply.code(404).send({ error: "no session for ticket" }); + } + if (view.paidAt == null) { + return reply.code(409).send({ error: "session not paid — nothing to receipt" }); + } + try { + const printedBy = await printPaymentReceipt(db, identity, { voucher: false }, app.log); return reply.code(200).send({ ok: true, printedBy }); } catch (err) { if (err instanceof NoPrinterAvailableError) { diff --git a/apps/web/src/BoothPayModal.tsx b/apps/web/src/BoothPayModal.tsx index 09135ed..3b9dda4 100644 --- a/apps/web/src/BoothPayModal.tsx +++ b/apps/web/src/BoothPayModal.tsx @@ -8,6 +8,7 @@ import { lookupSession, openShift, paySession, + printReceipt, printVoucher, reopenBarrier, type SessionLookup, @@ -43,6 +44,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose const [error, setError] = useState(null); const [result, setResult] = useState(null); const [openingShift, setOpeningShift] = useState(false); + const [reprinting, setReprinting] = useState(false); const s: SessionLookup | undefined = session.data; // Checkbox default comes from config the first time it loads; operator can toggle. @@ -84,6 +86,19 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose } } + async function handleReprintReceipt() { + setReprinting(true); + setError(null); + try { + const r = await printReceipt(identity); + setResult(t("pay.receiptReprinted", { printer: r.printedBy })); + } catch (e) { + setError((e as Error).message); + } finally { + setReprinting(false); + } + } + async function handlePayAndExit() { if (!s) return; setError(null); @@ -96,14 +111,25 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose // 2. Voucher OR immediate exit. setPhase("finishing"); if (voucher) { + // The voucher slip carries the payment detail + barcode + grace. const r = await printVoucher(identity); setResult(t("pay.voucherPrinted", { printer: r.printedBy })); } else { const r = await boothExit(identity); + // No voucher → auto-print a standalone payment receipt for transparency. + // Best-effort: a printer fault must NOT block the exit that already happened; + // the operator can reprint from the done screen. + let receiptNote = ""; + try { + await printReceipt(identity); + } catch { + receiptNote = ` ${t("pay.receiptPrintFailed")}`; + } setResult( - r.opened + (r.opened ? t("pay.paidBarrierOpened") - : t("pay.paidExitRecorded", { reason: r.reason ?? t("booth.openManually") }), + : t("pay.paidExitRecorded", { reason: r.reason ?? t("booth.openManually") })) + + receiptNote, ); } // Refresh the live views. @@ -270,13 +296,27 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose {/* Actions */}
{phase === "done" ? ( - + <> + {/* Reprint the payment receipt (slip jammed / customer asks). + Only for a charged session — a subscription has no payment. */} + {!isSubscription && ( + + )} + + ) : ( <>