Files
kooltab/docs/server-tabs-source-of-truth.md

10 KiB
Raw Permalink Blame History

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

{
  "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:

{
  "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:

{ "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.