import 'dart:convert'; import 'dart:math'; import 'package:drift/drift.dart'; import 'package:flutter/foundation.dart' show listEquals; import 'package:uuid/uuid.dart'; import '../database/app_database.dart'; import '../database/stock_queries.dart'; import '../models/bar_tab.dart'; import '../models/closed_tab.dart'; import '../models/closed_tab_item.dart'; import '../models/payment_method.dart'; import '../models/product.dart'; import '../models/server_tab.dart'; import '../models/tab_event.dart'; import '../models/tab_item.dart'; import '../models/tab_item_purchase.dart'; class TabNotFoundException implements Exception { final String tabId; const TabNotFoundException(this.tabId); @override String toString() => 'Tab not found: $tabId'; } class TabVersionConflictException implements Exception { final int expectedVersion; final int actualVersion; const TabVersionConflictException({ required this.expectedVersion, required this.actualVersion, }); @override String toString() => 'Tab is at version $actualVersion, expected $expectedVersion'; } abstract class BarTabService { /// Open tabs with their visible items, re-emitted on every change. Stream> watchOpenTabs(); /// With [includeRemoved], items whose purchases were all undone are kept /// so history can show them. Future> getOpenTabs({bool includeRemoved = false}); Future getTabById(String id); Future createTab({required String customerName}); Future renameTab({required String tabId, required String customerName}); /// Deletes an open tab and returns its items to inventory. Future deleteTab(String tabId); Future addProductToTab({ required String tabId, required Product product, }); /// Returns the delta that was actually applied, which is smaller than /// [delta] when it would push the quantity below zero. Future adjustTabItemQuantity({ required String tabItemId, required int delta, }); /// Archives the tab's current items into history and clears them. /// The tab itself stays open under the same customer name. /// /// Throws [TabVersionConflictException] when [expectedVersion] is given and /// 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(); Future> getClosedTabsPaginated({ int limit = 20, int offset = 0, String? customerName, }); Future getClosedTabCount({String? customerName}); Future> getDistinctCustomerNames(); /// Replaces the open tabs with the server's [tabs], the venue's source of /// truth, and replays the changes the server hasn't applied yet (the /// events still in the outbox) on top. Stock is left alone: the local /// changes already counted it. Future applyServerState(List tabs); /// Adds settlements made anywhere in the venue to the history. Settlements /// already in it (such as this tablet's own) are skipped. Future recordSettlements(List settlements); } class DriftBarTabService implements BarTabService { final AppDatabase database; final TabEventFactory eventFactory; final _uuid = const Uuid(); DriftBarTabService({required this.database, TabEventFactory? eventFactory}) : eventFactory = eventFactory ?? TabEventFactory(); TabItemPurchase _mapPurchaseRow(TabItemPurchaseRow row) { return TabItemPurchase( id: row.id, tabItemId: row.tabItemId, purchasedAt: row.purchasedAt, removedAt: row.removedAt, ); } TabItem _mapItemRow(TabItemRow row, List purchases) { return TabItem( id: row.id, tabId: row.tabId, productId: row.productId, productName: row.productName, quantity: row.quantity, unitPriceInCents: row.unitPriceInCents, purchases: purchases, ); } BarTab _mapTabRow(BarTabRow row, List items) { return BarTab( id: row.id, customerName: row.customerName, openedAt: row.openedAt, version: row.version, items: items, ); } ClosedTabItem _mapClosedItemRow(ClosedTabItemRow row) { return ClosedTabItem( id: row.id, closedTabId: row.closedTabId, productId: row.productId, productName: row.productName, quantity: row.quantity, unitPriceInCents: row.unitPriceInCents, purchasedAt: row.purchasedAt, removedAt: row.removedAt, ); } /// Tabs, items and purchases in a single query, so loading N tabs doesn't /// cost N * items extra round trips. JoinedSelectStatement _tabsQuery({String? tabId}) { final query = database.select(database.barTabs).join([ leftOuterJoin( database.tabItems, database.tabItems.tabId.equalsExp(database.barTabs.id), ), leftOuterJoin( database.tabItemPurchases, database.tabItemPurchases.tabItemId.equalsExp(database.tabItems.id), ), ]); if (tabId != null) query.where(database.barTabs.id.equals(tabId)); query.orderBy([ OrderingTerm.desc(database.barTabs.openedAt), OrderingTerm.asc(database.tabItems.createdAt), OrderingTerm.asc(database.tabItemPurchases.purchasedAt), OrderingTerm.asc(database.tabItemPurchases.id), ]); return query; } List _groupTabs( List rows, { required bool includeRemoved, }) { final tabRows = {}; final itemRowsByTab = >{}; final purchasesByItem = >{}; for (final row in rows) { final tab = row.readTable(database.barTabs); tabRows[tab.id] = tab; final itemRows = itemRowsByTab.putIfAbsent(tab.id, () => {}); final item = row.readTableOrNull(database.tabItems); if (item == null) continue; itemRows[item.id] = item; final purchases = purchasesByItem.putIfAbsent(item.id, () => []); final purchase = row.readTableOrNull(database.tabItemPurchases); if (purchase != null) purchases.add(_mapPurchaseRow(purchase)); } return [ for (final tab in tabRows.values) _mapTabRow(tab, [ for (final item in itemRowsByTab[tab.id]!.values) if (includeRemoved ? purchasesByItem[item.id]!.isNotEmpty : item.quantity > 0) _mapItemRow(item, purchasesByItem[item.id]!), ]), ]; } Future _getTabRow(String tabId) { return (database.select( database.barTabs, )..where((tab) => tab.id.equals(tabId))).getSingleOrNull(); } Future _recordPurchase(String tabItemId) async { await database .into(database.tabItemPurchases) .insert( TabItemPurchasesCompanion.insert( id: _uuid.v4(), tabItemId: tabItemId, purchasedAt: DateTime.now(), ), ); } /// Marks the most recent active purchases as removed, keeping them in the /// audit trail. Future _removeLatestPurchases( String tabItemId, int count, { DateTime? removedAt, }) async { final latest = await (database.select(database.tabItemPurchases) ..where( (purchase) => purchase.tabItemId.equals(tabItemId) & purchase.removedAt.isNull(), ) ..orderBy([ (purchase) => OrderingTerm.desc(purchase.purchasedAt), (purchase) => OrderingTerm.desc(purchase.id), ]) ..limit(count)) .get(); await (database.update(database.tabItemPurchases) ..where((purchase) => purchase.id.isIn(latest.map((row) => row.id)))) .write( TabItemPurchasesCompanion( removedAt: Value(removedAt ?? DateTime.now()), ), ); } Future _deleteTabWithItems(String tabId) async { await _deleteItemsOfTab(tabId); await (database.delete( database.barTabs, )..where((row) => row.id.equals(tabId))).go(); } /// Foreign keys aren't enforced, so purchases are deleted explicitly. Future _deleteItemsOfTab(String tabId) async { final itemIds = database.selectOnly(database.tabItems) ..addColumns([database.tabItems.id]) ..where(database.tabItems.tabId.equals(tabId)); await (database.delete( database.tabItemPurchases, )..where((purchase) => purchase.tabItemId.isInQuery(itemIds))).go(); await (database.delete( database.tabItems, )..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(), ), ); } /// Queues a `line_added` ([delta] > 0) or `line_removed` ([delta] < 0) /// event and moves the stock of [productId], the product the line was /// created from, the other way. Every line event goes through here, so a /// line event and its stock change can never drift apart. Future _recordLineChange({ required String tabId, required String customerName, required String lineId, required String productId, required String productName, required int unitPriceInCents, required int delta, }) async { final lineEvent = delta > 0 ? eventFactory.lineAdded : eventFactory.lineRemoved; await _recordEvent( lineEvent( tabId: tabId, customerName: customerName, lineId: lineId, productId: productId, productName: productName, unitPriceInCents: unitPriceInCents, quantity: delta.abs(), ), ); await database.changeTrackedStock(productId, -delta); } Future _incrementTabVersion(String tabId) async { await (database.update( database.barTabs, )..where((tab) => tab.id.equals(tabId))).write( BarTabsCompanion.custom( version: database.barTabs.version + const Constant(1), ), ); } String _validateCustomerName(String customerName) { final name = customerName.trim(); if (name.isEmpty) { throw ArgumentError.value( customerName, 'customerName', 'Must not be empty', ); } return name; } @override Stream> watchOpenTabs() { return _tabsQuery() .watch() .map((rows) => _groupTabs(rows, includeRemoved: false)) // A sync rewrites the tabs even when nothing changed .distinct(listEquals); } @override Future> getOpenTabs({bool includeRemoved = false}) async { final rows = await _tabsQuery().get(); return _groupTabs(rows, includeRemoved: includeRemoved); } @override Future getTabById(String id) async { final rows = await _tabsQuery(tabId: id).get(); return _groupTabs(rows, includeRemoved: false).firstOrNull; } @override Future createTab({required String customerName}) async { final id = _uuid.v4(); final name = _validateCustomerName(customerName); final openedAt = DateTime.now(); await database.transaction(() async { await database .into(database.barTabs) .insert( BarTabsCompanion.insert( id: id, customerName: name, openedAt: openedAt, version: const Value(1), ), ); await _recordEvent( eventFactory.tabOpened( tabId: id, customerName: name, openedAt: openedAt, ), ); }); return (await getTabById(id))!; } @override Future renameTab({ required String tabId, required String customerName, }) async { final name = _validateCustomerName(customerName); await database.transaction(() async { final updatedRows = await (database.update(database.barTabs) ..where((tab) => tab.id.equals(tabId))) .write(BarTabsCompanion(customerName: Value(name))); if (updatedRows != 1) throw TabNotFoundException(tabId); await _recordEvent( eventFactory.tabRenamed(tabId: tabId, customerName: name), ); await _incrementTabVersion(tabId); }); } @override Future deleteTab(String tabId) async { await database.transaction(() async { final tab = await getTabById(tabId); if (tab == null) return; for (final item in tab.items) { await _recordLineChange( tabId: tab.id, customerName: tab.customerName, lineId: item.id, productId: item.productId, productName: item.productName, unitPriceInCents: item.unitPriceInCents, delta: -item.quantity, ); } await _recordEvent( eventFactory.tabDeleted(tabId: tab.id, customerName: tab.customerName), ); await _deleteTabWithItems(tabId); }); } @override Future addProductToTab({ required String tabId, required Product product, }) async { await database.transaction(() async { final tabRow = await _getTabRow(tabId); if (tabRow == null) throw TabNotFoundException(tabId); // A merge on the server can leave two lines of one product at // different prices; new drinks go on the oldest final existingItem = await (database.select(database.tabItems) ..where( (item) => item.tabId.equals(tabId) & item.productId.equals(product.id), ) ..orderBy([(item) => OrderingTerm.asc(item.createdAt)]) ..limit(1)) .getSingleOrNull(); if (existingItem != null) { await (database.update( database.tabItems, )..where((item) => item.id.equals(existingItem.id))).write( TabItemsCompanion(quantity: Value(existingItem.quantity + 1)), ); await _recordPurchase(existingItem.id); // The line keeps the price it was first added at await _recordLineChange( tabId: tabId, customerName: tabRow.customerName, lineId: existingItem.id, productId: existingItem.productId, productName: existingItem.productName, unitPriceInCents: existingItem.unitPriceInCents, delta: 1, ); } else { final tabItemId = _uuid.v4(); await database .into(database.tabItems) .insert( TabItemsCompanion.insert( id: tabItemId, tabId: tabId, productId: product.id, productName: product.name, quantity: 1, unitPriceInCents: product.priceInCents, createdAt: DateTime.now(), ), ); await _recordPurchase(tabItemId); await _recordLineChange( tabId: tabId, customerName: tabRow.customerName, lineId: tabItemId, productId: product.id, productName: product.name, unitPriceInCents: product.priceInCents, delta: 1, ); } await _incrementTabVersion(tabId); }); } @override Future adjustTabItemQuantity({ required String tabItemId, required int delta, }) async { if (delta == 0) return 0; return database.transaction(() async { final item = await (database.select( database.tabItems, )..where((row) => row.id.equals(tabItemId))).getSingleOrNull(); if (item == null) return 0; final newQuantity = max(item.quantity + delta, 0); final actualDelta = newQuantity - item.quantity; if (actualDelta == 0) return 0; if (actualDelta > 0) { for (var index = 0; index < actualDelta; index++) { await _recordPurchase(item.id); } } else { await _removeLatestPurchases(item.id, -actualDelta); } await (database.update(database.tabItems) ..where((row) => row.id.equals(tabItemId))) .write(TabItemsCompanion(quantity: Value(newQuantity))); final tabRow = await _getTabRow(item.tabId); await _recordLineChange( tabId: item.tabId, customerName: tabRow?.customerName ?? '', lineId: item.id, productId: item.productId, productName: item.productName, unitPriceInCents: item.unitPriceInCents, delta: actualDelta, ); await _incrementTabVersion(item.tabId); return actualDelta; }); } @override Future closeTab( String tabId, { PaymentMethod paymentMethod = PaymentMethod.cash, int? expectedVersion, String? commandId, }) async { await database.transaction(() async { final tabRow = await _getTabRow(tabId); if (tabRow == null) throw TabNotFoundException(tabId); if (expectedVersion != null && tabRow.version != expectedVersion) { throw TabVersionConflictException( expectedVersion: expectedVersion, actualVersion: tabRow.version, ); } final rows = await _tabsQuery(tabId: tabId).get(); final items = _groupTabs(rows, includeRemoved: true).first.items; if (items.isEmpty) return; // One settlement, not a removal per line: these drinks were sold final settledItems = [ for (final item in items) if (item.quantity > 0) item, ]; final settledEvent = settledItems.isEmpty ? null : 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) ( productId: item.productId, productName: item.productName, quantity: item.quantity, unitPriceInCents: item.unitPriceInCents, ), ], ); // The server reports this settlement back under the event's id, so it // isn't added to history twice final closedTabId = settledEvent?.id ?? _uuid.v4(); await database .into(database.closedTabs) .insert( ClosedTabsCompanion.insert( id: closedTabId, originalTabId: tabId, customerName: tabRow.customerName, closedAt: DateTime.now(), paymentMethod: Value(paymentMethod), ), ); await database.batch((batch) { batch.insertAll(database.closedTabItems, [ for (final item in items) for (final purchase in item.purchases) ClosedTabItemsCompanion.insert( id: _uuid.v4(), closedTabId: closedTabId, productId: item.productId, productName: item.productName, quantity: 1, unitPriceInCents: item.unitPriceInCents, purchasedAt: Value(purchase.purchasedAt), removedAt: Value(purchase.removedAt), ), ]); }); if (settledEvent != null) await _recordEvent(settledEvent); await _deleteItemsOfTab(tabId); await _incrementTabVersion(tabId); }); } /// Loads the items for every row in one query instead of one per tab. Future> _withItems(List tabRows) async { if (tabRows.isEmpty) return []; final itemRows = await (database.select(database.closedTabItems) ..where( (item) => item.closedTabId.isIn(tabRows.map((row) => row.id)), ) ..orderBy([ (item) => OrderingTerm.desc(item.purchasedAt), (item) => OrderingTerm.desc(item.id), ])) .get(); final itemsByTab = >{}; for (final row in itemRows) { itemsByTab .putIfAbsent(row.closedTabId, () => []) .add(_mapClosedItemRow(row)); } return [ for (final row in tabRows) ClosedTab( id: row.id, originalTabId: row.originalTabId, customerName: row.customerName, closedAt: row.closedAt, paymentMethod: row.paymentMethod, items: itemsByTab[row.id] ?? [], ), ]; } SimpleSelectStatement<$ClosedTabsTable, ClosedTabsRow> _closedTabsQuery({ String? customerName, }) { final query = database.select(database.closedTabs) ..orderBy([(tab) => OrderingTerm.desc(tab.closedAt)]); if (customerName != null && customerName.isNotEmpty) { query.where((tab) => tab.customerName.equals(customerName)); } return query; } @override Future> getClosedTabs() async { return _withItems(await _closedTabsQuery().get()); } @override Future> getClosedTabsPaginated({ int limit = 20, int offset = 0, String? customerName, }) async { final query = _closedTabsQuery(customerName: customerName) ..limit(limit, offset: offset); return _withItems(await query.get()); } @override Future getClosedTabCount({String? customerName}) async { final countExpr = database.closedTabs.id.count(); final query = database.selectOnly(database.closedTabs) ..addColumns([countExpr]); if (customerName != null && customerName.isNotEmpty) { query.where(database.closedTabs.customerName.equals(customerName)); } final row = await query.getSingle(); return row.read(countExpr) ?? 0; } @override Future> getDistinctCustomerNames() async { final customerName = database.closedTabs.customerName; final query = database.selectOnly(database.closedTabs, distinct: true) ..addColumns([customerName]) ..orderBy([OrderingTerm.asc(customerName)]); final rows = await query.get(); return rows.map((row) => row.read(customerName)!).toList(); } @override Future applyServerState(List tabs) async { await database.transaction(() async { for (final tab in await database.select(database.barTabs).get()) { await _deleteTabWithItems(tab.id); } for (final tab in tabs) { await _insertServerTab(tab); } // Read here, so changes made while the sync was in flight are included final outbox = await (database.select(database.tabEvents)..orderBy([ (row) => OrderingTerm.asc(row.createdAt), (row) => OrderingTerm.asc(const CustomExpression('rowid')), ])) .get(); for (final row in outbox) { await _replayEvent(jsonDecode(row.payload) as Map); } }); } Future _insertServerTab(ServerTab tab) async { await database .into(database.barTabs) .insert( BarTabsCompanion.insert( id: tab.id, customerName: tab.customerName, openedAt: tab.openedAt, version: Value(tab.version), ), ); for (final line in tab.lines) { final createdAt = line.units.isEmpty ? tab.openedAt : line.units .map((unit) => unit.addedAt) .reduce((a, b) => a.isBefore(b) ? a : b); await database .into(database.tabItems) .insert( TabItemsCompanion.insert( id: line.id, tabId: tab.id, productId: line.productId, productName: line.productName, quantity: line.quantity, unitPriceInCents: line.unitPriceInCents, createdAt: createdAt, ), ); await database.batch((batch) { batch.insertAll(database.tabItemPurchases, [ for (final unit in line.units) TabItemPurchasesCompanion.insert( id: unit.id, tabItemId: line.id, purchasedAt: unit.addedAt, removedAt: Value(unit.removedAt), ), ]); }); } } /// Applies an outbox event to the local tables the way the server will: /// without writing events and without touching stock. Unknown types and /// events about tabs that are gone are skipped. Future _replayEvent(Map event) async { final tabId = event['tabId'] as String?; if (tabId == null) return; final customerName = event['customerName'] as String? ?? ''; final occurredAt = DateTime.tryParse(event['occurredAt'] as String? ?? '')?.toLocal() ?? DateTime.now(); switch (TabEventType.fromValue(event['type'] as String? ?? '')) { case TabEventType.tabOpened: final openedAt = DateTime.tryParse( event['openedAt'] as String? ?? '', )?.toLocal(); await _ensureTab(tabId, customerName, openedAt ?? occurredAt); case TabEventType.tabRenamed: await (database.update(database.barTabs) ..where((tab) => tab.id.equals(tabId))) .write(BarTabsCompanion(customerName: Value(customerName))); case TabEventType.tabDeleted: await _deleteTabWithItems(tabId); case TabEventType.lineAdded: await _replayLineAdded(event, tabId, customerName, occurredAt); case TabEventType.lineRemoved: final line = await _findLine(tabId, event); final quantity = event['quantity'] as int? ?? 0; if (line == null || quantity <= 0) return; final removed = min(quantity, line.quantity); await _setLineQuantity(line.id, line.quantity - removed); await _removeLatestPurchases(line.id, removed, removedAt: occurredAt); case TabEventType.settled: for (final item in event['items'] as List? ?? []) { await _replaySettledLine(tabId, item as Map); } // Stock is rebased separately, see StockQueries.rebaseStock case TabEventType.stockSet: case null: return; } } /// A drink put on a tab that was deleted elsewhere brings the tab back, so /// no sale is lost. Future _replayLineAdded( Map event, String tabId, String customerName, DateTime occurredAt, ) async { final quantity = event['quantity'] as int? ?? 0; if (quantity <= 0) return; await _ensureTab(tabId, customerName, occurredAt); var line = await _findLine(tabId, event); if (line == null) { final lineId = event['lineId'] as String?; final idTaken = lineId == null || await (database.select( database.tabItems, )..where((item) => item.id.equals(lineId))).getSingleOrNull() != null; line = TabItemRow( id: idTaken ? _uuid.v4() : lineId, tabId: tabId, productId: event['productId'] as String? ?? '', productName: event['productName'] as String? ?? '', quantity: 0, unitPriceInCents: event['unitPriceInCents'] as int? ?? 0, createdAt: occurredAt, ); await database.into(database.tabItems).insert(line); } await _setLineQuantity(line.id, line.quantity + quantity); await database.batch((batch) { batch.insertAll(database.tabItemPurchases, [ for (var index = 0; index < quantity; index++) TabItemPurchasesCompanion.insert( // The same id the server gives this unit id: '${event['id']}:$index', tabItemId: line!.id, purchasedAt: occurredAt, ), ]); }); } /// Takes the settled pieces off the line, oldest first: drinks added /// elsewhere after the settlement stay on the tab. Future _replaySettledLine( String tabId, Map item, ) async { final line = await _findLine(tabId, item); final quantity = item['quantity'] as int? ?? 0; if (line == null || quantity <= 0) return; final settled = min(quantity, line.quantity); final oldest = await (database.select(database.tabItemPurchases) ..where( (purchase) => purchase.tabItemId.equals(line.id) & purchase.removedAt.isNull(), ) ..orderBy([ (purchase) => OrderingTerm.asc(purchase.purchasedAt), (purchase) => OrderingTerm.asc(purchase.id), ]) ..limit(settled)) .get(); // Undone drinks went into the history with the settlement await (database.delete(database.tabItemPurchases)..where( (purchase) => purchase.id.isIn(oldest.map((row) => row.id)) | (purchase.tabItemId.equals(line.id) & purchase.removedAt.isNotNull()), )) .go(); if (line.quantity == settled) { await (database.delete( database.tabItems, )..where((row) => row.id.equals(line.id))).go(); } else { await _setLineQuantity(line.id, line.quantity - settled); } } Future _ensureTab( String tabId, String customerName, DateTime openedAt, ) async { await database .into(database.barTabs) .insert( BarTabsCompanion.insert( id: tabId, customerName: customerName, openedAt: openedAt, version: const Value(1), ), mode: InsertMode.insertOrIgnore, ); } /// The line [event] is about: the same line id, else the same product at /// the same price, else the same name at the same price. Future _findLine( String tabId, Map event, ) async { final lineId = event['lineId'] as String?; final productId = event['productId'] as String?; final productName = event['productName'] as String?; final price = event['unitPriceInCents'] as int?; Future first(Expression Function($TabItemsTable) where) { return (database.select(database.tabItems) ..where((item) => item.tabId.equals(tabId) & where(item)) ..orderBy([(item) => OrderingTerm.asc(item.createdAt)]) ..limit(1)) .getSingleOrNull(); } if (lineId != null) { final line = await first((item) => item.id.equals(lineId)); if (line != null) return line; } if (price == null) return null; if (productId != null && productId.isNotEmpty) { final line = await first( (item) => item.productId.equals(productId) & item.unitPriceInCents.equals(price), ); if (line != null) return line; } if (productName == null) return null; return first( (item) => item.productName.equals(productName) & item.unitPriceInCents.equals(price), ); } Future _setLineQuantity(String lineId, int quantity) async { await (database.update(database.tabItems) ..where((row) => row.id.equals(lineId))) .write(TabItemsCompanion(quantity: Value(quantity))); } @override Future recordSettlements(List settlements) async { if (settlements.isEmpty) return; await database.transaction(() async { for (final settlement in settlements) { final known = await (database.select( database.closedTabs, )..where((row) => row.id.equals(settlement.id))).getSingleOrNull(); // Already in the history, e.g. settled on this tablet if (known != null) continue; await database .into(database.closedTabs) .insert( ClosedTabsCompanion.insert( id: settlement.id, originalTabId: settlement.tabId, customerName: settlement.customerName, closedAt: settlement.settledAt, paymentMethod: Value(settlement.paymentMethod), ), ); await database.batch((batch) { batch.insertAll(database.closedTabItems, [ for (final line in settlement.lines) for (final unit in line.units) ClosedTabItemsCompanion.insert( id: _uuid.v4(), closedTabId: settlement.id, productId: line.productId, productName: line.productName, quantity: 1, unitPriceInCents: line.unitPriceInCents, purchasedAt: Value(unit.addedAt), removedAt: Value(unit.removedAt), ), ]); }); } }); } }