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:
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user