feat: add feature
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-10-19 23:23:36 +02:00
parent fa38ac7617
commit 874f68b4b1
11 changed files with 577 additions and 2 deletions
@@ -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;
+31
View File
@@ -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])
}
+1
View File
@@ -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();
@@ -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([]);
});
});
+19
View File
@@ -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,
};
}
+7
View File
@@ -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[];
}
+82
View File
@@ -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[];
}
+106
View File
@@ -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);
});
});
+74
View File
@@ -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('; ');
}
+11 -2
View File
@@ -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
+166
View File
@@ -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<Column> get primaryKey => {id};
}
```
### 2. Write Events Together with the Change
In `BarTabService` (and wherever else tabs change), insert the event **in the same database transaction**
as the change itself. That way a crash can never record a sale without the event, or an event without the
sale. Put the JSON building in one small helper (e.g. `TabEventFactory` or methods on the service) instead
of building maps in many places.
### 3. Send Events in the Sync
In the sync request, add an `events` field with the **oldest 500** events from the outbox (ordered by
`createdAt`), as a list of the stored JSON objects:
```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<String> acknowledgedEventIds; // json['acknowledgedEventIds']
final List<RejectedEvent> rejectedEvents; // json['rejectedEvents']: [{ "id": String?, "reason": String }]
```
After a successful sync:
- Delete every event in `acknowledgedEventIds` from the outbox.
- Delete every event in `rejectedEvents` that has an id too, and log `id` + `reason` (debug log is fine).
Re-sending won't help: it's a bug in the app that should be fixed.
- Events created while the sync was in flight are not in either list; they stay and go with the next sync.
If the sync request fails (network, 5xx), delete nothing; the same events are sent again next time. The
server stores each event id only once, so re-sending is safe.
If the outbox has more than 500 events (e.g. after a long time offline), they drain 500 per sync; no
special handling needed.
### Important Notes
- Generate ids with the `uuid` package (`const Uuid().v4()`), already used in the app if available; add it
otherwise.
- Always send `occurredAt` in UTC with the `Z` suffix.
- Amounts are integers in cents everywhere, like `SnapshotTab.totalInCents`.
- Don't change how tabs, versions, `openTabs`, commands or planned tabs work.
- Keep the outbox in the local database, never only in memory: events must survive an app restart and a
tablet that is offline for a whole evening.
### Testing
Unit tests:
- Adding a product, changing a quantity up and down, and removing a line write the right events with the
right quantities.
- Settling writes one `settled` event and no `line_removed` events.
- A processed `close_tab` command writes a `settled` event with its `commandId` and payment method, once.
- The sync sends at most 500 events, oldest first, and deletes exactly the acknowledged and rejected ids.
- A failed sync deletes nothing.
Manual check against the server:
1. Add a few drinks to a tab, remove one, and settle it with Payconiq.
2. After the next sync the outbox is empty.
3. On the server: `SELECT type, "productName", "quantityDelta", "amountInCents", "paymentMethod" FROM "TabEvent" ORDER BY "occurredAt";`
shows the additions, the removal (negative `quantityDelta`) and the settlement.
4. Turn off wifi, add drinks, turn it back on: the events arrive with their original `occurredAt`.
### Files to Modify
- `lib/database/app_database.dart` — `TabEvents` table, schema version and migration
- `lib/services/bar_tab_service.dart` — write events with every change to a tab's lines and on settlement
- The remote `close_tab` handling — write the `settled` event with `commandId`
- `lib/models/remote_command.dart` — `events` in the sync request, `acknowledgedEventIds` and `rejectedEvents` in `SyncResponse`
- `lib/services/remote_sync_service.dart` — send outbox events, delete acknowledged and rejected ones