feat: make server owner of tabs
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
## 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` | **new** |
|
||||
| `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).
|
||||
|
||||
### 2. The venue's tab state
|
||||
|
||||
```
|
||||
Tab { id, customerName, 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` 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) and `addedAt = occurredAt`. |
|
||||
| `line_removed` | Find the line; none → ignore. Mark the `min(quantity, line.quantity)` **latest** active units removed (`removedAt = occurredAt`). |
|
||||
| `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", "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).
|
||||
|
||||
### 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.
|
||||
Reference in New Issue
Block a user