8.4 KiB
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,SnapshotTaband the sync requestlib/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 tabslib/models/bar_tab.dart— theBarTabmodel and its lineslib/database/app_database.dart— the Drift database schema and migrations- The code that processes a
close_tabremote command (search forclose_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:
{ "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_addedwith the quantity added. - Quantity of a line increased (e.g. 2 → 5) →
line_addedwithquantity: 3. - Quantity of a line decreased or a line removed (e.g. 5 → 1) →
line_removedwithquantity: 4. - Price of a line changed →
line_removedfor the old line (old price) andline_addedfor the new one. - Tab settled (paid; the tab stays open with a zero balance) → one
settledevent with the amount, payment method and the settled lines. Do not also writeline_removedevents for the lines that the settlement clears: those drinks were sold, not taken back. - Remote
close_tabcommand processed → the samesettledevent, withpaymentMethodfrom the command andcommandIdset 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_removedfor 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):
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:
{ "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):
final List<String> acknowledgedEventIds; // json['acknowledgedEventIds']
final List<RejectedEvent> rejectedEvents; // json['rejectedEvents']: [{ "id": String?, "reason": String }]
After a successful sync:
- Delete every event in
acknowledgedEventIdsfrom the outbox. - Delete every event in
rejectedEventsthat has an id too, and logid+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
uuidpackage (const Uuid().v4()), already used in the app if available; add it otherwise. - Always send
occurredAtin UTC with theZsuffix. - 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
settledevent and noline_removedevents. - A processed
close_tabcommand writes asettledevent with itscommandIdand 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:
- Add a few drinks to a tab, remove one, and settle it with Payconiq.
- After the next sync the outbox is empty.
- On the server:
SELECT type, "productName", "quantityDelta", "amountInCents", "paymentMethod" FROM "TabEvent" ORDER BY "occurredAt";shows the additions, the removal (negativequantityDelta) and the settlement. - Turn off wifi, add drinks, turn it back on: the events arrive with their original
occurredAt.
Files to Modify
lib/database/app_database.dart—TabEventstable, schema version and migrationlib/services/bar_tab_service.dart— write events with every change to a tab's lines and on settlement- The remote
close_tabhandling — write thesettledevent withcommandId lib/models/remote_command.dart—eventsin the sync request,acknowledgedEventIdsandrejectedEventsinSyncResponselib/services/remote_sync_service.dart— send outbox events, delete acknowledged and rejected ones