Files
kooltab-backoffice/docs/flutter-sales-events-prompt.md
T
brammie15 874f68b4b1
CI / build-and-test (push) Canceled after 0s
feat: add feature
2026-10-19 23:23:36 +02:00

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, 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:

{ "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):

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 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