diff --git a/lib/database/app_database.dart b/lib/database/app_database.dart index 553f832..04319c7 100644 --- a/lib/database/app_database.dart +++ b/lib/database/app_database.dart @@ -187,6 +187,23 @@ class ProcessedRemoteCommands extends Table { Set get primaryKey => {commandId}; } +/// Outbox of tab events (lines added/removed, settlements) waiting to be +/// sent to the server for sales statistics. Rows are deleted once the server +/// acknowledges or rejects them. +@DataClassName('TabEventRow') +class TabEvents extends Table { + /// The event's UUID. + TextColumn get id => text()(); + + /// The event as JSON, exactly as it is sent. + TextColumn get payload => text()(); + + DateTimeColumn get createdAt => dateTime()(); + + @override + Set get primaryKey => {id}; +} + @DriftDatabase( tables: [ Products, @@ -198,13 +215,14 @@ class ProcessedRemoteCommands extends Table { AppSettingsTable, RemoteSyncState, ProcessedRemoteCommands, + TabEvents, ], ) class AppDatabase extends _$AppDatabase { AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); @override - int get schemaVersion => 14; + int get schemaVersion => 15; @override MigrationStrategy get migration { @@ -303,6 +321,10 @@ class AppDatabase extends _$AppDatabase { if (from < 14 && !await hasTableColumn('products', 'infinite_stock')) { await migrator.addColumn(products, products.infiniteStock); } + + if (from < 15) { + await migrator.createTable(tabEvents); + } }, ); } diff --git a/lib/database/app_database.g.dart b/lib/database/app_database.g.dart index 35aac62..ede97eb 100644 --- a/lib/database/app_database.g.dart +++ b/lib/database/app_database.g.dart @@ -3796,6 +3796,270 @@ class ProcessedRemoteCommandsCompanion } } +class $TabEventsTable extends TabEvents + with TableInfo<$TabEventsTable, TabEventRow> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $TabEventsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _payloadMeta = const VerificationMeta( + 'payload', + ); + @override + late final GeneratedColumn payload = GeneratedColumn( + 'payload', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + @override + List get $columns => [id, payload, createdAt]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'tab_events'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('payload')) { + context.handle( + _payloadMeta, + payload.isAcceptableOrUnknown(data['payload']!, _payloadMeta), + ); + } else if (isInserting) { + context.missing(_payloadMeta); + } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } else if (isInserting) { + context.missing(_createdAtMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + TabEventRow map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return TabEventRow( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + payload: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}payload'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + ); + } + + @override + $TabEventsTable createAlias(String alias) { + return $TabEventsTable(attachedDatabase, alias); + } +} + +class TabEventRow extends DataClass implements Insertable { + /// The event's UUID. + final String id; + + /// The event as JSON, exactly as it is sent. + final String payload; + final DateTime createdAt; + const TabEventRow({ + required this.id, + required this.payload, + required this.createdAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['payload'] = Variable(payload); + map['created_at'] = Variable(createdAt); + return map; + } + + TabEventsCompanion toCompanion(bool nullToAbsent) { + return TabEventsCompanion( + id: Value(id), + payload: Value(payload), + createdAt: Value(createdAt), + ); + } + + factory TabEventRow.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return TabEventRow( + id: serializer.fromJson(json['id']), + payload: serializer.fromJson(json['payload']), + createdAt: serializer.fromJson(json['createdAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'payload': serializer.toJson(payload), + 'createdAt': serializer.toJson(createdAt), + }; + } + + TabEventRow copyWith({String? id, String? payload, DateTime? createdAt}) => + TabEventRow( + id: id ?? this.id, + payload: payload ?? this.payload, + createdAt: createdAt ?? this.createdAt, + ); + TabEventRow copyWithCompanion(TabEventsCompanion data) { + return TabEventRow( + id: data.id.present ? data.id.value : this.id, + payload: data.payload.present ? data.payload.value : this.payload, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + ); + } + + @override + String toString() { + return (StringBuffer('TabEventRow(') + ..write('id: $id, ') + ..write('payload: $payload, ') + ..write('createdAt: $createdAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, payload, createdAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is TabEventRow && + other.id == this.id && + other.payload == this.payload && + other.createdAt == this.createdAt); +} + +class TabEventsCompanion extends UpdateCompanion { + final Value id; + final Value payload; + final Value createdAt; + final Value rowid; + const TabEventsCompanion({ + this.id = const Value.absent(), + this.payload = const Value.absent(), + this.createdAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + TabEventsCompanion.insert({ + required String id, + required String payload, + required DateTime createdAt, + this.rowid = const Value.absent(), + }) : id = Value(id), + payload = Value(payload), + createdAt = Value(createdAt); + static Insertable custom({ + Expression? id, + Expression? payload, + Expression? createdAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (payload != null) 'payload': payload, + if (createdAt != null) 'created_at': createdAt, + if (rowid != null) 'rowid': rowid, + }); + } + + TabEventsCompanion copyWith({ + Value? id, + Value? payload, + Value? createdAt, + Value? rowid, + }) { + return TabEventsCompanion( + id: id ?? this.id, + payload: payload ?? this.payload, + createdAt: createdAt ?? this.createdAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (payload.present) { + map['payload'] = Variable(payload.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('TabEventsCompanion(') + ..write('id: $id, ') + ..write('payload: $payload, ') + ..write('createdAt: $createdAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + abstract class _$AppDatabase extends GeneratedDatabase { _$AppDatabase(QueryExecutor e) : super(e); $AppDatabaseManager get managers => $AppDatabaseManager(this); @@ -3815,6 +4079,7 @@ abstract class _$AppDatabase extends GeneratedDatabase { ); late final $ProcessedRemoteCommandsTable processedRemoteCommands = $ProcessedRemoteCommandsTable(this); + late final $TabEventsTable tabEvents = $TabEventsTable(this); @override Iterable> get allTables => allSchemaEntities.whereType>(); @@ -3829,6 +4094,7 @@ abstract class _$AppDatabase extends GeneratedDatabase { appSettingsTable, remoteSyncState, processedRemoteCommands, + tabEvents, ]; @override StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules([ @@ -6508,6 +6774,168 @@ typedef $$ProcessedRemoteCommandsTableProcessedTableManager = ProcessedRemoteCommandRow, PrefetchHooks Function() >; +typedef $$TabEventsTableCreateCompanionBuilder = + TabEventsCompanion Function({ + required String id, + required String payload, + required DateTime createdAt, + Value rowid, + }); +typedef $$TabEventsTableUpdateCompanionBuilder = + TabEventsCompanion Function({ + Value id, + Value payload, + Value createdAt, + Value rowid, + }); + +class $$TabEventsTableFilterComposer + extends Composer<_$AppDatabase, $TabEventsTable> { + $$TabEventsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get payload => $composableBuilder( + column: $table.payload, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); +} + +class $$TabEventsTableOrderingComposer + extends Composer<_$AppDatabase, $TabEventsTable> { + $$TabEventsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get payload => $composableBuilder( + column: $table.payload, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$TabEventsTableAnnotationComposer + extends Composer<_$AppDatabase, $TabEventsTable> { + $$TabEventsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get payload => + $composableBuilder(column: $table.payload, builder: (column) => column); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); +} + +class $$TabEventsTableTableManager + extends + RootTableManager< + _$AppDatabase, + $TabEventsTable, + TabEventRow, + $$TabEventsTableFilterComposer, + $$TabEventsTableOrderingComposer, + $$TabEventsTableAnnotationComposer, + $$TabEventsTableCreateCompanionBuilder, + $$TabEventsTableUpdateCompanionBuilder, + ( + TabEventRow, + BaseReferences<_$AppDatabase, $TabEventsTable, TabEventRow>, + ), + TabEventRow, + PrefetchHooks Function() + > { + $$TabEventsTableTableManager(_$AppDatabase db, $TabEventsTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$TabEventsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$TabEventsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$TabEventsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value payload = const Value.absent(), + Value createdAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => TabEventsCompanion( + id: id, + payload: payload, + createdAt: createdAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + required String payload, + required DateTime createdAt, + Value rowid = const Value.absent(), + }) => TabEventsCompanion.insert( + id: id, + payload: payload, + createdAt: createdAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$TabEventsTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $TabEventsTable, + TabEventRow, + $$TabEventsTableFilterComposer, + $$TabEventsTableOrderingComposer, + $$TabEventsTableAnnotationComposer, + $$TabEventsTableCreateCompanionBuilder, + $$TabEventsTableUpdateCompanionBuilder, + ( + TabEventRow, + BaseReferences<_$AppDatabase, $TabEventsTable, TabEventRow>, + ), + TabEventRow, + PrefetchHooks Function() + >; class $AppDatabaseManager { final _$AppDatabase _db; @@ -6533,4 +6961,6 @@ class $AppDatabaseManager { _db, _db.processedRemoteCommands, ); + $$TabEventsTableTableManager get tabEvents => + $$TabEventsTableTableManager(_db, _db.tabEvents); } diff --git a/lib/models/remote_command.dart b/lib/models/remote_command.dart index 3967d96..79531ad 100644 --- a/lib/models/remote_command.dart +++ b/lib/models/remote_command.dart @@ -59,15 +59,34 @@ class RemoteCommand { } } +/// A tab event the server refused to store. Re-sending won't help. +class RejectedEvent { + final String? id; + final String reason; + + const RejectedEvent({this.id, required this.reason}); + + factory RejectedEvent.fromJson(Map json) { + return RejectedEvent( + id: json['id'] as String?, + reason: json['reason'] as String? ?? '', + ); + } +} + class SyncResponse { final List commands; final String? cursor; final List plannedTabNames; + final List acknowledgedEventIds; + final List rejectedEvents; const SyncResponse({ required this.commands, this.cursor, this.plannedTabNames = const [], + this.acknowledgedEventIds = const [], + this.rejectedEvents = const [], }); factory SyncResponse.fromJson(Map json) { @@ -81,6 +100,14 @@ class SyncResponse { for (final name in json['plannedTabNames'] as List? ?? []) name as String, ], + acknowledgedEventIds: [ + for (final id in json['acknowledgedEventIds'] as List? ?? []) + id as String, + ], + rejectedEvents: [ + for (final event in json['rejectedEvents'] as List? ?? []) + RejectedEvent.fromJson(event as Map), + ], ); } } @@ -136,16 +163,21 @@ class SyncRequest { final String? cursor; final List openTabs; + /// Outbox tab events, oldest first, as their stored JSON objects. + final List> events; + const SyncRequest({ required this.deviceId, this.cursor, required this.openTabs, + this.events = const [], }); Map toJson() => { 'deviceId': deviceId, if (cursor != null) 'cursor': cursor, 'openTabs': openTabs.map((tab) => tab.toJson()).toList(), + 'events': events, }; } diff --git a/lib/models/tab_event.dart b/lib/models/tab_event.dart new file mode 100644 index 0000000..0d1a01a --- /dev/null +++ b/lib/models/tab_event.dart @@ -0,0 +1,158 @@ +import 'package:uuid/uuid.dart'; + +import 'payment_method.dart'; + +/// Types of [TabEvent] the server understands. +enum TabEventType { + lineAdded('line_added'), + lineRemoved('line_removed'), + settled('settled'); + + final String value; + + const TabEventType(this.value); +} + +/// Something that happened on a tab, reported to the server for sales +/// statistics. [payload] is the JSON object that is stored in the outbox and +/// sent as is. +class TabEvent { + final String id; + final Map payload; + + const TabEvent({required this.id, required this.payload}); +} + +/// One settled line in a `settled` event. +typedef SettledLine = ({ + String productName, + int quantity, + int unitPriceInCents, +}); + +/// Builds the events, so their JSON shape lives in one place. +class TabEventFactory { + final _uuid = const Uuid(); + final DateTime Function() _now; + + TabEventFactory({DateTime Function()? now}) : _now = now ?? DateTime.now; + + /// [quantity] is how many pieces were added; always positive. + TabEvent lineAdded({ + required String tabId, + required String customerName, + required String productName, + required int unitPriceInCents, + required int quantity, + }) { + return _lineEvent( + TabEventType.lineAdded, + tabId: tabId, + customerName: customerName, + productName: productName, + unitPriceInCents: unitPriceInCents, + quantity: quantity, + ); + } + + /// [quantity] is how many pieces were removed; always positive. + TabEvent lineRemoved({ + required String tabId, + required String customerName, + required String productName, + required int unitPriceInCents, + required int quantity, + }) { + return _lineEvent( + TabEventType.lineRemoved, + tabId: tabId, + customerName: customerName, + productName: productName, + unitPriceInCents: unitPriceInCents, + quantity: quantity, + ); + } + + TabEvent settled({ + required String tabId, + required String customerName, + required int amountInCents, + required PaymentMethod paymentMethod, + required List items, + String? commandId, + }) { + return _event( + TabEventType.settled, + tabId: tabId, + customerName: customerName, + fields: { + 'amountInCents': amountInCents, + 'paymentMethod': paymentMethodValue(paymentMethod), + 'commandId': ?commandId, + 'items': [ + for (final item in items) + { + 'productName': item.productName, + 'quantity': item.quantity, + 'unitPriceInCents': item.unitPriceInCents, + }, + ], + }, + ); + } + + /// The server's payment method names: `cash`, `payconiq`, + /// `payment_pending` or `other`. + static String paymentMethodValue(PaymentMethod method) { + return switch (method) { + PaymentMethod.cash => 'cash', + PaymentMethod.payconiq => 'payconiq', + PaymentMethod.paymentPending => 'payment_pending', + }; + } + + TabEvent _lineEvent( + TabEventType type, { + required String tabId, + required String customerName, + required String productName, + required int unitPriceInCents, + required int quantity, + }) { + if (quantity < 1) { + throw ArgumentError.value(quantity, 'quantity', 'Must be at least 1'); + } + + return _event( + type, + tabId: tabId, + customerName: customerName, + fields: { + 'productName': productName, + 'unitPriceInCents': unitPriceInCents, + 'quantity': quantity, + }, + ); + } + + TabEvent _event( + TabEventType type, { + required String tabId, + required String customerName, + required Map fields, + }) { + final id = _uuid.v4(); + + return TabEvent( + id: id, + payload: { + 'id': id, + 'type': type.value, + 'tabId': tabId, + 'customerName': customerName, + 'occurredAt': _now().toUtc().toIso8601String(), + ...fields, + }, + ); + } +} diff --git a/lib/services/bar_tab_service.dart b/lib/services/bar_tab_service.dart index ab5b862..7c83a5e 100644 --- a/lib/services/bar_tab_service.dart +++ b/lib/services/bar_tab_service.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:math'; import 'package:drift/drift.dart'; @@ -10,6 +11,7 @@ import '../models/closed_tab.dart'; import '../models/closed_tab_item.dart'; import '../models/payment_method.dart'; import '../models/product.dart'; +import '../models/tab_event.dart'; import '../models/tab_item.dart'; import '../models/tab_item_purchase.dart'; @@ -69,11 +71,13 @@ abstract class BarTabService { /// The tab itself stays open under the same customer name. /// /// Throws [TabVersionConflictException] when [expectedVersion] is given and - /// the tab changed since. + /// the tab changed since. [commandId] is the remote command that requested + /// the settlement, if any. Future closeTab( String tabId, { PaymentMethod paymentMethod = PaymentMethod.cash, int? expectedVersion, + String? commandId, }); Future> getClosedTabs(); @@ -91,9 +95,11 @@ abstract class BarTabService { class DriftBarTabService implements BarTabService { final AppDatabase database; + final TabEventFactory eventFactory; final _uuid = const Uuid(); - DriftBarTabService({required this.database}); + DriftBarTabService({required this.database, TabEventFactory? eventFactory}) + : eventFactory = eventFactory ?? TabEventFactory(); TabItemPurchase _mapPurchaseRow(TabItemPurchaseRow row) { return TabItemPurchase( @@ -255,6 +261,21 @@ class DriftBarTabService implements BarTabService { )..where((item) => item.tabId.equals(tabId))).go(); } + /// Queues [event] in the outbox. Call it inside the transaction that makes + /// the change, so the change and its event are stored together or not at + /// all. + Future _recordEvent(TabEvent event) async { + await database + .into(database.tabEvents) + .insert( + TabEventsCompanion.insert( + id: event.id, + payload: jsonEncode(event.payload), + createdAt: DateTime.now(), + ), + ); + } + Future _incrementTabVersion(String tabId) async { await (database.update( database.barTabs, @@ -341,6 +362,15 @@ class DriftBarTabService implements BarTabService { for (final item in tab.items) { await database.increaseStock(item.productId, item.quantity); + await _recordEvent( + eventFactory.lineRemoved( + tabId: tab.id, + customerName: tab.customerName, + productName: item.productName, + unitPriceInCents: item.unitPriceInCents, + quantity: item.quantity, + ), + ); } await _deleteItemsOfTab(tabId); @@ -356,6 +386,9 @@ class DriftBarTabService implements BarTabService { required Product product, }) async { await database.transaction(() async { + final tabRow = await _getTabRow(tabId); + if (tabRow == null) throw TabNotFoundException(tabId); + await database.decreaseStock(product.id, 1); final existingItem = @@ -373,6 +406,16 @@ class DriftBarTabService implements BarTabService { TabItemsCompanion(quantity: Value(existingItem.quantity + 1)), ); await _recordPurchase(existingItem.id); + // The line keeps the price it was first added at + await _recordEvent( + eventFactory.lineAdded( + tabId: tabId, + customerName: tabRow.customerName, + productName: existingItem.productName, + unitPriceInCents: existingItem.unitPriceInCents, + quantity: 1, + ), + ); } else { final tabItemId = _uuid.v4(); @@ -390,6 +433,15 @@ class DriftBarTabService implements BarTabService { ), ); await _recordPurchase(tabItemId); + await _recordEvent( + eventFactory.lineAdded( + tabId: tabId, + customerName: tabRow.customerName, + productName: product.name, + unitPriceInCents: product.priceInCents, + quantity: 1, + ), + ); } await _incrementTabVersion(tabId); @@ -429,6 +481,20 @@ class DriftBarTabService implements BarTabService { ..where((row) => row.id.equals(tabItemId))) .write(TabItemsCompanion(quantity: Value(newQuantity))); + final tabRow = await _getTabRow(item.tabId); + final lineEvent = actualDelta > 0 + ? eventFactory.lineAdded + : eventFactory.lineRemoved; + await _recordEvent( + lineEvent( + tabId: item.tabId, + customerName: tabRow?.customerName ?? '', + productName: item.productName, + unitPriceInCents: item.unitPriceInCents, + quantity: actualDelta.abs(), + ), + ); + await _incrementTabVersion(item.tabId); return actualDelta; @@ -440,6 +506,7 @@ class DriftBarTabService implements BarTabService { String tabId, { PaymentMethod paymentMethod = PaymentMethod.cash, int? expectedVersion, + String? commandId, }) async { await database.transaction(() async { final tabRow = await _getTabRow(tabId); @@ -488,6 +555,34 @@ class DriftBarTabService implements BarTabService { ]); }); + // One settlement, not a removal per line: these drinks were sold + final settledItems = [ + for (final item in items) + if (item.quantity > 0) item, + ]; + if (settledItems.isNotEmpty) { + await _recordEvent( + eventFactory.settled( + tabId: tabId, + customerName: tabRow.customerName, + amountInCents: settledItems.fold( + 0, + (total, item) => total + item.lineTotalInCents, + ), + paymentMethod: paymentMethod, + commandId: commandId, + items: [ + for (final item in settledItems) + ( + productName: item.productName, + quantity: item.quantity, + unitPriceInCents: item.unitPriceInCents, + ), + ], + ), + ); + } + await _deleteItemsOfTab(tabId); await _incrementTabVersion(tabId); }); diff --git a/lib/services/remote_sync_service.dart b/lib/services/remote_sync_service.dart index 3218748..98b1a19 100644 --- a/lib/services/remote_sync_service.dart +++ b/lib/services/remote_sync_service.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:drift/drift.dart'; @@ -45,6 +46,9 @@ class RemoteSyncService { static const _venueIdKey = 'remote_venue_id'; static const _syncStateId = 'default'; + /// Most outbox events sent per sync; a larger backlog drains over syncs. + static const maxEventsPerSync = 500; + static const _pollingInterval = Duration( seconds: kSyncPollingIntervalSeconds, ); @@ -227,6 +231,8 @@ class RemoteSyncService { Future _doSync(_Credentials credentials) async { final state = await _getSyncState(); + // Read before the snapshot, so the snapshot is never older than the events + final outbox = await _pendingEvents(); final openTabs = await barTabService.getOpenTabs(); final response = await apiClient.sync( @@ -235,9 +241,15 @@ class RemoteSyncService { deviceId: credentials.deviceId, cursor: state?.serverCursor, openTabs: openTabs.map(_toSnapshot).toList(), + events: [ + for (final row in outbox) + jsonDecode(row.payload) as Map, + ], ), ); + await _clearSentEvents(response); + for (final command in response.commands) { await _processCommand(command, credentials); } @@ -251,6 +263,38 @@ class RemoteSyncService { await _updateSyncState(success: true, cursor: response.cursor); } + Future> _pendingEvents() { + return (database.select(database.tabEvents) + ..orderBy([ + (row) => OrderingTerm.asc(row.createdAt), + // Same-second events keep their insertion order + (row) => OrderingTerm.asc(const CustomExpression('rowid')), + ]) + ..limit(maxEventsPerSync)) + .get(); + } + + /// Events the server stored or refused leave the outbox. Events created + /// while the sync was in flight are in neither list and go next time. + Future _clearSentEvents(SyncResponse response) async { + for (final rejected in response.rejectedEvents) { + debugPrint( + 'RemoteSyncService: event ${rejected.id} rejected: ${rejected.reason}', + ); + } + + final ids = { + ...response.acknowledgedEventIds, + for (final rejected in response.rejectedEvents) + if (rejected.id != null) rejected.id!, + }; + if (ids.isEmpty) return; + + await (database.delete( + database.tabEvents, + )..where((row) => row.id.isIn(ids))).go(); + } + SnapshotTab _toSnapshot(BarTab tab) { return SnapshotTab( id: tab.id, @@ -309,6 +353,7 @@ class RemoteSyncService { command.tabId, paymentMethod: PaymentMethod.fromValue(command.paymentMethod), expectedVersion: command.expectedVersion, + commandId: command.id, ); _eventController.add( diff --git a/pubspec.yaml b/pubspec.yaml index 7b05de5..4b29611 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: kooltap description: "KoolTap: a tablet point-of-sale app for running bar tabs." publish_to: 'none' -version: 1.2.13+0 +version: 1.3.0+0 environment: sdk: ^3.12.2 diff --git a/test/database_migration_test.dart b/test/database_migration_test.dart index 1804dc6..1280986 100644 --- a/test/database_migration_test.dart +++ b/test/database_migration_test.dart @@ -89,4 +89,9 @@ void main() { expect(orphan, isNull); }); + + test('creates an empty tab event outbox', () async { + expect(await database.select(database.tabEvents).get(), isEmpty); + expect((await database.select(database.barTabs).get()), hasLength(1)); + }); } diff --git a/test/remote_sync_service_test.dart b/test/remote_sync_service_test.dart index 87a79d8..659fb83 100644 --- a/test/remote_sync_service_test.dart +++ b/test/remote_sync_service_test.dart @@ -443,4 +443,152 @@ void main() { }, ); }); + + group('tab events', () { + Future queueEvent(String id, DateTime createdAt) async { + await database + .into(database.tabEvents) + .insert( + TabEventsCompanion.insert( + id: id, + payload: jsonEncode({'id': id, 'type': 'line_added'}), + createdAt: createdAt, + ), + ); + } + + Future> outboxIds() async { + final rows = await database.select(database.tabEvents).get(); + return [for (final row in rows) row.id]; + } + + List sentIds([int request = 0]) => [ + for (final event in server.syncRequests[request]['events'] as List) + event['id'] as String, + ]; + + test('sends at most 500 events, oldest first', () async { + final start = DateTime(2026, 10, 24, 20); + // Inserted newest first, so only the timestamps give the order + for (var i = 501; i >= 1; i--) { + await queueEvent('e$i', start.add(Duration(minutes: i))); + } + + await service.sync(); + + expect(sentIds(), [for (var i = 1; i <= 500; i++) 'e$i']); + expect(server.syncRequests.single['events'].first, { + 'id': 'e1', + 'type': 'line_added', + }); + }); + + test('deletes exactly the acknowledged and rejected events', () async { + final now = DateTime.now(); + for (final id in ['e1', 'e2', 'e3']) { + await queueEvent(id, now); + } + server.respondWith({ + 'acknowledgedEventIds': ['e1'], + 'rejectedEvents': [ + {'id': 'e2', 'reason': 'unknown type'}, + {'reason': 'not an object'}, + ], + }); + + await service.sync(); + + expect(await outboxIds(), ['e3']); + }); + + test('a failed sync deletes nothing', () async { + await queueEvent('e1', DateTime.now()); + server.syncResponse = http.Response( + jsonEncode({ + 'acknowledgedEventIds': ['e1'], + }), + 500, + ); + + await service.sync(); + + expect(await outboxIds(), ['e1']); + }); + + test('an older server without event fields keeps the events', () async { + await queueEvent('e1', DateTime.now()); + + await service.sync(); + + expect(await outboxIds(), ['e1']); + }); + + test('events created during the sync wait for the next one', () async { + final tab = await barTabService.createTab(customerName: 'Alice'); + await barTabService.addProductToTab(tabId: tab.id, product: _beer); + server.holdSync = Completer(); + + final running = service.sync(); + await pumpEventQueue(); + await barTabService.addProductToTab(tabId: tab.id, product: _beer); + server.respondWith({'acknowledgedEventIds': sentIds()}); + server.holdSync!.complete(); + server.holdSync = null; + await running; + + expect(sentIds(), hasLength(1)); + final remaining = await outboxIds(); + expect(remaining, hasLength(1)); + expect(remaining, isNot(contains(sentIds().single))); + }); + + test('a close_tab command writes one settled event', () async { + final tab = await barTabService.createTab(customerName: 'Alice'); + await barTabService.addProductToTab(tabId: tab.id, product: _beer); + await barTabService.addProductToTab(tabId: tab.id, product: _beer); + final current = (await barTabService.getTabById(tab.id))!; + server.respondWith({ + 'commands': [ + _closeCommand( + id: 'cmd-1', + tabId: tab.id, + expectedVersion: current.version, + ), + ], + }); + + // The second sync receives the same command again + await service.sync(); + await service.sync(); + + final settled = [ + for (final event in server.syncRequests.last['events'] as List) + if (event['type'] == 'settled') event, + ]; + expect(settled, hasLength(1)); + expect(settled.single['commandId'], 'cmd-1'); + expect(settled.single['paymentMethod'], 'payconiq'); + expect(settled.single['amountInCents'], 800); + expect(settled.single['customerName'], 'Alice'); + }); + + test('a conflicting close_tab command writes no settled event', () async { + final tab = await barTabService.createTab(customerName: 'Alice'); + await barTabService.addProductToTab(tabId: tab.id, product: _beer); + server.respondWith({ + 'commands': [ + _closeCommand(id: 'cmd-1', tabId: tab.id, expectedVersion: 1), + ], + }); + + await service.sync(); + await service.sync(); + + final types = [ + for (final event in server.syncRequests.last['events'] as List) + event['type'], + ]; + expect(types, ['line_added']); + }); + }); } diff --git a/test/tab_events_test.dart b/test/tab_events_test.dart new file mode 100644 index 0000000..732a2ae --- /dev/null +++ b/test/tab_events_test.dart @@ -0,0 +1,272 @@ +import 'dart:convert'; + +import 'package:drift/drift.dart' hide isNull, isNotNull; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:kooltap/database/app_database.dart'; +import 'package:kooltap/models/payment_method.dart'; +import 'package:kooltap/models/product.dart'; +import 'package:kooltap/models/tab_event.dart'; +import 'package:kooltap/services/bar_tab_service.dart'; + +const _cola = Product( + id: 'prod-1', + name: 'Cola', + category: 'Drinks', + stockQuantity: 100, + lowStockThreshold: 10, + priceInCents: 250, +); + +void main() { + late AppDatabase database; + late DriftBarTabService service; + + setUp(() async { + database = AppDatabase(NativeDatabase.memory()); + service = DriftBarTabService(database: database); + + await database + .into(database.products) + .insert( + ProductsCompanion.insert( + id: _cola.id, + name: _cola.name, + category: _cola.category, + stockQuantity: _cola.stockQuantity, + lowStockThreshold: _cola.lowStockThreshold, + priceInCents: _cola.priceInCents, + ), + ); + }); + + tearDown(() async { + await database.close(); + }); + + Future>> outbox() async { + final rows = + await (database.select(database.tabEvents)..orderBy([ + (row) => OrderingTerm.asc(const CustomExpression('rowid')), + ])) + .get(); + return [ + for (final row in rows) jsonDecode(row.payload) as Map, + ]; + } + + Future lineIdOf(String tabId) async { + return (await service.getTabById(tabId))!.items.single.id; + } + + Future tabWithTwoColas() async { + final tab = await service.createTab(customerName: 'Jan'); + await service.addProductToTab(tabId: tab.id, product: _cola); + await service.addProductToTab(tabId: tab.id, product: _cola); + await database.delete(database.tabEvents).go(); + return (database.select( + database.barTabs, + )..where((row) => row.id.equals(tab.id))).getSingle(); + } + + group('line events', () { + test('adding a product writes line_added with the common fields', () async { + final tab = await service.createTab(customerName: 'Jan'); + await service.addProductToTab(tabId: tab.id, product: _cola); + + final event = (await outbox()).single; + expect(event['type'], 'line_added'); + expect(event['tabId'], tab.id); + expect(event['customerName'], 'Jan'); + expect(event['productName'], 'Cola'); + expect(event['unitPriceInCents'], 250); + expect(event['quantity'], 1); + expect(event['id'], matches(RegExp(r'^[0-9a-f-]{36}$'))); + expect(event['occurredAt'], endsWith('Z')); + + final row = await database.select(database.tabEvents).getSingle(); + expect(row.id, event['id']); + }); + + test('adding to an existing line keeps that line\'s price', () async { + final tab = await service.createTab(customerName: 'Jan'); + await service.addProductToTab(tabId: tab.id, product: _cola); + await service.addProductToTab( + tabId: tab.id, + product: const Product( + id: 'prod-1', + name: 'Cola', + category: 'Drinks', + stockQuantity: 100, + lowStockThreshold: 10, + priceInCents: 300, + ), + ); + + final events = await outbox(); + expect(events.map((event) => event['unitPriceInCents']), [250, 250]); + }); + + test('changing a quantity writes the difference', () async { + final tab = await service.createTab(customerName: 'Jan'); + await service.addProductToTab(tabId: tab.id, product: _cola); + await service.addProductToTab(tabId: tab.id, product: _cola); + final lineId = await lineIdOf(tab.id); + + await service.adjustTabItemQuantity(tabItemId: lineId, delta: 3); + await service.adjustTabItemQuantity(tabItemId: lineId, delta: -4); + + final events = (await outbox()).skip(2).toList(); + expect(events[0]['type'], 'line_added'); + expect(events[0]['quantity'], 3); + expect(events[1]['type'], 'line_removed'); + expect(events[1]['quantity'], 4); + expect(events[1]['unitPriceInCents'], 250); + }); + + test('removing a line writes line_removed for what was left', () async { + final tab = await tabWithTwoColas(); + final lineId = await lineIdOf(tab.id); + + // More than is on the tab: only the 2 that were there are removed + await service.adjustTabItemQuantity(tabItemId: lineId, delta: -5); + + final event = (await outbox()).single; + expect(event['type'], 'line_removed'); + expect(event['quantity'], 2); + }); + + test('a change that does nothing writes no event', () async { + final tab = await service.createTab(customerName: 'Jan'); + await service.addProductToTab(tabId: tab.id, product: _cola); + final lineId = await lineIdOf(tab.id); + await service.adjustTabItemQuantity(tabItemId: lineId, delta: -1); + await database.delete(database.tabEvents).go(); + + await service.adjustTabItemQuantity(tabItemId: lineId, delta: -1); + await service.adjustTabItemQuantity(tabItemId: lineId, delta: 0); + + expect(await outbox(), isEmpty); + }); + + test('deleting a tab writes line_removed for each line', () async { + final tab = await tabWithTwoColas(); + + await service.deleteTab(tab.id); + + final event = (await outbox()).single; + expect(event['type'], 'line_removed'); + expect(event['quantity'], 2); + expect(event['customerName'], 'Jan'); + }); + + test('later events use the new name after a rename', () async { + final tab = await service.createTab(customerName: 'Jan'); + await service.renameTab(tabId: tab.id, customerName: 'Janneke'); + expect(await outbox(), isEmpty); + + await service.addProductToTab(tabId: tab.id, product: _cola); + + expect((await outbox()).single['customerName'], 'Janneke'); + }); + + test('a failed change writes no event', () async { + await expectLater( + service.addProductToTab(tabId: 'missing', product: _cola), + throwsA(isA()), + ); + + expect(await outbox(), isEmpty); + }); + }); + + group('settled events', () { + test('settling writes one settled event and no removals', () async { + final tab = await tabWithTwoColas(); + + await service.closeTab(tab.id, paymentMethod: PaymentMethod.payconiq); + + final event = (await outbox()).single; + expect(event['type'], 'settled'); + expect(event['customerName'], 'Jan'); + expect(event['amountInCents'], 500); + expect(event['paymentMethod'], 'payconiq'); + expect(event.containsKey('commandId'), false); + expect(event['items'], [ + {'productName': 'Cola', 'quantity': 2, 'unitPriceInCents': 250}, + ]); + }); + + test('lines that were taken back are not settled', () async { + final tab = await tabWithTwoColas(); + final lineId = await lineIdOf(tab.id); + await service.adjustTabItemQuantity(tabItemId: lineId, delta: -1); + + await service.closeTab(tab.id); + + final settled = (await outbox()).last; + expect(settled['amountInCents'], 250); + expect(settled['paymentMethod'], 'cash'); + expect((settled['items'] as List).single['quantity'], 1); + }); + + test('settling an empty tab writes nothing', () async { + final tab = await service.createTab(customerName: 'Jan'); + + await service.closeTab(tab.id); + + expect(await outbox(), isEmpty); + }); + + test('a version conflict writes nothing', () async { + final tab = await tabWithTwoColas(); + + await expectLater( + service.closeTab(tab.id, expectedVersion: tab.version - 1), + throwsA(isA()), + ); + + expect(await outbox(), isEmpty); + }); + + test('carries the command id when given', () async { + final tab = await tabWithTwoColas(); + + await service.closeTab( + tab.id, + paymentMethod: PaymentMethod.paymentPending, + commandId: 'cmd-1', + ); + + final event = (await outbox()).single; + expect(event['commandId'], 'cmd-1'); + expect(event['paymentMethod'], 'payment_pending'); + }); + }); + + test('occurredAt is the UTC time it happened', () { + final factory = TabEventFactory( + now: () => DateTime.utc(2026, 10, 24, 21, 41, 3, 120), + ); + + final event = factory.lineAdded( + tabId: 'a1b2', + customerName: 'Jan', + productName: 'Cola', + unitPriceInCents: 250, + quantity: 2, + ); + + expect(event.payload['occurredAt'], '2026-10-24T21:41:03.120Z'); + expect( + () => factory.lineRemoved( + tabId: 'a1b2', + customerName: 'Jan', + productName: 'Cola', + unitPriceInCents: 250, + quantity: 0, + ), + throwsArgumentError, + ); + }); +}