7.7 KiB
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_removedon a line the tab state can't find still changes the stock: the tablet counted the drink back.- Use the event's
occurredAtas 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.
{ "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,categoryandpriceInCentsper reported product. - Ignore
stockin 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 astock_setevent 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
{
"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_setevents). Events you reject must not change the stock: the tablet drops them. stock: int (may be negative) ornullwhen not tracked.countedAt: ISO 8601 UTC ornull.- The tablet replaces the stock of every listed product and replays its unsent
line_added,line_removedandstock_setevents on top with the same rules as section 2 (using thecountedAtsent here). A product left out keeps the tablet's own stock. - Send
stockon every successful sync. Leaving the key out means "this server doesn't own the stock" (old behaviour: the tablet appliesstockAdjustmentsitself).
Order within one sync
- Upsert the reported
products(nostock, no removals). - Apply the received events (once per id), oldest first: tab state and stock in one transaction; acknowledge/reject.
- Record
stockAdjustmentResultsfrom older tablets (unchanged). - Build
tabs,settlementsandstockfrom the resulting state. - Notify the dashboard (
stocktopic) 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 sendsstockAdjustmentResults): its sales still count, its catalogstockis ignored.