feat: make server owner of tabs

This commit is contained in:
2026-10-20 16:15:14 +02:00
parent 5754ae742d
commit 93acfe15a9
26 changed files with 3654 additions and 389 deletions
+133
View File
@@ -0,0 +1,133 @@
## KoolTap Server — Hold the Stock (Source of Truth) for Several Tablets
Until now every tablet owned the stock: it counted down its own sales, reported its whole catalog with its own
`stock` on every sync, and applied the dashboard's stock adjustments itself. With two tablets in one venue each one
only knows its own sales, so they report different stock and the dashboard flips between them, and a delivery
registered on the dashboard is applied once per tablet.
From now on the **server holds the stock per product per venue**. It derives it from the tab events it already
receives (`line_added` / `line_removed` carry `productId` and `quantity`), counts made on a tablet (new `stock_set`
event) and the dashboard's adjustments. Tablets keep selling offline: they keep counting locally, and on every sync
they replace their stock with the server's and replay their unsent events on top, exactly like the tabs
(`server-tabs-source-of-truth.md`). Stock may go negative; a sale is never blocked.
The app side is done (KoolTap app, `lib/database/stock_queries.dart` `rebaseStock`,
`lib/services/remote_sync_service.dart`). Until the server sends `stock`, the app behaves exactly as before.
This builds on the tabs change: events are applied once per id, in the order of section "Order within one sync".
### 1. Stock state
Per `(venueId, productId)`, on the existing `Product` row:
| Field | Meaning |
|---|---|
| `stock` | `int` or `null`. `null` = not tracked: sales don't change it. May be negative. |
| `countedAt` | **new**, timestamp or `null`: when the stock was last set (a count). Line events that happened before it are already in that count. |
Migration: keep the current `stock` values (the last value a tablet reported) as the starting point, `countedAt` =
`null`. With several tablets that value may be off; a stock count on the dashboard fixes it.
### 2. Events that change stock
Apply these in the **same transaction** as the tab state, once per event id. An event that is a duplicate (already
applied) changes nothing, also not the stock.
| Event | Rule |
|---|---|
| `line_added` | `stock -= quantity`, if the product exists, `stock` is not `null`, and `countedAt` is `null` or `occurredAt > countedAt`. |
| `line_removed` | `stock += quantity`, same conditions. Applies the event's own `quantity` even when the tab state could remove fewer units (the tablet did the same). |
| `tab_deleted`, `settled`, `tab_opened`, `tab_renamed` | No stock change. A deleted tab's lines arrive as `line_removed` first; a settlement doesn't change stock, the drinks were counted down when added. |
| `stock_set` | **new**, see below. |
- An event without `productId` (older app versions) or for a product the server doesn't know changes no stock.
- `line_removed` on a line the tab state can't find still changes the stock: the tablet counted the drink back.
- Use the event's `occurredAt` as stored (after the existing clamp for clocks running ahead).
**`stock_set`** — a count or tracking change made on a tablet (inventory screen, a new product's starting stock).
It is not about a tab: it has **no `tabId` and no `customerName`**.
```json
{ "id": "…", "type": "stock_set", "productId": "12", "quantity": 30, "occurredAt": "2026-10-24T15:03:40.000Z" }
```
| Field | Type | Notes |
|---|---|---|
| `productId` | string | max 100 chars |
| `quantity` | int ≥ 0, or `null` | `null` = stop tracking this product |
Rule: if `countedAt` is `null` or `occurredAt >= countedAt`: `stock = quantity`, `countedAt = occurredAt`. Otherwise
ignore it (a newer count already exists, e.g. a dashboard count made while this tablet was offline). Unknown product →
acknowledge and ignore. Acknowledge it like any other event; it doesn't appear in the tab state or in `settlements`.
The tablet sends `stock_set` when a product is created (unless untracked), when the default products are seeded, and
when its stock or tracking is changed by hand.
### 3. Catalog (`products` in the request)
- Keep upserting `name`, `category` and `priceInCents` per reported product.
- **Ignore `stock`** in the report once the server owns the stock (keep accepting the field; older servers use it).
- A product the server sees for the first time is created with `stock = null` (untracked). Its starting stock arrives
as a `stock_set` event in the same request.
- **Stop removing products that one tablet doesn't report**: with several tablets, tablet B not knowing a product of
tablet A must not delete A's stock. Deleting or hiding products becomes a dashboard action (out of scope here).
Process `products` **before** the events of the same request, so a `stock_set` for a new product finds it.
### 4. Dashboard stock adjustments
The server applies them itself, **immediately**, also when every tablet is offline:
| `type` | Rule |
|---|---|
| `add` | `stock += quantity` (signed). Refuse when `stock` is `null` (`stock_not_tracked`). |
| `set` | `stock = quantity`, `countedAt = now`. On an untracked product this starts tracking it. |
Store it as `applied` with `stockAfter` right away. **Stop sending `stockAdjustments`** to tablets (send `[]` or leave
the key out): a tablet that receives `stock` ignores them anyway, so a delivery is never applied once per tablet.
Keep accepting `stockAdjustmentResults` from older app versions for adjustments that were already pending.
A `set` uses the server's clock, line events the tablet's. A few seconds of clock difference only matter for sales
rung up in the seconds around a count.
### 5. Sync response: `stock`
```json
{
"stock": [
{ "productId": "12", "stock": 46, "countedAt": "2026-10-24T15:03:40.000Z" },
{ "productId": "13", "stock": null, "countedAt": null }
]
}
```
- **Every** product of the venue, **after** applying the events acknowledged in this same response (and their
`stock_set` events). Events you reject must not change the stock: the tablet drops them.
- `stock`: int (may be negative) or `null` when not tracked. `countedAt`: ISO 8601 UTC or `null`.
- The tablet replaces the stock of every listed product and replays its unsent `line_added`, `line_removed` and
`stock_set` events on top with the same rules as section 2 (using the `countedAt` sent here). A product left out
keeps the tablet's own stock.
- Send `stock` on every successful sync. Leaving the key out means "this server doesn't own the stock" (old
behaviour: the tablet applies `stockAdjustments` itself).
### Order within one sync
1. Upsert the reported `products` (no `stock`, no removals).
2. Apply the received events (once per id), oldest first: tab state and stock in one transaction; acknowledge/reject.
3. Record `stockAdjustmentResults` from older tablets (unchanged).
4. Build `tabs`, `settlements` and `stock` from the resulting state.
5. Notify the dashboard (`stock` topic) when any stock changed.
### Testing
- Tablets A and B both sell 2 Cola (stock 48): both tablets and the dashboard show 44.
- Tablet A offline sells 3 Cola, B sells 1: B and the dashboard show 47 until A syncs, then everyone shows 44.
- Register a delivery of +24 on the dashboard with two tablets online: stock goes up by 24 once, on the dashboard at
once and on both tablets after their next sync.
- Tablet A offline sells 2 Cola at 14:00; the dashboard counts 30 at 15:00; A syncs at 16:00: stock stays 30.
- Tablet A offline sells 2 Cola at 15:30 after the 15:00 count of 30: stock 28 after A syncs.
- Tablet A sets Cola to 40 on its inventory screen: the dashboard and tablet B show 40 after the syncs.
- A sync that resends an already acknowledged `line_added`: the stock doesn't change.
- A rejected `line_added`: the stock doesn't change on the server, and the tablet's stock goes back after the sync.
- A tablet on the previous app version (no `stock_set`, still sends `stockAdjustmentResults`): its sales still count,
its catalog `stock` is ignored.
+140
View File
@@ -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.