feat(booth): payment receipt / exit voucher — transparency slip + CP852 fixes

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
This commit is contained in:
2026-06-18 20:46:38 +02:00
parent 9c9f777784
commit d71ba82999
13 changed files with 376 additions and 29 deletions
@@ -2,6 +2,7 @@ import type {
DeviceHealth,
PrinterDevice,
PrintReport,
ReceiptData,
SubscriptionCardData,
TicketData,
} from "../interfaces.js";
@@ -9,6 +10,7 @@ import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
import { hostField, portField, stubLog } from "./common.js";
import {
probe,
renderReceipt,
renderReport,
renderSubscriptionCard,
renderTicket,
@@ -92,6 +94,14 @@ class CashinoPrinter implements PrinterDevice {
);
stubLog(this.driverId, `printed subscription card ${data.code}`);
}
async printReceipt(data: ReceiptData): Promise<void> {
await sendRaw(this.#host, this.#port, renderReceipt(data), this.#timeout);
stubLog(
this.driverId,
`printed ${data.voucher ? "voucher" : "receipt"} ${data.ticketId}`,
);
}
}
const roleField: ConfigField = {
+125 -2
View File
@@ -1,6 +1,7 @@
import { Socket } from "node:net";
import type {
PrintReport,
ReceiptData,
SubscriptionCardData,
TicketData,
} from "../interfaces.js";
@@ -39,7 +40,7 @@ const SELECT_CP852 = Buffer.from([ESC, 0x74, 0x12]);
// so we never emit a byte that renders as the wrong glyph. Extend as needed.
const CP852: Record<string, number> = {
ë: 0x89,
Ë: 0xeb,
Ë: 0xd3, // CP852 0xD3 = U+00CB Ë (0xEB is ű — wrong; fixed after a misprint)
ç: 0x87,
Ç: 0x80,
// common Latin-2 extras that may appear in a park name/address:
@@ -74,7 +75,11 @@ const ASCII_FALLBACK: Record<string, string> = {
* ASCII letter. Pair with SELECT_CP852 in the print preamble. */
function line(text = ""): Buffer {
const out: number[] = [];
for (const ch of text) {
// Intl.NumberFormat separates the amount from the currency with a NO-BREAK
// SPACE (U+00A0) or NARROW NO-BREAK SPACE (U+202F); neither is in CP852, so
// they'd print as "?". Normalise to a plain space (e.g. "1000 Lekë").
const normalised = text.replace(/[  ]/g, " ");
for (const ch of normalised) {
const code = ch.codePointAt(0) ?? 0;
const mapped = CP852[ch];
const fallback = ASCII_FALLBACK[ch];
@@ -158,8 +163,67 @@ const STR = {
/** "Valid: <from> – <to>" line on the card. */
validity: (from: string, to: string) => `Vlen: ${from} – ${to}`,
phone: (v: string) => `TEL: ${v}`,
// --- payment receipt ---
/** Receipt title. */
receipt: "FATURË PAGESE",
/** Voucher-mode title (the same slip self-exits). */
voucherTitle: "BILETË DALJE",
/** "Entry:" — entry time row. */
entry: (v: string) => `Hyrja: ${v}`,
/** "Paid:" — payment time row. */
paid: (v: string) => `Pagesa: ${v}`,
/** "Duration:" — time parked. */
duration: (v: string) => `Kohëzgjatja: ${v}`,
/** "Tender:" — cash/card. */
tender: (v: string) => `Mënyra: ${v}`,
tenderCash: "Para në dorë",
tenderCard: "Kartë",
/** "Paid:" amount label (precedes the large total). */
amountLabel: "PAGUAR",
/** Walk-back grace emphasis (voucher mode) — two short lines that each fit the
* 80mm width, so neither wraps mid-word. */
graceLines: (min: number): readonly string[] => [
`Dilni brenda ${min} min.`,
"Skanoni këtë biletë në dalje.",
],
/** Thank-you footer. */
thanks: "Faleminderit!",
} as const;
/** Format integer minor units + ISO-4217 currency as a major-unit string for the
* printed receipt. Mirrors the booth UI's formatMoney (no float money model). */
function money(amountMinor: number, currency: string): string {
const major = amountMinor / 100;
try {
return new Intl.NumberFormat("sq-AL", {
style: "currency",
currency,
}).format(major);
} catch {
return `${major.toFixed(2)} ${currency}`;
}
}
/** Human duration between two ISO times, e.g. "2h 14m" / "47m". Whole minutes,
* mirroring the booth UI's formatDuration. */
function duration(fromIso: string, toIso: string): string {
const ms = Date.parse(toIso) - Date.parse(fromIso);
if (!Number.isFinite(ms) || ms < 0) return "—";
const mins = Math.floor(ms / 60_000);
const h = Math.floor(mins / 60);
const m = mins % 60;
return h > 0 ? `${h}h ${m}m` : `${m}m`;
}
/** Local date+time "YYYY-MM-DD HH:MM" for a receipt row. The host clock is the
* site's local time (the appliance runs in the site's zone). */
function stamp(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
/** Build the ESC/POS byte stream for a free-form text report (e.g. shift Z-report). */
export function renderReport(report: PrintReport): Buffer {
return Buffer.concat([
@@ -254,6 +318,65 @@ export function renderSubscriptionCard(data: SubscriptionCardData): Buffer {
return Buffer.concat(parts);
}
/** Build the ESC/POS byte stream for a PAYMENT RECEIPT. Header → title → the
* transparency figures (entry / paid / duration / amount / tender). In voucher
* mode it ALSO prints the scannable ticket-id barcode and emphasises the
* walk-back grace, so the one slip both proves payment and self-exits at a
* distant exit reader. Standalone (voucher=false) is detail-only. Albanian. */
export function renderReceipt(data: ReceiptData): Buffer {
const parts: Buffer[] = [
INIT,
SELECT_CP852,
renderHeader(data.header),
line(),
ALIGN_CENTER,
BOLD_ON,
DOUBLE_ON,
line(data.voucher ? STR.voucherTitle : STR.receipt),
DOUBLE_OFF,
BOLD_OFF,
line(),
ALIGN_LEFT,
// The transparency figures.
line(STR.entry(stamp(data.enteredAt))),
line(STR.paid(stamp(data.paidAt))),
line(STR.duration(duration(data.enteredAt, data.paidAt))),
line(
STR.tender(data.tender === "card" ? STR.tenderCard : STR.tenderCash),
),
line(),
// The amount, large and centred.
ALIGN_CENTER,
line(STR.amountLabel),
BOLD_ON,
DOUBLE_ON,
line(money(data.amountMinor, data.currency)),
DOUBLE_OFF,
BOLD_OFF,
line(),
];
if (data.voucher) {
// The same ticket id, scannable at the exit reader, + the grace emphasis.
parts.push(
code128(data.ticketId),
line(),
line(data.ticketId),
line(),
);
if (data.graceExitMin != null && data.graceExitMin > 0) {
parts.push(
BOLD_ON,
...STR.graceLines(data.graceExitMin).map((l) => line(l)),
BOLD_OFF,
);
}
}
parts.push(line(), line(STR.thanks), FEED_AND_CUT);
return Buffer.concat(parts);
}
/** Open a TCP socket, write the bytes, wait for flush, then close. */
export function sendRaw(
host: string,
@@ -13,6 +13,7 @@ import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
import { hostField, portField, stubLog } from "./common.js";
import {
probe,
renderReceipt,
renderReport,
renderSubscriptionCard,
renderTicket,
@@ -177,6 +178,14 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
stubLog(this.driverId, `printed subscription card ${data.code}`);
}
async printReceipt(data: import("../interfaces.js").ReceiptData): Promise<void> {
await sendRaw(this.#host, this.#port, renderReceipt(data), this.#timeout);
stubLog(
this.driverId,
`printed ${data.voucher ? "voucher" : "receipt"} ${data.ticketId}`,
);
}
/**
* 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
+28
View File
@@ -209,6 +209,30 @@ export interface TicketData {
readonly header?: TicketHeader;
}
/** A PAYMENT RECEIPT handed to the customer after a completed payment — the
* transparency record: when they entered, when they paid, how long they stayed,
* and how much they paid. Printed in two modes (see `voucher`):
* - voucher mode: ALSO carries the scannable ticket-id barcode + the walk-back
* grace window, so the same slip both proves payment AND self-exits at a
* distant exit reader (replaces the old barcode-only voucher);
* - standalone mode: detail-only (no barcode), printed at payment when the booth
* is at the exit and no voucher is issued.
* Money is integer MINOR units + an ISO-4217 currency (never a float) — the
* driver formats it. See wiki/concepts/booth-exit-flow.md, tariff.md. */
export interface ReceiptData {
readonly ticketId: string;
readonly enteredAt: string; // ISO-8601
readonly paidAt: string; // ISO-8601
readonly amountMinor: number;
readonly currency: string; // ISO-4217 (e.g. "ALL")
readonly tender: "cash" | "card";
/** Voucher mode: print the scannable barcode + emphasise the walk-back grace. */
readonly voucher: boolean;
/** Minutes the customer has to reach the exit after paying (voucher mode only). */
readonly graceExitMin?: number | null;
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.
@@ -231,6 +255,10 @@ export interface PrinterDevice extends Device {
printReport(report: PrintReport): Promise<void>;
/** Print a subscription card: a scannable QR of the code + holder/validity. */
printSubscriptionCard(data: SubscriptionCardData): Promise<void>;
/** Print a payment receipt (transparency: entry/paid/duration/amount). In
* voucher mode it also carries the ticket-id barcode + grace window so it
* doubles as the self-exit voucher. See ReceiptData. */
printReceipt(data: ReceiptData): Promise<void>;
}
export interface PrintReport {