Files
brammie15 314717ba3a
CI / build-and-test (push) Canceled after 0s
feat: move to V2
2026-10-29 11:21:52 +01:00

123 lines
5.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 <code> # 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<T>(path: string, body: unknown, auth = true): Promise<T> {
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<SyncResponse>('/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 = <T extends TerminalEvent['type']>(type: T, fields: object) =>
({ id: randomUUID(), type, occurredAt: new Date().toISOString(), ...fields }) as Extract<TerminalEvent, { type: T }>;
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;
});