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:
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user