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
+62 -13
View File
@@ -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<ReceiptData, "voucher" | "header"> | 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<string> {
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;
}
+34 -2
View File
@@ -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) {
+49 -9
View File
@@ -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<string | null>(null);
const [result, setResult] = useState<string | null>(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 */}
<div className="flex justify-end gap-2 pt-1">
{phase === "done" ? (
<button
type="button"
onClick={onClose}
className="rounded-term border border-term-amber px-4 py-1.5 text-[12px] uppercase tracking-wider text-term-amber"
>
{t("common.close")}
</button>
<>
{/* Reprint the payment receipt (slip jammed / customer asks).
Only for a charged session — a subscription has no payment. */}
{!isSubscription && (
<button
type="button"
onClick={handleReprintReceipt}
disabled={reprinting}
className="rounded-term border border-term-border px-3 py-1.5 text-[12px] uppercase tracking-wider text-term-muted hover:text-term-text disabled:opacity-50"
>
{reprinting ? t("pay.reprinting") : t("pay.reprintReceipt")}
</button>
)}
<button
type="button"
onClick={onClose}
className="rounded-term border border-term-amber px-4 py-1.5 text-[12px] uppercase tracking-wider text-term-amber"
>
{t("common.close")}
</button>
</>
) : (
<>
<button
+9 -2
View File
@@ -610,12 +610,19 @@ export function boothExit(identity: string): Promise<BoothExitResult> {
return apiFetch("/api/exit", { method: "POST", body: JSON.stringify({ identity }) });
}
/** Print an exit voucher (paid ticket id reprinted) for self-exit at a distant
* exit. Requires the session to be paid. */
/** Print an exit voucher (paid ticket id reprinted as a barcode) + payment detail,
* for self-exit at a distant exit. Requires the session to be paid. */
export function printVoucher(identity: string): Promise<{ ok: boolean; printedBy: string }> {
return apiFetch("/api/voucher", { method: "POST", body: JSON.stringify({ identity }) });
}
/** Print a standalone PAYMENT RECEIPT (entry/paid/duration/amount, no barcode).
* Auto-printed after a payment when no voucher is issued; also the "reprint"
* action. Requires the session to be paid. */
export function printReceipt(identity: string): Promise<{ ok: boolean; printedBy: string }> {
return apiFetch("/api/receipt", { method: "POST", body: JSON.stringify({ identity }) });
}
// --- Snapshots (entry/exit evidence images) -------------------------------
export interface SnapshotMeta {
+5
View File
@@ -305,6 +305,11 @@ export const en: Catalog = {
subAssistHint: "Prepaid subscription. Open the barrier to assist the exit (faulty reader / missing card). No payment.",
subBarrierOpened: "Barrier opened for the subscriber (intervention recorded).",
voucherPrinted: "Exit voucher printed on {{printer}}. Customer self-exits at the exit.",
// payment receipt (transparency slip)
receiptPrintFailed: "(receipt didn't print — use \"Reprint receipt\".)",
receiptReprinted: "Receipt reprinted on {{printer}}.",
reprintReceipt: "Reprint receipt",
reprinting: "printing…",
noSnapshots: "no snapshots",
loadingSnapshots: "loading snapshots…",
},
+5
View File
@@ -307,6 +307,11 @@ export const sq = {
subAssistHint: "Abonim i parapaguar. Hap barrierën për të ndihmuar daljen (lexues me defekt / kartë e munguar). S'ka pagesë.",
subBarrierOpened: "Barriera u hap për abonentin (ndërhyrje e regjistruar).",
voucherPrinted: "Bileta e daljes u printua në {{printer}}. Klienti del vetë te dalja.",
// payment receipt (transparency slip)
receiptPrintFailed: "(fatura nuk u printua — provoni \"Riprinto faturën\".)",
receiptReprinted: "Fatura u riprintua në {{printer}}.",
reprintReceipt: "Riprinto faturën",
reprinting: "duke printuar…",
// snapshots
noSnapshots: "asnjë foto",
loadingSnapshots: "duke ngarkuar fotot…",
@@ -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 {
+17 -1
View File
@@ -34,6 +34,20 @@ trigger. Decided 2026-06-17.
runs the existing exit validation — which now finds the session **paid + within walk-back grace**,
so it opens. No new identity or code type; the "biletë dalje" is a *paid reprint* of the entry
ticket id. Reuses [[tariff|walk-back grace]] exactly.
- **The customer always gets a transparency receipt after paying (built 2026-06-18).** Entry time,
payment time, duration parked, amount + tender — one shared `ReceiptData`/`renderReceipt` in
`@parking/devices`, two modes: (a) **voucher mode** = those figures PLUS the scannable barcode and
an emphasised walk-back-grace line ("Dilni brenda N min — skanoni këtë biletë në dalje"), so the
one slip both proves payment and self-exits — this **replaced the old barcode-only voucher**;
(b) **standalone mode** = detail-only (no barcode), **auto-printed at payment** when the checkbox
is OFF (booth at the exit, no voucher). Both fold the figures from the SIGNED ledger (latest
`payment` event) and print on the booth printer (failover to the dispenser). The standalone
auto-print is **best-effort**: a printer fault must NOT block the exit that already happened — the
modal shows a note and offers **"Reprint receipt"** (also used if the slip jams or the customer
asks later). Routes: `POST /api/voucher` (voucher mode), `POST /api/receipt` (standalone/reprint;
requires paid, allows an already-exited session so a reprint still works). Receipt timestamps use
the **host-local clock** (the appliance runs in site time) — distinct from the tariff's frozen tz,
which governs pricing reproducibility, not display.
- **The checkbox default lives in `site_config`** (`exit_voucher_default`, a site-wide boolean edited
in Site settings) — because it's booth geography, not per-ticket. The operator may override per
transaction. (Per-exit-point config deferred until a site has both a near and a far exit.)
@@ -154,6 +168,8 @@ off the latest payment. See [[tariff]] (walk-back grace) for the pricing side of
reader path (one code path, two triggers).
- **Open: walk-back grace renews on every payment** — see the flagged section above (voucher overstay
re-grants a full grace window; pick a fix before production).
- Voucher print = reprint the ticket id barcode on the booth printer ([[ticket-encoding]]).
- Voucher/receipt print = `renderReceipt` (shared ESC/POS) on the booth printer ([[ticket-encoding]],
[[printer-status-monitoring]]): voucher mode = figures + barcode + grace; standalone = figures only,
auto-printed at payment. `POST /api/voucher`, `POST /api/receipt`.
- Open: a force-open **override** (lost ticket / equipment fault) — deferred; would be a separately
audited signed event, not folded into the validated path.
+15
View File
@@ -51,6 +51,21 @@ dependency — plus the code as text + holder/validity. Used for the auto-printe
subscription card. (Verified: the `GS ( k` store/print byte sequences + the embedded code appear on
the wire against a TCP capture.)
**`printReceipt(ReceiptData)`** (added 2026-06-18) — the payment receipt / exit voucher
([[booth-exit-flow]]). Same ESC/POS preamble; figures + amount, optional Code128 + grace.
### CP852 codepage — two gotchas found on a real misprint (2026-06-18)
The driver selects **CP852** (Latin-2) via `ESC t 18` and maps the Albanian letters to their CP852
bytes. A printed voucher surfaced two encoding bugs, both now fixed in `printer-escpos.ts`:
- **Uppercase `Ë` was mapped to `0xEB` — wrong.** In CP852 `0xEB` is `ű`; the correct byte for
`Ë` (U+00CB) is **`0xD3`**. The title `BILETË DALJE` / `FATURË PAGESE` printed garbled until fixed.
(Lowercase `ë` = `0x89` was always correct.) Anyone extending the CP852 map must verify bytes
against the actual Unicode.org `CP852.TXT`, not guess.
- **`Intl.NumberFormat` injects a NO-BREAK SPACE.** Formatting `ALL` money yields `"1000 Lekë"`
(separator = U+00A0, sometimes U+202F narrow NBSP) — neither is in CP852, so it printed as `1000?Lekë`.
`line()` now normalises U+00A0/U+202F → a plain space before encoding. Any Intl-formatted value on a
ticket is affected, not just money.
## Status
Driver written and compiles; entry-ticket layout is a first pass; live status monitoring is
+8
View File
@@ -868,3 +868,11 @@ The owner linked a Claude Design project (`019ddfee-…`, "TRM — Tracking & Ra
## [2026-06-18] fix | Cashino printer — ping-only driver (no false status) + Albanian device-role wording
Two device-feedback issues. (1) **Cashino 80mm printer reported wrong status.** It was configured on the `rongta` driver, whose `readStatus()` scrapes the Rongta board's `/prn_stat.htm` status page — which the Cashino does NOT serve. Result: a bogus `degraded`/page-error verdict while the printer was actually online (it printed fine; `healthCheck` TCP-ping passed). Root cause: the Cashino is an ESC/POS PRINT clone but has no trustworthy STATUS mechanism. Fix: extracted the shared ESC/POS rendering + transport (renderTicket/renderReport/renderSubscriptionCard/sendRaw/probe + CP852 map + code128/qrCode) from `printer-rongta.ts` into a new `drivers/printer-escpos.ts`; added a dedicated `cashino` driver that reuses that print path but is deliberately **NOT** `MonitorableDevice` (no readStatus). So `isMonitorable()` is false and the device monitor falls back to the generic `healthCheck()` — a plain TCP reachability ping of the print socket: reachable→ready, unreachable→offline, never a guessed paper/cover state. Rongta driver unchanged (still scrapes its page, still monitorable). Registered `cashinoDriver`; re-exported from the package. Switched the live entry-dispenser printer at **10.0.10.9** from `rongta`→`cashino` in the DB (backed up incl. WAL: apps/server/parking.sqlite*.bak-cashino-*); 10.0.10.10 (booth Rongta) left as-is. Verified at runtime: cashino registered, isMonitorable=false, no readStatus, healthCheck→offline on unreachable; live /api/devices/status → both printers `ready` (lane via ping, booth via page). (2) **Albanian device-role chip wording was wrong.** The footer label is `"{category} {role}"`; the role suffixes read badly: access `mixed`="i përzier" gave `Barriera i përzier` ("Barrier mixed" — wrong word + wrong gender; `mixed` actually means a barrier spanning >1 direction) → now `hyrje/dalje` (entry/exit). printer `lane`="korsia" gave `Printer korsia` ("Printer the-lane") → now `në korsi` (at the lane); `booth`="kabina" (`Printer kabina`) → `në kabinë` (at the booth). English tidied to match: mixed→"entry/exit", lane→"at lane", booth→"at booth". i18n catalog parity green. Updated [[printer-status-monitoring]]. No schema/event-chain change.
## [2026-06-18] feat | Payment receipt — transparency slip (entry/paid/duration/amount), voucher or standalone
After a completed payment the customer now 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: (a) VOUCHER mode = those figures PLUS the scannable Code128 barcode and an emphasised walk-back-grace line ("Dilni brenda N min — skanoni këtë biletë në dalje") so the one slip both proves payment and self-exits at a distant exit reader — this REPLACED the old barcode-only voucher (printExitVoucher → printPaymentReceipt); (b) STANDALONE mode = detail-only (no barcode), AUTO-printed at payment when the voucher checkbox is OFF (booth at the exit). Figures are folded from the SIGNED ledger (latest payment event), printed on the booth printer (failover to dispenser). Money via Intl minor-units (no float); duration = whole minutes (mirrors UI formatDuration); timestamps use the host-local clock (appliance = site time; distinct from the tariff's frozen tz, which governs PRICING reproducibility not display). Server: booth-print.ts `printPaymentReceipt(db,id,{voucher},log)` + `receiptFigures()`; routes `POST /api/voucher` (voucher) and new `POST /api/receipt` (standalone/reprint — requires paid, allows already-exited so reprint works). Both ESC/POS drivers (rongta + cashino) gained `printReceipt` (PrinterDevice interface). Frontend: BoothPayModal auto-prints the standalone receipt after a non-voucher payment (BEST-EFFORT — a printer fault must not block the exit that already happened; shows a note + a "Reprint receipt" button in the done phase, also for slip jams / later asks); api.ts `printReceipt()`. Decision (with user): auto-print on payment (not on-demand-only / not a new config toggle), with reprint fallback. i18n: receiptPrintFailed/receiptReprinted/reprintReceipt/reprinting (sq+en, parity green). Verified: full build green; renderReceipt output inspected in both modes (correct figures, barcode, grace line, Albanian); /api/receipt + /api/voucher live (404 on unknown id — route+validation reached, no physical print fired on the real booth printer). Updated [[booth-exit-flow]]. No schema/event-chain change.
## [2026-06-18] fix | Receipt/voucher misprint — CP852 `Ë` byte + Intl NBSP (found on a real printout)
A printed exit voucher (photo from the booth) surfaced three glitches in the new payment receipt, all fixed in `printer-escpos.ts`. (1) **Title garbled**: uppercase `Ë` was mapped to CP852 `0xEB` — wrong (that's `ű`); the correct byte is `0xD3` (U+00CB). `BILETË DALJE`/`FATURË PAGESE` now print correctly (lowercase `ë`=0x89 was always fine). (2) **`1000?Lekë`**: `Intl.NumberFormat("sq-AL", currency:"ALL")` separates amount from currency with a NO-BREAK SPACE (U+00A0; some locales U+202F narrow NBSP), which isn't in CP852 → printed as `?`. `line()` now normalises U+00A0/U+202F → plain space before encoding, so any Intl-formatted value on a ticket is safe, not just money. (3) **Grace line wrapped mid-word** ("…në dali / 8."): split `graceLine` into two short lines (`graceLines`) that each fit 80mm, emitted as two centered line() calls. Re-rendered & byte-verified: 0xD3 present, no 0x3f (`?`) byte, two clean grace lines, `1000 Lekë`. Full build green. Documented the CP852 gotchas in [[rongta-printer]]. NB: verify CP852 bytes against Unicode.org CP852.TXT, never guess. No schema/event-chain change.