@@ -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;
|
||||||
|
|
||||||
@@ -21,6 +21,7 @@ model Venue {
|
|||||||
regCodes RegistrationCode[]
|
regCodes RegistrationCode[]
|
||||||
plannedTabs PlannedTab[]
|
plannedTabs PlannedTab[]
|
||||||
tabReminders TabReminder[]
|
tabReminders TabReminder[]
|
||||||
|
tabEvents TabEvent[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Device {
|
model Device {
|
||||||
@@ -38,6 +39,7 @@ model Device {
|
|||||||
tabSnapshots OpenTabSnapshot[]
|
tabSnapshots OpenTabSnapshot[]
|
||||||
tabOwnerships TabOwnership[]
|
tabOwnerships TabOwnership[]
|
||||||
remoteCommands RemoteCommand[]
|
remoteCommands RemoteCommand[]
|
||||||
|
tabEvents TabEvent[]
|
||||||
|
|
||||||
@@index([venueId, revokedAt])
|
@@index([venueId, revokedAt])
|
||||||
}
|
}
|
||||||
@@ -183,3 +185,32 @@ model TabReminder {
|
|||||||
@@index([venueId, sentAt])
|
@@index([venueId, sentAt])
|
||||||
@@index([venueId, tabId])
|
@@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])
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ async function main() {
|
|||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
await tx.auditLog.deleteMany();
|
await tx.auditLog.deleteMany();
|
||||||
|
await tx.tabEvent.deleteMany();
|
||||||
await tx.tabReminder.deleteMany();
|
await tx.tabReminder.deleteMany();
|
||||||
await tx.plannedTab.deleteMany();
|
await tx.plannedTab.deleteMany();
|
||||||
await tx.remoteCommand.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() },
|
openTabSnapshot: { deleteMany: jest.fn(), upsert: jest.fn(), updateMany: jest.fn() },
|
||||||
plannedTab: { updateMany: jest.fn().mockResolvedValue({ count: delivered }) },
|
plannedTab: { updateMany: jest.fn().mockResolvedValue({ count: delivered }) },
|
||||||
device: { update: jest.fn() },
|
device: { update: jest.fn() },
|
||||||
|
tabEvent: { createMany: jest.fn() },
|
||||||
};
|
};
|
||||||
const prisma = {
|
const prisma = {
|
||||||
openTabSnapshot: { findMany: jest.fn().mockResolvedValue(previous) },
|
openTabSnapshot: { findMany: jest.fn().mockResolvedValue(previous) },
|
||||||
@@ -154,3 +155,49 @@ describe('DevicesService.sync writes', () => {
|
|||||||
expect(tx.openTabSnapshot.updateMany).not.toHaveBeenCalled();
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
Injectable,
|
Injectable,
|
||||||
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
@@ -13,9 +14,12 @@ import { Prisma } from '@prisma/client';
|
|||||||
import { OpenTabDto, OpenTabItemDto, SyncDto } from './dto/sync.dto';
|
import { OpenTabDto, OpenTabItemDto, SyncDto } from './dto/sync.dto';
|
||||||
import { customerNameKey } from '../planned-tabs/customer-name';
|
import { customerNameKey } from '../planned-tabs/customer-name';
|
||||||
import { AckCommandDto } from './dto/ack-command.dto';
|
import { AckCommandDto } from './dto/ack-command.dto';
|
||||||
|
import { parseTabEvents, tabEventRow } from './tab-events';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DevicesService {
|
export class DevicesService {
|
||||||
|
private readonly logger = new Logger(DevicesService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private auditService: AuditService,
|
private auditService: AuditService,
|
||||||
@@ -31,6 +35,10 @@ export class DevicesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
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 openTabs = dto.openTabs || [];
|
||||||
const openTabIds = openTabs.map((t) => t.id);
|
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({
|
await tx.device.update({
|
||||||
where: { id: device.id },
|
where: { id: device.id },
|
||||||
data: { lastSeenAt: now },
|
data: { lastSeenAt: now },
|
||||||
@@ -208,6 +223,10 @@ export class DevicesService {
|
|||||||
createdAt: c.createdAt,
|
createdAt: c.createdAt,
|
||||||
})),
|
})),
|
||||||
plannedTabNames: plannedTabs.map((t) => t.customerName),
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,4 +70,11 @@ export class SyncDto {
|
|||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
@Type(() => OpenTabDto)
|
@Type(() => OpenTabDto)
|
||||||
openTabs: 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[];
|
||||||
}
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -44,8 +44,17 @@ Sync semantics:
|
|||||||
filters. Devices dedupe by command id and re-send the ack for commands they already processed.
|
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.
|
- 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
|
- `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.
|
reports an open tab with that name (ignoring case and extra spaces), the customer is marked delivered
|
||||||
The customer (and their email, used for reminders) is kept.
|
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
|
### Tabs
|
||||||
`GET /venues/:venueId/tabs` - List open tabs
|
`GET /venues/:venueId/tabs` - List open tabs
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user