+11
-2
@@ -44,8 +44,17 @@ Sync semantics:
|
||||
filters. Devices dedupe by command id and re-send the ack for commands they already processed.
|
||||
- The first ack for a command wins; later acks are accepted as no-ops.
|
||||
- `plannedTabNames` only contains planned customers the device has not opened a tab for yet. Once a sync
|
||||
reports an open tab with exactly that name, the customer is marked delivered and no longer sent.
|
||||
The customer (and their email, used for reminders) is kept.
|
||||
reports an open tab with that name (ignoring case and extra spaces), the customer is marked delivered
|
||||
and no longer sent. The customer (and their email, used for reminders) is kept.
|
||||
- `events` (optional) reports what happened on tabs since the last sync, for sales statistics. Each event
|
||||
has a terminal-generated UUID `id`, a `type`, `tabId`, `customerName` and `occurredAt` (ISO-8601, UTC):
|
||||
- `line_added` / `line_removed`: `productName`, `unitPriceInCents`, `quantity` (always positive)
|
||||
- `settled`: `amountInCents`, `paymentMethod` (`cash`, `payconiq`, `payment_pending` or `other`),
|
||||
optional `commandId` (the remote close that caused it) and `items` (the settled lines)
|
||||
|
||||
The response lists `acknowledgedEventIds` (stored, including events already stored by an earlier sync)
|
||||
and `rejectedEvents` (`[{ id, reason }]`, invalid and never stored). The terminal removes both from its
|
||||
outbox. An invalid event never fails the sync. At most 1000 events per sync.
|
||||
|
||||
### Tabs
|
||||
`GET /venues/:venueId/tabs` - List open tabs
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
## KoolTap Flutter App — Report Tab Events for Sales Statistics
|
||||
|
||||
The server can now store **what happens on tabs**: lines added, lines removed and settlements. This is used
|
||||
for sales statistics (sales per night, per product, per hour, cash vs Payconiq). Add an **event outbox** to
|
||||
the app: every change to a tab writes an event to a local table, the sync sends pending events to the
|
||||
server, and the app deletes them once the server confirms them.
|
||||
|
||||
Nothing else about the sync changes: `openTabs`, commands, acks and `plannedTabNames` keep working exactly
|
||||
as they do now.
|
||||
|
||||
### Existing Files (read these first)
|
||||
|
||||
- `lib/models/remote_command.dart` — `SyncResponse`, `RemoteCommand`, `SnapshotTab` and the sync request
|
||||
- `lib/services/remote_sync_service.dart` — the sync loop (`_performSync()` or similar)
|
||||
- `lib/services/bar_tab_service.dart` — creates and changes tabs, adds/removes lines, settles tabs
|
||||
- `lib/models/bar_tab.dart` — the `BarTab` model and its lines
|
||||
- `lib/database/app_database.dart` — the Drift database schema and migrations
|
||||
- The code that processes a `close_tab` remote command (search for `close_tab`)
|
||||
|
||||
Before writing code, find **every** place where a tab's lines or balance change: adding a product, changing
|
||||
a line's quantity, removing a line, changing a price, settling (locally and through a remote `close_tab`
|
||||
command) and deleting a tab. List them in your plan.
|
||||
|
||||
### The Events
|
||||
|
||||
Every event is a JSON object with these common fields:
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `id` | string (UUID v4) | Generated by the app when the event is created. Never reuse. |
|
||||
| `type` | string | `line_added`, `line_removed` or `settled` |
|
||||
| `tabId` | string | The same id the app sends as `SnapshotTab.id` |
|
||||
| `customerName` | string | The tab's customer name at that moment |
|
||||
| `occurredAt` | string | `DateTime.now().toUtc().toIso8601String()` at the moment it happened |
|
||||
|
||||
`line_added` and `line_removed` add:
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `productName` | string | As shown on the tab |
|
||||
| `unitPriceInCents` | int | Price per piece at that moment |
|
||||
| `quantity` | int | How many were added or removed. **Always positive** (≥ 1) |
|
||||
|
||||
`settled` adds:
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `amountInCents` | int | The amount that was settled |
|
||||
| `paymentMethod` | string | `cash`, `payconiq`, `payment_pending` or `other`. Map the app's own payment options to these. |
|
||||
| `commandId` | string (UUID), optional | The remote `close_tab` command id, when the settlement came from the dashboard |
|
||||
| `items` | list, optional | The lines that were settled: `[{ "productName", "quantity", "unitPriceInCents" }]` |
|
||||
|
||||
Examples:
|
||||
|
||||
```json
|
||||
{ "id": "6f1c2c0e-6a2b-4c1e-9a4c-1f2a3b4c5d6e", "type": "line_added", "tabId": "a1b2", "customerName": "Jan",
|
||||
"occurredAt": "2026-10-24T21:41:03.120Z", "productName": "Cola", "unitPriceInCents": 250, "quantity": 2 }
|
||||
|
||||
{ "id": "7a2d3e4f-5b6c-4d7e-8f9a-0b1c2d3e4f5a", "type": "settled", "tabId": "a1b2", "customerName": "Jan",
|
||||
"occurredAt": "2026-10-24T23:55:00.000Z", "amountInCents": 500, "paymentMethod": "payconiq",
|
||||
"items": [{ "productName": "Cola", "quantity": 2, "unitPriceInCents": 250 }] }
|
||||
```
|
||||
|
||||
### When to Write Which Event
|
||||
|
||||
- **Product added to a tab** → `line_added` with the quantity added.
|
||||
- **Quantity of a line increased** (e.g. 2 → 5) → `line_added` with `quantity: 3`.
|
||||
- **Quantity of a line decreased or a line removed** (e.g. 5 → 1) → `line_removed` with `quantity: 4`.
|
||||
- **Price of a line changed** → `line_removed` for the old line (old price) and `line_added` for the new one.
|
||||
- **Tab settled** (paid; the tab stays open with a zero balance) → **one** `settled` event with the amount,
|
||||
payment method and the settled lines. Do **not** also write `line_removed` events for the lines that the
|
||||
settlement clears: those drinks were sold, not taken back.
|
||||
- **Remote `close_tab` command processed** → the same `settled` event, with `paymentMethod` from the command
|
||||
and `commandId` set to the command's id. Write it only when the settlement actually happened (not when the
|
||||
command fails or conflicts), and only once, even if the same command is received again.
|
||||
- **Tab deleted while it still has lines** → `line_removed` for each remaining line (it was a mistake).
|
||||
- **Customer renamed** → no event; later events just use the new name.
|
||||
|
||||
### 1. Outbox Table (Drift)
|
||||
|
||||
In `lib/database/app_database.dart` add a table, bump `schemaVersion` and add a migration step that
|
||||
creates it for existing installs (existing data must stay):
|
||||
|
||||
```dart
|
||||
class TabEvents extends Table {
|
||||
TextColumn get id => text()(); // the event's UUID
|
||||
TextColumn get payload => text()(); // the event as JSON
|
||||
DateTimeColumn get createdAt => dateTime()();
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Write Events Together with the Change
|
||||
|
||||
In `BarTabService` (and wherever else tabs change), insert the event **in the same database transaction**
|
||||
as the change itself. That way a crash can never record a sale without the event, or an event without the
|
||||
sale. Put the JSON building in one small helper (e.g. `TabEventFactory` or methods on the service) instead
|
||||
of building maps in many places.
|
||||
|
||||
### 3. Send Events in the Sync
|
||||
|
||||
In the sync request, add an `events` field with the **oldest 500** events from the outbox (ordered by
|
||||
`createdAt`), as a list of the stored JSON objects:
|
||||
|
||||
```json
|
||||
{ "deviceId": "…", "openTabs": [ … ], "events": [ { … }, { … } ] }
|
||||
```
|
||||
|
||||
Read the events **before** building `openTabs`, so the snapshot is never older than the events.
|
||||
|
||||
### 4. Handle the Response
|
||||
|
||||
Add to `SyncResponse` (both optional, default to empty lists so an older server still works):
|
||||
|
||||
```dart
|
||||
final List<String> acknowledgedEventIds; // json['acknowledgedEventIds']
|
||||
final List<RejectedEvent> rejectedEvents; // json['rejectedEvents']: [{ "id": String?, "reason": String }]
|
||||
```
|
||||
|
||||
After a successful sync:
|
||||
- Delete every event in `acknowledgedEventIds` from the outbox.
|
||||
- Delete every event in `rejectedEvents` that has an id too, and log `id` + `reason` (debug log is fine).
|
||||
Re-sending won't help: it's a bug in the app that should be fixed.
|
||||
- Events created while the sync was in flight are not in either list; they stay and go with the next sync.
|
||||
|
||||
If the sync request fails (network, 5xx), delete nothing; the same events are sent again next time. The
|
||||
server stores each event id only once, so re-sending is safe.
|
||||
|
||||
If the outbox has more than 500 events (e.g. after a long time offline), they drain 500 per sync; no
|
||||
special handling needed.
|
||||
|
||||
### Important Notes
|
||||
|
||||
- Generate ids with the `uuid` package (`const Uuid().v4()`), already used in the app if available; add it
|
||||
otherwise.
|
||||
- Always send `occurredAt` in UTC with the `Z` suffix.
|
||||
- Amounts are integers in cents everywhere, like `SnapshotTab.totalInCents`.
|
||||
- Don't change how tabs, versions, `openTabs`, commands or planned tabs work.
|
||||
- Keep the outbox in the local database, never only in memory: events must survive an app restart and a
|
||||
tablet that is offline for a whole evening.
|
||||
|
||||
### Testing
|
||||
|
||||
Unit tests:
|
||||
- Adding a product, changing a quantity up and down, and removing a line write the right events with the
|
||||
right quantities.
|
||||
- Settling writes one `settled` event and no `line_removed` events.
|
||||
- A processed `close_tab` command writes a `settled` event with its `commandId` and payment method, once.
|
||||
- The sync sends at most 500 events, oldest first, and deletes exactly the acknowledged and rejected ids.
|
||||
- A failed sync deletes nothing.
|
||||
|
||||
Manual check against the server:
|
||||
1. Add a few drinks to a tab, remove one, and settle it with Payconiq.
|
||||
2. After the next sync the outbox is empty.
|
||||
3. On the server: `SELECT type, "productName", "quantityDelta", "amountInCents", "paymentMethod" FROM "TabEvent" ORDER BY "occurredAt";`
|
||||
shows the additions, the removal (negative `quantityDelta`) and the settlement.
|
||||
4. Turn off wifi, add drinks, turn it back on: the events arrive with their original `occurredAt`.
|
||||
|
||||
### Files to Modify
|
||||
|
||||
- `lib/database/app_database.dart` — `TabEvents` table, schema version and migration
|
||||
- `lib/services/bar_tab_service.dart` — write events with every change to a tab's lines and on settlement
|
||||
- The remote `close_tab` handling — write the `settled` event with `commandId`
|
||||
- `lib/models/remote_command.dart` — `events` in the sync request, `acknowledgedEventIds` and `rejectedEvents` in `SyncResponse`
|
||||
- `lib/services/remote_sync_service.dart` — send outbox events, delete acknowledged and rejected ones
|
||||
Reference in New Issue
Block a user