Files
kooltab-backoffice/docs/terminal-protocol.md
T
brammie15 314717ba3a
CI / build-and-test (push) Canceled after 0s
feat: move to V2
2026-10-29 11:21:52 +01:00

82 lines
4.9 KiB
Markdown

# Terminal protocol (v2)
How a bar terminal (the Flutter app) talks to the backoffice. The schemas are the source of truth:
`packages/shared/src/terminal.ts`. `pnpm schema` writes them as JSON Schema to `packages/shared/schema/` for
generating the app's models.
v2 is not compatible with v1: the API refuses apps older than 2.0.0 with `426 app_update_required`, also on the v1
routes (`/devices/...`, `GET /venues`), so a 1.x tablet keeps its unsent sales and asks to be updated.
## Every request
- `x-app-version: <the app's version>`, e.g. `2.0.0+41`. Older or missing: 426, and the app asks to be updated.
- After pairing: `Authorization: Bearer <token>`. 401 `device_revoked` or `token_expired`: pair again.
- Errors are `{ "error": { "code": "...", "message": "..." } }`.
## Pairing: `POST /api/v1/terminal/register`
```json
{ "registrationCode": "8F3A21C0", "name": "Bar terminal 1" }
```
The code comes from the dashboard (Settings, or the superadmin page) and works once, for 10 minutes. It says which
venue the terminal is for. Returns `{ deviceId, venueId, venueName, token, tokenExpiresAt }`; the token is shown only
this once and lasts 90 days.
## Sync: `POST /api/v1/terminal/sync`
```json
{
"events": [ ... ],
"settlementsCursor": "1234",
"catalogVersion": "6.1761657600000"
}
```
- `events`: the outbox, oldest first, at most 1000. Keep an event until its id is in `acknowledgedEventIds`;
sending one again is harmless.
- `settlementsCursor`: from the previous response; leave it out on a new terminal to get the last 30 days.
- `catalogVersion`: from the previous response; the catalog is only sent when it changed.
The response:
| Field | |
|---|---|
| `acknowledgedEventIds` | Applied now or before: drop them from the outbox |
| `rejectedEvents` | `{ id, reason }`: invalid and never applied. Log and drop them, sending them again won't help |
| `tabs` | Every open tab of the venue, with its lines and drinks and the email of the customer with that name: replace your own with these |
| `catalog` | Every product, archived ones included (hide those), or `null` when `catalogVersion` is current |
| `catalogVersion` | Send it next time |
| `stock` | `{ productId, stock, countedAt }` for every product; `stock` null: not tracked. Can be negative |
| `settlements` | New settlements of the venue (from every terminal and the dashboard), oldest first |
| `settlementsCursor` | Send it next time |
Then replay the outbox events that were not acknowledged on top of `tabs` and `stock`, with the rules below, so the
terminal shows what it did offline.
## Events
Every event has `id` (a UUID the terminal makes), `type` and `occurredAt` (ISO 8601). A clock that runs more than 5
minutes ahead is capped at the server's time. Tab, line, product and event ids are UUIDs; order ids are any string
unique within the tab. Unknown fields are ignored.
| type | fields | effect |
|---|---|---|
| `tab_opened` | `tabId`, `customerName`, `email`? | Opens the tab, unless it is open already. With an email, adds the customer to the venue (or fills in their email) |
| `tab_renamed` | `tabId`, `customerName` | |
| `tab_deleted` | `tabId` | Deletes the tab without settling: what is on it is not paid and stays out of the stock |
| `line_added` | `tabId`, `customerName`, `lineId`, `orderId`, `productId`, `productName`, `unitPriceInCents`, `quantity` ≥ 1 | Adds `quantity` drinks with ids `<event id>:0`, `:1`, … Reopens a tab deleted elsewhere. Counts the stock down |
| `line_removed` | `tabId`, `lineId`, `orderId`, `quantity` ≥ 1 | Takes off the latest drinks of that order on the line, never more than are on it; those go back into the stock |
| `settled` | `tabId`, `customerName`, `amountInCents`, `paymentMethod`, `items: [{ lineId, productId, productName, unitPriceInCents, quantity }]` | Settles the oldest `quantity` drinks of each line, with the drinks taken off it. A line the server doesn't know is kept in the history as it was sent. `paymentMethod`: `cash`, `payconiq`, `other` |
| `stock_set` | `productId`, `quantity` (≥ 0, or null to stop tracking) | A count. The latest count wins, also when an older one arrives later; drinks rung up before it are in it |
| `product_created` | `productId`, `product: { name, priceInCents, category?, lowStockThreshold?, imageId? }` | A product made on the terminal, sellable right away under its id. Its starting stock is a `stock_set` |
| `product_updated` | `productId`, `product: { ...only the fields that changed }` | Per field, the latest change wins, so offline edits of different fields both survive |
| `product_archived`, `product_restored` | `productId` | Hides (deletes) or brings back a product |
Drinks a terminal rings up on a tab that was settled elsewhere meanwhile stay on the tab: no sale is lost.
## Pictures: `POST /api/v1/terminal/images`
Multipart, the picture as `file` (at most 10 MB). Returns `{ imageId }`; link it with a `product_updated` event. The
picture is at `GET /api/v1/images/<imageId>`, public and cacheable forever.