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
+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.