From bbf61c48dfd240784dc663a568cd975ec7e258a8 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Fri, 19 Jun 2026 11:35:13 +0200 Subject: [PATCH] =?UTF-8?q?fix(ticket):=2011-digit=20IDs=20=E2=80=94=20fix?= =?UTF-8?q?=20KP-300H=20barcode=20line-overflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Cashino KP-300H printed entry tickets as raster garbage (solid black bars / banding) while the Rongta printed the same byte stream fine. Root cause: the barcode overflowed the print line, not data corruption. A 13-digit Code128 at module width 3 is ~534 dots. The KP-300H prints 72mm (512 usable dots at 203 dpi), so the symbol overran the line and the firmware rendered the overflow as raster noise. The Rongta runs 80mm (576 dots) and had just enough room — which is why only the Cashino failed. Confirmed on hardware: plain text printed clean, the barcode was the trigger, and an 11-digit code at width 3 (~468 dots) both fits and scans the full value at the exit reader. - Ticket IDs reduced 13 → 11 digits (10 random + Luhn). Length is driven by guess-resistance (10^10 space, ~1-in-10^7 to hit a live OPEN ticket even with thousands parked — the booth-operator threat model), not volume. - validateTicketCode is now length-agnostic (\d{10,14} + Luhn) so legacy 13-digit tickets still in circulation keep validating; the id stays opaque. Also: sendRaw now closes the print socket GRACEFULLY (end()+FIN, wait for close) instead of write-then-destroy, which could RST mid-stream and truncate a job. A separate latent bug found while diagnosing, fixed here. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V --- apps/server/src/entry-flow.ts | 28 ++++++++----- .../devices/src/drivers/printer-escpos.ts | 40 ++++++++++++++++--- 2 files changed, 53 insertions(+), 15 deletions(-) diff --git a/apps/server/src/entry-flow.ts b/apps/server/src/entry-flow.ts index 1f0c79e..9b88055 100644 --- a/apps/server/src/entry-flow.ts +++ b/apps/server/src/entry-flow.ts @@ -219,18 +219,24 @@ export class EntryFlow { /** * Opaque, unguessable transient ticket id (wiki/concepts/ticket-encoding.md). * - * Format: 13 digits = 12 cryptographically-random digits + 1 trailing Luhn check + * Format: 11 digits = 10 cryptographically-random digits + 1 trailing Luhn check * digit. All-numeric so the booth can read it on ANY legacy 1D barcode scanner and * an operator can hand-key it if every reader is down. RANDOM (not sequential): the * id must stay unguessable so an attacker can't iterate to claim a cheaper session - * — the anti-fraud property the wiki settles. 12 random digits = 10^12 space, so - * collisions are negligible at lot scale; the unique constraints on - * ledger_events.index / sessions.id are the backstop. The Luhn digit lets a manual - * entry reject a typo (validateTicketCode) instead of failing as "session not found". + * — the anti-fraud property the wiki settles. + * + * Length is driven by GUESS-RESISTANCE, not volume: with 10^10 valid ids and the + * Luhn digit rejecting 9/10 of malformed guesses, a blind attempt at a currently-OPEN + * ticket lands at ~1-in-10^7 even with thousands parked — comfortably safe — while + * being two digits (≈2 barcode modules) narrower than the old 13. Collisions are + * negligible at lot scale; the unique constraints on ledger_events.index / sessions.id + * are the backstop. (Older 13-digit ids stay valid — the id is opaque, length-agnostic.) + * The Luhn digit lets a manual entry reject a typo (validateTicketCode) instead of + * failing as "session not found". */ function newTicketId(): string { let body = ""; - for (let i = 0; i < 12; i += 1) body += String(randomInt(10)); + for (let i = 0; i < 10; i += 1) body += String(randomInt(10)); return body + luhnCheckDigit(body); } @@ -260,7 +266,11 @@ function luhnCheckDigit(digits: string): string { * never reject an id that already exists in the ledger. See ticket-encoding.md. */ export function validateTicketCode(code: string): boolean { - if (!/^\d{13}$/.test(code)) return false; - const body = code.slice(0, 12); - return luhnCheckDigit(body) === code[12]; + // Length-agnostic: an all-digit code whose last digit is the Luhn check of the rest. + // Accepts the current 11-digit ids AND any legacy 13-digit ones still in circulation + // (the id is opaque; only the digits+checksum shape matters). The 10..14 bound keeps + // a stray short/long string from being mistaken for a ticket. See ticket-encoding.md. + if (!/^\d{10,14}$/.test(code)) return false; + const body = code.slice(0, -1); + return luhnCheckDigit(body) === code[code.length - 1]; } diff --git a/packages/devices/src/drivers/printer-escpos.ts b/packages/devices/src/drivers/printer-escpos.ts index 7661300..af76d46 100644 --- a/packages/devices/src/drivers/printer-escpos.ts +++ b/packages/devices/src/drivers/printer-escpos.ts @@ -409,7 +409,15 @@ export function renderReceipt(data: ReceiptData): Buffer { return Buffer.concat(parts); } -/** Open a TCP socket, write the bytes, wait for flush, then close. */ +/** Open a TCP socket, write the bytes, and close GRACEFULLY so the printer reads the + * whole stream before the connection tears down. + * + * Why not write-then-destroy: a Socket.write() callback fires when the data reaches + * the local kernel buffer, NOT when the peer has read it. Calling destroy() at that + * point sends a TCP RST that can truncate the job in flight — the printer then has a + * desynced ESC/POS stream and prints raster garbage (solid black bars / banding). + * Instead we `end(payload)` (write + FIN) and wait for the socket to fully close, + * which only happens after the peer has drained our bytes and the FIN is acked. */ export function sendRaw( host: string, port: number, @@ -419,17 +427,37 @@ export function sendRaw( return new Promise((resolve, reject) => { const sock = new Socket(); let settled = false; - const done = (err?: Error) => { + // True once the payload + FIN have been handed off (flushed locally). After this, + // we've done our part; a slow/absent peer-FIN should NOT fail an already-sent job. + let written = false; + const fail = (err: Error) => { if (settled) return; settled = true; sock.destroy(); - err ? reject(err) : resolve(); + reject(err); + }; + const succeed = () => { + if (settled) return; + settled = true; + sock.destroy(); + resolve(); }; sock.setTimeout(timeoutMs); - sock.on("timeout", () => done(new Error("timeout"))); - sock.on("error", done); + // A timeout BEFORE the bytes are out is a real failure; one AFTER (some printers + // never send their FIN, holding the socket open) means the job was delivered — + // succeed rather than reject a ticket that already printed. + sock.on("timeout", () => (written ? succeed() : fail(new Error("timeout")))); + sock.on("error", fail); + // `close` fires after the bytes are flushed AND the connection is fully torn down + // (our FIN sent, peer's FIN received) — the job has been delivered by then. + sock.on("close", (hadError) => (hadError ? undefined : succeed())); sock.connect(port, host, () => { - sock.write(payload, (err) => (err ? done(err) : done())); + // end() writes the payload then sends FIN — a graceful half-close that lets the + // printer finish reading before the socket closes. No abrupt destroy(). The + // write callback confirms the bytes left our buffer. + sock.end(payload, () => { + written = true; + }); }); }); }