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