@@ -0,0 +1,81 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user