## 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 = ":"` (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 `":"`: 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": ":0", "addedAt": "…", "removedAt": null }, { "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`.