/** * A bar terminal on the command line, speaking the terminal protocol (docs/terminal-protocol.md): open tabs, ring up * drinks, settle, and watch the dashboard follow. Keeps its own outbox like the app does. * * pnpm simulate # as the seeded demo terminal * pnpm simulate --pair # pairs as a new terminal first * * API_URL (default http://localhost:3001/api/v1) and TERMINAL_TOKEN override the defaults. */ import { randomUUID } from 'crypto'; import * as readline from 'readline/promises'; import { stdin, stdout } from 'process'; import type { SyncResponse, TerminalEvent } from '@kooltap/shared'; const API_URL = process.env.API_URL ?? 'http://localhost:3001/api/v1'; const HEADERS = { 'Content-Type': 'application/json', 'x-app-version': '2.0.0' }; let token = process.env.TERMINAL_TOKEN ?? 'kt_demo-terminal-token'; let state: SyncResponse | null = null; let outbox: TerminalEvent[] = []; async function call(path: string, body: unknown, auth = true): Promise { const res = await fetch(`${API_URL}${path}`, { method: 'POST', headers: { ...HEADERS, ...(auth ? { Authorization: `Bearer ${token}` } : {}) }, body: JSON.stringify(body), }); const json = await res.json(); if (!res.ok) throw new Error(`${res.status} ${json.error?.code}: ${json.error?.message}`); return json; } /** Sends the outbox and takes over the venue's state, as the app does on every sync. */ async function sync() { const sent = outbox; state = await call('/terminal/sync', { events: sent, settlementsCursor: state?.settlementsCursor, catalogVersion: state?.catalogVersion, }).then((next) => ({ ...next, catalog: next.catalog ?? state?.catalog ?? [] })); const acknowledged = new Set(state.acknowledgedEventIds); outbox = outbox.filter((event) => !acknowledged.has(event.id)); for (const rejected of state.rejectedEvents) console.log(` rejected ${rejected.id}: ${rejected.reason}`); for (const settlement of state.settlements) console.log(` paid: ${settlement.customerName} € ${(settlement.amountInCents / 100).toFixed(2)} (${settlement.paymentMethod})`); show(); } function show() { const tabs = state?.tabs ?? []; console.log(`\n${tabs.length} open tabs${outbox.length ? `, ${outbox.length} events unsent` : ''}`); tabs.forEach((tab, i) => { const total = tab.lines.reduce((sum, line) => sum + line.quantity * line.unitPriceInCents, 0); const drinks = tab.lines.filter((l) => l.quantity > 0).map((l) => `${l.quantity}× ${l.productName}`).join(', '); console.log(` ${i + 1}. ${tab.customerName.padEnd(16)} € ${(total / 100).toFixed(2).padStart(7)} ${drinks}`); }); } const event = (type: T, fields: object) => ({ id: randomUUID(), type, occurredAt: new Date().toISOString(), ...fields }) as Extract; async function main() { const rl = readline.createInterface({ input: stdin, output: stdout }); const pairAt = process.argv.indexOf('--pair'); if (pairAt >= 0) { const paired = await call<{ token: string; deviceId: string }>('/terminal/register', { registrationCode: process.argv[pairAt + 1], name: 'Simulator' }, false); token = paired.token; console.log(`Paired as ${paired.deviceId}`); } await sync(); for (;;) { // Input that ends (a pipe) quits const choice = (await rl.question('\n[s]ync [o]pen tab [a]dd drinks [r]emove drink [p]ay [q]uit > ').catch(() => 'q')).trim().toLowerCase(); try { const tabs = state!.tabs; const pickTab = async () => tabs[Number(await rl.question('Tab number: ')) - 1]; if (choice === 'q') break; if (choice === 's') await sync(); if (choice === 'o') { outbox.push(event('tab_opened', { tabId: randomUUID(), customerName: (await rl.question('Customer name: ')).trim() })); await sync(); } if (choice === 'a') { const tab = await pickTab(); const products = state!.catalog!.filter((p) => !p.archived); products.forEach((p, i) => console.log(` ${i + 1}. ${p.name} € ${(p.priceInCents / 100).toFixed(2)}`)); const product = products[Number(await rl.question('Product number: ')) - 1]; const quantity = Number(await rl.question('How many: ')) || 1; const line = tab.lines.find((l) => l.productId === product.id); outbox.push(event('line_added', { tabId: tab.id, customerName: tab.customerName, lineId: line?.id ?? randomUUID(), orderId: randomUUID(), productId: product.id, productName: product.name, unitPriceInCents: product.priceInCents, quantity, })); await sync(); } if (choice === 'r') { const tab = await pickTab(); const line = tab.lines.find((l) => l.quantity > 0); const unit = line?.units.filter((u) => !u.removedAt).at(-1); if (line && unit?.orderId) outbox.push(event('line_removed', { tabId: tab.id, lineId: line.id, orderId: unit.orderId, quantity: 1 })); await sync(); } if (choice === 'p') { const tab = await pickTab(); const method = (await rl.question('cash / payconiq / payment_pending / other: ')).trim() || 'cash'; const items = tab.lines.filter((l) => l.quantity > 0) .map((l) => ({ lineId: l.id, productId: l.productId, productName: l.productName, unitPriceInCents: l.unitPriceInCents, quantity: l.quantity })); const amountInCents = items.reduce((sum, item) => sum + item.quantity * item.unitPriceInCents, 0); outbox.push(event('settled', { tabId: tab.id, customerName: tab.customerName, amountInCents, paymentMethod: method, items })); await sync(); } } catch (error) { console.log(` ${(error as Error).message}`); } } rl.close(); } main().catch((error) => { console.error(error.message ?? error); process.exitCode = 1; });