feat: make server owner of tabs
This commit is contained in:
@@ -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.
|
||||
@@ -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