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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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