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
+19 -9
View File
@@ -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];
}