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
|
||||
|
||||
+9
-2
@@ -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 {
|
||||
|
||||
@@ -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…",
|
||||
},
|
||||
|
||||
@@ -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…",
|
||||
|
||||
Reference in New Issue
Block a user