168 lines
10 KiB
Markdown
168 lines
10 KiB
Markdown
## KoolTap Server — Hold the Open Tabs (Source of Truth), Settle from the Dashboard
|
||
|
||
Until now every tablet owned its own tabs: it sent them as `openTabs` and never received tabs back. A newly paired
|
||
tablet started empty, and two tablets in one venue never saw each other's tabs.
|
||
|
||
From now on the **server holds the venue's open tabs**. Tablets keep working fully offline: every change is made
|
||
locally first and queued as an **event** in the tablet's outbox. On every sync the server applies the events it
|
||
receives, and answers with the venue's **complete** open tab state. The tablet replaces its local tabs with that state
|
||
and replays the events the server hasn't applied yet on top.
|
||
|
||
The app side is done (KoolTap app, `lib/services/remote_sync_service.dart`, `lib/services/bar_tab_service.dart`).
|
||
Until the server sends `tabs`, the app behaves exactly as before.
|
||
|
||
### 1. Events (request `events`, unchanged transport)
|
||
|
||
Events arrive in `events`, oldest first, at most 500 per sync. Acknowledge/reject them exactly as now
|
||
(`acknowledgedEventIds`, `rejectedEvents`). **Apply each event id once**: a tablet resends an event until it is
|
||
acknowledged, and an acknowledged id can still arrive again if the response got lost.
|
||
|
||
Common fields (already sent): `id`, `type`, `tabId`, `customerName`, `occurredAt` (ISO 8601 UTC).
|
||
|
||
| `type` | Extra fields | New? |
|
||
|---|---|---|
|
||
| `tab_opened` | `openedAt`, `email?` | **new** (`email` newer, see section 6) |
|
||
| `tab_renamed` | — (`customerName` is the new name) | **new** |
|
||
| `tab_deleted` | — (always preceded by a `line_removed` for every line still on the tab) | **new** |
|
||
| `line_added` | `lineId`, `productId`, `productName`, `unitPriceInCents`, `quantity` (≥ 1) | `lineId`, `productId` new |
|
||
| `line_removed` | same as `line_added` | `lineId`, `productId` new |
|
||
| `settled` | `amountInCents`, `paymentMethod`, `commandId?`, `items[{ productId, productName, quantity, unitPriceInCents }]` | `productId` in items new |
|
||
|
||
Events created by an older app version may lack `lineId` / `productId`; match those by name (see below).
|
||
|
||
`line_added` and `line_removed` also carry `orderId`: the order the drinks belong to (drinks rung up on one tab
|
||
without a pause of 5 minutes, and without leaving the tab in between). Each unit stores it as `orderId`; units from
|
||
older app versions have none and are grouped by time (a gap of over 5 minutes starts a new order). On `line_removed`
|
||
the field is optional: present (also `null`), only units of that order count; absent, any unit of the line.
|
||
|
||
### 2. The venue's tab state
|
||
|
||
```
|
||
Tab { id, customerName, email | null, openedAt, version, lines: Line[] }
|
||
Line { id, productId, productName, unitPriceInCents, quantity, units: Unit[] }
|
||
Unit { id, addedAt, removedAt | null } // one per piece ever put on the line
|
||
```
|
||
|
||
`quantity` = number of units with `removedAt == null`. Units are what lets every tablet show each drink with its time
|
||
(also struck-through drinks that were taken back).
|
||
|
||
**Finding the line of an event** (on the event's tab): the line with `id == lineId`; else the oldest line with the same
|
||
`productId` and `unitPriceInCents`; else the oldest line with the same `productName` and `unitPriceInCents`.
|
||
|
||
**Applying events** (each applied event bumps the tab's `version` by 1):
|
||
|
||
| Event | Rule |
|
||
|---|---|
|
||
| `tab_opened` | Create the tab with `tabId`, `customerName`, `openedAt` and `email` (absent → `null`) if it doesn't exist. Duplicate names are allowed (two offline tablets opening "Jan" give two tabs). |
|
||
| `tab_renamed` | Set `customerName`. Unknown tab → ignore (acknowledge). |
|
||
| `tab_deleted` | Remove the tab and its lines. Unknown tab → ignore. |
|
||
| `line_added` | If the tab doesn't exist (deleted elsewhere), **re-create it** from `tabId` + `customerName` (`openedAt = occurredAt`), so no sale is lost. Find the line; none → create it with `id = lineId` (or a new id if that id is taken). Add `quantity` units with `id = "<event id>:<index>"` (index 0…quantity−1), `addedAt = occurredAt` and the event's `orderId`. |
|
||
| `line_removed` | Find the line; none → ignore. Mark the `min(quantity, n)` **latest** active units removed (`removedAt = occurredAt`), where `n` counts the line's active units of the event's `orderId` (all of them when the event has no `orderId`). |
|
||
| `settled` | For each item: find the line; take `min(item.quantity, line.quantity)` of the **oldest** active units off it, together with the line's removed units; drop the line when nothing is left. Drinks added after the settlement (by another tablet) **stay on the tab**. Record a settlement (section 4) with id = the event's `id`. The tab itself stays open (tabs are never closed, only emptied). |
|
||
|
||
The unit ids **must** follow `"<event id>:<index>"`: the tablet gives replayed units the same ids, so a sync that
|
||
changes nothing changes nothing on screen.
|
||
|
||
Stock is applied in the same transaction, see `server-stock-source-of-truth.md`.
|
||
|
||
### 3. Sync response: `tabs`
|
||
|
||
```json
|
||
{
|
||
"tabs": [
|
||
{ "id": "…", "customerName": "Alice", "email": "alice@example.com", "openedAt": "2026-10-24T20:00:00.000Z", "version": 7,
|
||
"lines": [
|
||
{ "id": "…", "productId": "12", "productName": "Cola", "unitPriceInCents": 250, "quantity": 1,
|
||
"units": [ { "id": "<event id>:0", "addedAt": "…", "removedAt": null },
|
||
{ "id": "<event id>:1", "addedAt": "…", "removedAt": "…" } ] } ] }
|
||
]
|
||
}
|
||
```
|
||
|
||
- The **complete** list of the venue's open tabs (also empty ones) **after** applying the events acknowledged in this
|
||
same response. A tab left out disappears from the tablet.
|
||
- Events from this request that you reject must **not** be applied: the tablet drops them.
|
||
- Send `tabs` on every successful sync. Leaving the key out means "this server doesn't hold tabs" (old behaviour).
|
||
- `openTabs` in the request is the tablet's own view; ignore it once `tabs` is sent (it will be removed later).
|
||
|
||
### 4. Settlements feed: `settlements` + `settlementsCursor`
|
||
|
||
Every tablet's History screen shows all settlements of the venue, also offline.
|
||
|
||
Request: `settlementsCursor` (string, absent on the first sync after pairing).
|
||
Response:
|
||
|
||
```json
|
||
{
|
||
"settlements": [
|
||
{ "id": "…", "tabId": "…", "customerName": "Alice", "settledAt": "…", "paymentMethod": "payconiq",
|
||
"lines": [ { "id": "…", "productId": "12", "productName": "Cola", "unitPriceInCents": 250, "quantity": 2,
|
||
"units": [ { "id": "…", "addedAt": "…", "removedAt": null } ] } ] }
|
||
],
|
||
"settlementsCursor": "opaque"
|
||
}
|
||
```
|
||
|
||
- Settlements made **anywhere** (this tablet, other tablets, the dashboard) after the cursor, oldest first, at most
|
||
500 per sync; the tablet stores the returned cursor and sends it back next time.
|
||
- Without a cursor: the settlements of the last 30 days (or fewer; this only fills a new tablet's history).
|
||
- A settlement made on a tablet has the `settled` event's id; the tablet already has those and skips them by id. Include
|
||
them anyway: the feed doesn't need to know where a settlement came from.
|
||
- `lines[].units` = the units that were settled, plus removed (struck-through) units of those lines.
|
||
- `paymentMethod`: `cash`, `payconiq` or `payment_pending`.
|
||
|
||
### 5. Dashboard: settle and planned tabs
|
||
|
||
- **Close tab on the dashboard**: the server settles the tab itself, immediately, also when every tablet is offline.
|
||
Apply it like a `settled` event covering all active units (record `requestedBy`), bump `version`, and add it to the
|
||
settlements feed. Tablets see the lines disappear on their next sync. **Stop sending `close_tab` commands** to
|
||
tablets for this (the app still handles them, for older servers).
|
||
Use the tab's `version` as an optimistic check if the dashboard confirms a total: refuse when it changed since.
|
||
- **Planned tab names**: create them as tabs in the server state. Once `tabs` is sent, the app ignores
|
||
`plannedTabNames` (otherwise every tablet would open them).
|
||
|
||
### 6. Customer email
|
||
|
||
Since the Open Tab dialog, the bartender can add a customer with a name and an **optional email**. The app sends it
|
||
on the `tab_opened` event:
|
||
|
||
```json
|
||
{ "id": "…", "type": "tab_opened", "tabId": "…", "customerName": "Alice", "occurredAt": "…",
|
||
"openedAt": "…", "email": "alice@example.com" }
|
||
```
|
||
|
||
- `email` is **left out** (not `null`) when none was entered, and on every event from older app versions. The app trims
|
||
it and checks it looks like `name@domain.tld`; validate again if you rely on it, and reject nothing over it (store
|
||
`null` instead of refusing the event).
|
||
- Store it on the tab and return it as `email` in `tabs` (`null` or absent when there is none).
|
||
- There is no event to change or remove an email yet; the dashboard is the place for that if needed.
|
||
- Until the server returns `email`, the tablet keeps the one entered on it (by tab id) across syncs, but other
|
||
tablets don't see it. Note that the tablet treats `null`/absent as "unknown" and keeps its own value, so clearing an
|
||
email from the dashboard won't reach a tablet that has one stored.
|
||
- The email is personal data: show it only to venue staff, and delete it with the tab.
|
||
|
||
### Order within one sync
|
||
|
||
1. Apply the received events (once per id), oldest first; acknowledge/reject them.
|
||
2. Build `tabs` from the resulting state, and `settlements` after the request's cursor.
|
||
3. Commands, stock adjustments etc. unchanged.
|
||
|
||
### Not in this change
|
||
|
||
- **Stock with several tablets**: see `server-stock-source-of-truth.md`.
|
||
|
||
### Testing
|
||
|
||
- Pair a second tablet: it shows the venue's open tabs on its first sync.
|
||
- Tablet A adds 2 Cola to Alice, tablet B adds 1: both show 3, dashboard shows 3.
|
||
- Tablet A offline: adds drinks, opens a tab, renames one, deletes one. Back online: everything arrives once, both
|
||
tablets and the dashboard agree.
|
||
- Tablet A (offline) settles Alice with 2 Cola while tablet B adds a third: after both synced, Alice has 1 Cola left and
|
||
the settlement has 2.
|
||
- Tablet B deletes Bob while offline tablet A adds a beer to Bob: Bob comes back with that beer.
|
||
- Settle on the dashboard with every tablet off: the tab is emptied at once; the tablets show it in History after their
|
||
next sync, exactly once.
|
||
- The same event id sent twice (also in two syncs) is applied once.
|
||
- Add a customer with an email on tablet A: after both synced, tablet B shows the email under the name in the Open Tab
|
||
dialog. A customer added without email has `email: null` in `tabs`.
|