## 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: managed on the dashboard - The dashboard adds, changes and archives products (archived = deleted for the tablets, kept for tabs and history). A product added with a starting count is tracked from that moment. - The tablet's `products` report is only used on the first sync of the venue, to take over its catalog and stock once. After that it is ignored, also its `stock`. - The sync response sends `catalog`: every product of the venue, `[{ id, name, category, priceInCents, lowStockThreshold, archived }]`. The tablet adds and updates its products from it, and hides archived ones and ones the catalog doesn't list (such as the default products of a newly paired tablet). Leaving the key out means "the tablet manages its own catalog". - A paired tablet can't add, rename, reprice or delete products; it still counts stock (`stock_set`) and keeps its own product pictures until pictures are managed on the dashboard. - A `stock_set` for a product the venue doesn't have is acknowledged and ignored. ### 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.