fix(ticket): 11-digit IDs — fix KP-300H barcode line-overflow

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
This commit is contained in:
2026-06-19 11:35:13 +02:00
parent 00f3d141b6
commit bbf61c48df
2 changed files with 53 additions and 15 deletions
+34 -6
View File
@@ -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;
});
});
});
}