diff --git a/apps/api/prisma/migrations/20261020120000_tab_events/migration.sql b/apps/api/prisma/migrations/20261020120000_tab_events/migration.sql new file mode 100644 index 0000000..667c5fd --- /dev/null +++ b/apps/api/prisma/migrations/20261020120000_tab_events/migration.sql @@ -0,0 +1,33 @@ +-- CreateTable +CREATE TABLE "TabEvent" ( + "id" TEXT NOT NULL, + "venueId" TEXT NOT NULL, + "deviceId" TEXT NOT NULL, + "tabId" TEXT NOT NULL, + "customerName" TEXT NOT NULL, + "type" TEXT NOT NULL, + "productName" TEXT, + "unitPriceInCents" INTEGER, + "quantityDelta" INTEGER, + "amountInCents" INTEGER, + "paymentMethod" TEXT, + "commandId" TEXT, + "items" JSONB, + "occurredAt" TIMESTAMP(3) NOT NULL, + "receivedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "TabEvent_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "TabEvent_venueId_occurredAt_idx" ON "TabEvent"("venueId", "occurredAt"); + +-- CreateIndex +CREATE INDEX "TabEvent_venueId_type_occurredAt_idx" ON "TabEvent"("venueId", "type", "occurredAt"); + +-- AddForeignKey +ALTER TABLE "TabEvent" ADD CONSTRAINT "TabEvent_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TabEvent" ADD CONSTRAINT "TabEvent_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 1e56944..224c433 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -21,6 +21,7 @@ model Venue { regCodes RegistrationCode[] plannedTabs PlannedTab[] tabReminders TabReminder[] + tabEvents TabEvent[] } model Device { @@ -38,6 +39,7 @@ model Device { tabSnapshots OpenTabSnapshot[] tabOwnerships TabOwnership[] remoteCommands RemoteCommand[] + tabEvents TabEvent[] @@index([venueId, revokedAt]) } @@ -183,3 +185,32 @@ model TabReminder { @@index([venueId, sentAt]) @@index([venueId, tabId]) } + +// What happened on a tab, as reported by the terminal: lines added or removed, and settlements. +// The id is generated by the terminal so a re-sent event is stored only once. +model TabEvent { + id String @id + venueId String + deviceId String + tabId String + customerName String + // line_added | line_removed | settled + type String + productName String? + unitPriceInCents Int? + // Signed: positive for line_added, negative for line_removed + quantityDelta Int? + // settled only + amountInCents Int? + paymentMethod String? + commandId String? + items Json? + occurredAt DateTime + receivedAt DateTime @default(now()) + + venue Venue @relation(fields: [venueId], references: [id]) + device Device @relation(fields: [deviceId], references: [id]) + + @@index([venueId, occurredAt]) + @@index([venueId, type, occurredAt]) +} diff --git a/apps/api/prisma/setup.js b/apps/api/prisma/setup.js index a5734c8..1962dad 100644 --- a/apps/api/prisma/setup.js +++ b/apps/api/prisma/setup.js @@ -31,6 +31,7 @@ async function main() { await prisma.$transaction(async (tx) => { await tx.auditLog.deleteMany(); + await tx.tabEvent.deleteMany(); await tx.tabReminder.deleteMany(); await tx.plannedTab.deleteMany(); await tx.remoteCommand.deleteMany(); diff --git a/apps/api/src/devices/devices.service.spec.ts b/apps/api/src/devices/devices.service.spec.ts index ceeeb67..3a8810c 100644 --- a/apps/api/src/devices/devices.service.spec.ts +++ b/apps/api/src/devices/devices.service.spec.ts @@ -9,6 +9,7 @@ function createService({ previous = [] as Snapshot[], delivered = 0, expired = 0 openTabSnapshot: { deleteMany: jest.fn(), upsert: jest.fn(), updateMany: jest.fn() }, plannedTab: { updateMany: jest.fn().mockResolvedValue({ count: delivered }) }, device: { update: jest.fn() }, + tabEvent: { createMany: jest.fn() }, }; const prisma = { openTabSnapshot: { findMany: jest.fn().mockResolvedValue(previous) }, @@ -154,3 +155,49 @@ describe('DevicesService.sync writes', () => { expect(tx.openTabSnapshot.updateMany).not.toHaveBeenCalled(); }); }); + +describe('DevicesService.sync tab events', () => { + const event = { + id: '6f1c2c0e-6a2b-4c1e-9a4c-1f2a3b4c5d6e', + type: 'line_added', + tabId: 't1', + customerName: 'Jan', + occurredAt: new Date().toISOString(), + productName: 'Cola', + unitPriceInCents: 250, + quantity: 2, + }; + + it('stores events once and acknowledges them', async () => { + const { service, tx } = createService(); + + const result = await service.sync(device, { deviceId: 'd1', openTabs: [], events: [event] }); + + expect(tx.tabEvent.createMany).toHaveBeenCalledWith({ + data: [expect.objectContaining({ id: event.id, venueId: 'v1', deviceId: 'd1', quantityDelta: 2 })], + skipDuplicates: true, + }); + expect(result.acknowledgedEventIds).toEqual([event.id]); + expect(result.rejectedEvents).toEqual([]); + }); + + it('still syncs when an event is invalid, and reports it', async () => { + const { service, tx } = createService(); + + const result = await service.sync(device, { deviceId: 'd1', openTabs: [], events: [{ ...event, quantity: -1 }] }); + + expect(tx.tabEvent.createMany).not.toHaveBeenCalled(); + expect(tx.device.update).toHaveBeenCalled(); + expect(result.acknowledgedEventIds).toEqual([]); + expect(result.rejectedEvents).toEqual([{ id: event.id, reason: expect.stringMatching(/quantity/) }]); + }); + + it('works for terminals that send no events', async () => { + const { service, tx } = createService(); + + const result = await service.sync(device, { deviceId: 'd1', openTabs: [] }); + + expect(tx.tabEvent.createMany).not.toHaveBeenCalled(); + expect(result.acknowledgedEventIds).toEqual([]); + }); +}); diff --git a/apps/api/src/devices/devices.service.ts b/apps/api/src/devices/devices.service.ts index ead85de..c3a0e69 100644 --- a/apps/api/src/devices/devices.service.ts +++ b/apps/api/src/devices/devices.service.ts @@ -1,5 +1,6 @@ import { Injectable, + Logger, NotFoundException, ConflictException, ForbiddenException, @@ -13,9 +14,12 @@ import { Prisma } from '@prisma/client'; import { OpenTabDto, OpenTabItemDto, SyncDto } from './dto/sync.dto'; import { customerNameKey } from '../planned-tabs/customer-name'; import { AckCommandDto } from './dto/ack-command.dto'; +import { parseTabEvents, tabEventRow } from './tab-events'; @Injectable() export class DevicesService { + private readonly logger = new Logger(DevicesService.name); + constructor( private prisma: PrismaService, private auditService: AuditService, @@ -31,6 +35,10 @@ export class DevicesService { } const now = new Date(); + const events = parseTabEvents(dto.events ?? []); + for (const { id, reason } of events.rejected) { + this.logger.warn(`Device ${device.id} sent an invalid tab event ${id ?? '(no id)'}: ${reason}`); + } const openTabs = dto.openTabs || []; const openTabIds = openTabs.map((t) => t.id); @@ -140,6 +148,13 @@ export class DevicesService { })); } + if (events.accepted.length > 0) { + await tx.tabEvent.createMany({ + data: events.accepted.map((event) => tabEventRow(event, device.venueId, device.id, now)), + skipDuplicates: true, + }); + } + await tx.device.update({ where: { id: device.id }, data: { lastSeenAt: now }, @@ -208,6 +223,10 @@ export class DevicesService { createdAt: c.createdAt, })), plannedTabNames: plannedTabs.map((t) => t.customerName), + // Stored (or already stored earlier): the terminal can drop these from its outbox + acknowledgedEventIds: events.accepted.map((e) => e.id), + // Invalid and never stored: the terminal should log and drop these too, re-sending won't help + rejectedEvents: events.rejected, }; } diff --git a/apps/api/src/devices/dto/sync.dto.ts b/apps/api/src/devices/dto/sync.dto.ts index 5b475d5..b811fd9 100644 --- a/apps/api/src/devices/dto/sync.dto.ts +++ b/apps/api/src/devices/dto/sync.dto.ts @@ -70,4 +70,11 @@ export class SyncDto { @ValidateNested({ each: true }) @Type(() => OpenTabDto) openTabs: OpenTabDto[]; + + // Validated one by one in the service: a bad event is rejected on its own instead of failing the sync + @ApiPropertyOptional({ description: 'TabEventDto[]: what happened on tabs since the last acknowledged sync' }) + @IsOptional() + @IsArray() + @ArrayMaxSize(1000) + events?: unknown[]; } \ No newline at end of file diff --git a/apps/api/src/devices/dto/tab-event.dto.ts b/apps/api/src/devices/dto/tab-event.dto.ts new file mode 100644 index 0000000..b86181d --- /dev/null +++ b/apps/api/src/devices/dto/tab-event.dto.ts @@ -0,0 +1,82 @@ +import { + IsString, IsIn, IsInt, Min, IsUUID, IsNotEmpty, MaxLength, IsISO8601, ValidateIf, IsOptional, IsArray, + ArrayMaxSize, ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { OpenTabItemDto } from './sync.dto'; + +export const TAB_EVENT_TYPES = ['line_added', 'line_removed', 'settled'] as const; +export type TabEventType = (typeof TAB_EVENT_TYPES)[number]; + +export const SETTLEMENT_PAYMENT_METHODS = ['cash', 'payconiq', 'payment_pending', 'other'] as const; + +const isLine = (event: TabEventDto) => event.type !== 'settled'; +const isSettlement = (event: TabEventDto) => event.type === 'settled'; + +export class TabEventDto { + @ApiProperty({ description: 'Generated by the terminal; re-sending the same id stores the event once' }) + @IsUUID() + id: string; + + @ApiProperty({ enum: TAB_EVENT_TYPES }) + @IsIn(TAB_EVENT_TYPES) + type: TabEventType; + + @ApiProperty({ example: 'local-tab-uuid' }) + @IsString() + @IsNotEmpty() + tabId: string; + + @ApiProperty({ example: 'Jan' }) + @IsString() + @MaxLength(200) + customerName: string; + + @ApiProperty({ example: '2026-10-24T21:41:03.120Z', description: 'When it happened on the terminal' }) + @IsISO8601({ strict: true }) + occurredAt: string; + + @ApiPropertyOptional({ example: 'Cola', description: 'line_added / line_removed' }) + @ValidateIf(isLine) + @IsString() + @IsNotEmpty() + @MaxLength(200) + productName?: string; + + @ApiPropertyOptional({ example: 250, description: 'line_added / line_removed' }) + @ValidateIf(isLine) + @IsInt() + @Min(0) + unitPriceInCents?: number; + + @ApiPropertyOptional({ example: 2, description: 'line_added / line_removed: how many were added or removed, always positive' }) + @ValidateIf(isLine) + @IsInt() + @Min(1) + quantity?: number; + + @ApiPropertyOptional({ example: 2450, description: 'settled: the amount that was settled' }) + @ValidateIf(isSettlement) + @IsInt() + @Min(0) + amountInCents?: number; + + @ApiPropertyOptional({ enum: SETTLEMENT_PAYMENT_METHODS, description: 'settled' }) + @ValidateIf(isSettlement) + @IsIn(SETTLEMENT_PAYMENT_METHODS) + paymentMethod?: string; + + @ApiPropertyOptional({ description: 'settled: the remote close command that caused it, if any' }) + @IsOptional() + @IsUUID() + commandId?: string; + + @ApiPropertyOptional({ type: [OpenTabItemDto], description: 'settled: the lines that were settled' }) + @IsOptional() + @IsArray() + @ArrayMaxSize(500) + @ValidateNested({ each: true }) + @Type(() => OpenTabItemDto) + items?: OpenTabItemDto[]; +} diff --git a/apps/api/src/devices/tab-events.spec.ts b/apps/api/src/devices/tab-events.spec.ts new file mode 100644 index 0000000..8983d6d --- /dev/null +++ b/apps/api/src/devices/tab-events.spec.ts @@ -0,0 +1,106 @@ +import { Prisma } from '@prisma/client'; +import { parseTabEvents, tabEventRow } from './tab-events'; + +const now = new Date('2026-10-24T22:00:00.000Z'); + +const lineAdded = { + id: '6f1c2c0e-6a2b-4c1e-9a4c-1f2a3b4c5d6e', + type: 'line_added', + tabId: 'tab-1', + customerName: 'Jan', + occurredAt: '2026-10-24T21:41:03.120Z', + productName: 'Cola', + unitPriceInCents: 250, + quantity: 2, +}; + +const settled = { + id: '7a2d3e4f-5b6c-4d7e-8f9a-0b1c2d3e4f5a', + type: 'settled', + tabId: 'tab-1', + customerName: 'Jan', + occurredAt: '2026-10-24T21:55:00.000Z', + amountInCents: 500, + paymentMethod: 'payconiq', + items: [{ productName: 'Cola', quantity: 2, unitPriceInCents: 250 }], +}; + +describe('parseTabEvents', () => { + it('accepts valid line and settlement events', () => { + const { accepted, rejected } = parseTabEvents([lineAdded, settled]); + + expect(accepted.map((e) => e.id)).toEqual([lineAdded.id, settled.id]); + expect(rejected).toEqual([]); + }); + + it('rejects invalid events one by one, keeping their id', () => { + const { accepted, rejected } = parseTabEvents([ + lineAdded, + { ...lineAdded, id: '8b3e4f5a-6c7d-4e8f-9a0b-1c2d3e4f5a6b', quantity: 0 }, + { ...settled, id: '9c4f5a6b-7d8e-4f9a-0b1c-2d3e4f5a6b7c', paymentMethod: 'bitcoin' }, + { ...lineAdded, id: 'not-a-uuid' }, + 'nonsense', + ]); + + expect(accepted.map((e) => e.id)).toEqual([lineAdded.id]); + expect(rejected.map((r) => r.id)).toEqual([ + '8b3e4f5a-6c7d-4e8f-9a0b-1c2d3e4f5a6b', + '9c4f5a6b-7d8e-4f9a-0b1c-2d3e4f5a6b7c', + 'not-a-uuid', + null, + ]); + expect(rejected[0].reason).toMatch(/quantity/); + }); + + it('ignores fields it does not know, so newer app versions keep working', () => { + const { accepted } = parseTabEvents([{ ...lineAdded, discountInCents: 50 }]); + + expect(accepted).toHaveLength(1); + expect(accepted[0]).not.toHaveProperty('discountInCents'); + }); + + it('rejects line events without a product', () => { + const { productName, ...withoutProduct } = lineAdded; + + expect(parseTabEvents([withoutProduct]).rejected).toHaveLength(1); + }); +}); + +describe('tabEventRow', () => { + it('stores removed lines as a negative quantity', () => { + const [event] = parseTabEvents([{ ...lineAdded, type: 'line_removed', quantity: 3 }]).accepted; + + expect(tabEventRow(event, 'v1', 'd1', now)).toMatchObject({ + venueId: 'v1', + deviceId: 'd1', + type: 'line_removed', + productName: 'Cola', + unitPriceInCents: 250, + quantityDelta: -3, + occurredAt: new Date(lineAdded.occurredAt), + }); + }); + + it('stores a settlement with its amount, payment method and lines', () => { + const [event] = parseTabEvents([settled]).accepted; + + const row = tabEventRow(event, 'v1', 'd1', now); + + expect(row).toMatchObject({ type: 'settled', amountInCents: 500, paymentMethod: 'payconiq', commandId: null }); + expect(row.items).toEqual(settled.items); + expect(row.quantityDelta).toBeUndefined(); + }); + + it('stores JSON null for a settlement without lines', () => { + const { items, ...withoutItems } = settled; + const [event] = parseTabEvents([withoutItems]).accepted; + + expect(tabEventRow(event, 'v1', 'd1', now).items).toBe(Prisma.JsonNull); + }); + + it('uses the receive time when the terminal clock runs ahead', () => { + const [event] = parseTabEvents([{ ...lineAdded, occurredAt: '2026-10-25T03:00:00.000Z' }]).accepted; + + expect(tabEventRow(event, 'v1', 'd1', now).occurredAt).toEqual(now); + }); +}); diff --git a/apps/api/src/devices/tab-events.ts b/apps/api/src/devices/tab-events.ts new file mode 100644 index 0000000..6b3c34e --- /dev/null +++ b/apps/api/src/devices/tab-events.ts @@ -0,0 +1,74 @@ +import { plainToInstance } from 'class-transformer'; +import { validateSync, ValidationError } from 'class-validator'; +import { Prisma } from '@prisma/client'; +import { TabEventDto } from './dto/tab-event.dto'; + +// A terminal clock that runs ahead must not put sales in the future +const MAX_CLOCK_AHEAD_MS = 5 * 60 * 1000; + +export type RejectedTabEvent = { id: string | null; reason: string }; + +export function parseTabEvents(raw: unknown[]): { accepted: TabEventDto[]; rejected: RejectedTabEvent[] } { + const accepted: TabEventDto[] = []; + const rejected: RejectedTabEvent[] = []; + + for (const value of raw) { + const id = typeof (value as { id?: unknown })?.id === 'string' ? (value as { id: string }).id : null; + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + rejected.push({ id, reason: 'event must be an object' }); + continue; + } + const event = plainToInstance(TabEventDto, value); + // Unknown fields are ignored so a newer app version adding one never loses sales + const errors = validateSync(event, { whitelist: true }); + if (errors.length > 0) { + rejected.push({ id, reason: describe(errors) }); + continue; + } + accepted.push(event); + } + + return { accepted, rejected }; +} + +export function tabEventRow(event: TabEventDto, venueId: string, deviceId: string, now: Date): Prisma.TabEventCreateManyInput { + const occurredAt = new Date(event.occurredAt); + const base = { + id: event.id, + venueId, + deviceId, + tabId: event.tabId, + customerName: event.customerName, + type: event.type, + occurredAt: occurredAt.getTime() > now.getTime() + MAX_CLOCK_AHEAD_MS ? now : occurredAt, + receivedAt: now, + }; + + if (event.type === 'settled') { + return { + ...base, + amountInCents: event.amountInCents, + paymentMethod: event.paymentMethod, + commandId: event.commandId ?? null, + items: event.items + ? event.items.map(({ productName, quantity, unitPriceInCents }) => ({ productName, quantity, unitPriceInCents })) + : Prisma.JsonNull, + }; + } + + return { + ...base, + productName: event.productName, + unitPriceInCents: event.unitPriceInCents, + quantityDelta: event.type === 'line_removed' ? -event.quantity! : event.quantity, + }; +} + +function describe(errors: ValidationError[]): string { + return errors + .flatMap((error) => [ + ...Object.values(error.constraints ?? {}), + ...(error.children?.length ? [`${error.property}: ${describe(error.children)}`] : []), + ]) + .join('; '); +} diff --git a/docs/api.md b/docs/api.md index 99bfeda..37900c6 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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 diff --git a/docs/flutter-sales-events-prompt.md b/docs/flutter-sales-events-prompt.md new file mode 100644 index 0000000..7247467 --- /dev/null +++ b/docs/flutter-sales-events-prompt.md @@ -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 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 acknowledgedEventIds; // json['acknowledgedEventIds'] +final List 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