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