feat: New Open Tab dialog with search and optional customer email. Drinks are now grouped per order. Payments trigger cha-ching and money rain.

This commit is contained in:
2026-10-28 02:11:32 +01:00
parent 1aee0541c4
commit 29ceb51177
41 changed files with 2771 additions and 812 deletions
+33 -6
View File
@@ -21,7 +21,7 @@ Common fields (already sent): `id`, `type`, `tabId`, `customerName`, `occurredAt
| `type` | Extra fields | New? |
|---|---|---|
| `tab_opened` | `openedAt` | **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 |
@@ -30,10 +30,15 @@ Common fields (already sent): `id`, `type`, `tabId`, `customerName`, `occurredAt
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, openedAt, version, lines: Line[] }
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
```
@@ -48,11 +53,11 @@ Unit { id, addedAt, removedAt | null } // one per piece ever put on the lin
| 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_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) and `addedAt = occurredAt`. |
| `line_removed` | Find the line; none → ignore. Mark the `min(quantity, line.quantity)` **latest** active units removed (`removedAt = occurredAt`). |
| `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
@@ -65,7 +70,7 @@ Stock is applied in the same transaction, see `server-stock-source-of-truth.md`.
```json
{
"tabs": [
{ "id": "…", "customerName": "Alice", "openedAt": "2026-10-24T20:00:00.000Z", "version": 7,
{ "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 },
@@ -116,6 +121,26 @@ Response:
- **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:
```json
{ "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.
@@ -138,3 +163,5 @@ Response:
- 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`.