diff --git a/apps/api/prisma/migrations/20261022120000_server_tabs_and_stock/migration.sql b/apps/api/prisma/migrations/20261022120000_server_tabs_and_stock/migration.sql new file mode 100644 index 0000000..8936c3b --- /dev/null +++ b/apps/api/prisma/migrations/20261022120000_server_tabs_and_stock/migration.sql @@ -0,0 +1,75 @@ +-- DropForeignKey +ALTER TABLE "RemoteCommand" DROP CONSTRAINT "RemoteCommand_deviceId_fkey"; + +-- DropIndex +DROP INDEX "Device_venueId_key"; + +-- AlterTable +ALTER TABLE "Venue" ADD COLUMN "tabsSeededAt" TIMESTAMP(3); + +-- AlterTable +ALTER TABLE "RemoteCommand" ALTER COLUMN "deviceId" DROP NOT NULL; + +-- AlterTable +ALTER TABLE "TabEvent" ADD COLUMN "lineId" TEXT, +ADD COLUMN "productId" TEXT, +ADD COLUMN "stockSetTo" INTEGER, +ALTER COLUMN "tabId" DROP NOT NULL, +ALTER COLUMN "customerName" DROP NOT NULL; + +-- AlterTable +ALTER TABLE "Product" ADD COLUMN "countedAt" TIMESTAMP(3); + +-- CreateTable +CREATE TABLE "Tab" ( + "venueId" TEXT NOT NULL, + "id" TEXT NOT NULL, + "customerName" TEXT NOT NULL, + "openedAt" TIMESTAMP(3) NOT NULL, + "version" INTEGER NOT NULL DEFAULT 0, + "lines" JSONB NOT NULL, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Tab_pkey" PRIMARY KEY ("venueId","id") +); + +-- CreateTable +CREATE TABLE "Settlement" ( + "id" TEXT NOT NULL, + "sequence" SERIAL NOT NULL, + "venueId" TEXT NOT NULL, + "tabId" TEXT NOT NULL, + "customerName" TEXT NOT NULL, + "settledAt" TIMESTAMP(3) NOT NULL, + "paymentMethod" TEXT NOT NULL, + "amountInCents" INTEGER NOT NULL, + "lines" JSONB NOT NULL, + "deviceId" TEXT, + "requestedBy" TEXT, + "commandId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Settlement_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Settlement_sequence_key" ON "Settlement"("sequence"); + +-- CreateIndex +CREATE INDEX "Settlement_venueId_sequence_idx" ON "Settlement"("venueId", "sequence"); + +-- CreateIndex +CREATE INDEX "Settlement_venueId_settledAt_idx" ON "Settlement"("venueId", "settledAt"); + +-- AddForeignKey +ALTER TABLE "RemoteCommand" ADD CONSTRAINT "RemoteCommand_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Tab" ADD CONSTRAINT "Tab_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Settlement" ADD CONSTRAINT "Settlement_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Settlement" ADD CONSTRAINT "Settlement_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device"("id") ON DELETE SET NULL ON UPDATE CASCADE; + diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 7698348..e287c27 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -24,11 +24,17 @@ model Venue { tabEvents TabEvent[] products Product[] stockAdjustments StockAdjustment[] + tabs Tab[] + settlements Settlement[] + // Set on the first sync after the server started holding the tabs: the tabs were then taken over from the + // terminal's own open tabs, once + tabsSeededAt DateTime? } model Device { id String @id @default(uuid()) - venueId String @unique + // A venue can have several terminals + venueId String name String tokenHash String @unique tokenPrefix String @@ -42,6 +48,7 @@ model Device { tabOwnerships TabOwnership[] remoteCommands RemoteCommand[] tabEvents TabEvent[] + settlements Settlement[] @@index([venueId, revokedAt]) } @@ -105,10 +112,13 @@ model TabOwnership { @@id([venueId, tabId]) } +// A request from the dashboard. close_tab used to be executed by the owning terminal; the server now settles +// the tab itself and records the command as completed right away, for the history. model RemoteCommand { id String @id @default(uuid()) venueId String - deviceId String + // Null for a command the server carried out itself + deviceId String? targetTabId String customerName String? amountInCents Int? @@ -122,8 +132,8 @@ model RemoteCommand { createdAt DateTime @default(now()) expiresAt DateTime - venue Venue @relation(fields: [venueId], references: [id]) - device Device @relation(fields: [deviceId], references: [id]) + venue Venue @relation(fields: [venueId], references: [id]) + device Device? @relation(fields: [deviceId], references: [id]) @@index([deviceId, status, createdAt]) @@index([venueId, status, createdAt]) @@ -188,16 +198,19 @@ model TabReminder { @@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. +// What happened on a tab, as reported by the terminal. The id is generated by the terminal so a re-sent event is +// stored, and applied to the tab state and stock, only once. model TabEvent { id String @id venueId String deviceId String - tabId String - customerName String - // line_added | line_removed | settled + // Null for stock_set, which is not about a tab + tabId String? + customerName String? + // tab_opened | tab_renamed | tab_deleted | line_added | line_removed | settled | stock_set type String + lineId String? + productId String? productName String? unitPriceInCents Int? // Signed: positive for line_added, negative for line_removed @@ -207,6 +220,8 @@ model TabEvent { paymentMethod String? commandId String? items Json? + // stock_set: the counted stock, null to stop tracking + stockSetTo Int? occurredAt DateTime receivedAt DateTime @default(now()) @@ -217,8 +232,9 @@ model TabEvent { @@index([venueId, type, occurredAt]) } -// The terminal's product catalog with its stock, as last reported by the terminal. The terminal owns the -// stock: it counts sales down offline, so the dashboard only changes it through StockAdjustments. +// The venue's products as reported by its terminals, with the stock. The server owns the stock: it follows the +// terminals' line events and stock counts and the dashboard's StockAdjustments. A product is never removed because +// one terminal doesn't report it. model Product { venueId String // Generated by the terminal @@ -226,8 +242,10 @@ model Product { name String category String? priceInCents Int - // Null when the terminal doesn't track stock for this product. Can be negative: a sale is never blocked. + // Null when stock is not tracked for this product. Can be negative: a sale is never blocked. stock Int? + // When the stock was last counted; line events that happened before it are already in the count + countedAt DateTime? // Dashboard-only setting, never sent to or overwritten by the terminal lowStockThreshold Int? reportedAt DateTime @@ -237,8 +255,8 @@ model Product { @@id([venueId, productId]) } -// A stock change requested from the dashboard. It is sent to the terminal on every sync until the terminal -// reports it applied or rejected. +// A stock change made on the dashboard. The server applies it right away. Older ones may still have been applied +// by a terminal, which reported the outcome. model StockAdjustment { id String @id @default(uuid()) venueId String @@ -252,7 +270,7 @@ model StockAdjustment { requestedById String // pending | applied | rejected | cancelled status String @default("pending") - // The terminal's stock right after applying it + // The stock right after applying it stockAfter Int? result String? createdAt DateTime @default(now()) @@ -263,3 +281,49 @@ model StockAdjustment { @@index([venueId, status, createdAt]) @@index([venueId, productId, createdAt]) } + +// An open tab of the venue: the source of truth the terminals replace their own tabs with on every sync. Tabs are +// never closed, only emptied by a settlement; tab_deleted removes one. +model Tab { + venueId String + // Generated by the terminal that opened it + id String + customerName String + openedAt DateTime + // Bumped by every change, so the dashboard can refuse to settle a tab that changed since it was shown + version Int @default(0) + // [{ id, productId, productName, unitPriceInCents, units: [{ id, addedAt, removedAt }] }] + lines Json + updatedAt DateTime @updatedAt + + venue Venue @relation(fields: [venueId], references: [id]) + + @@id([venueId, id]) +} + +// A settlement made on a terminal (id = the settled event's id) or on the dashboard. Terminals read them as a feed +// after their cursor, which is the sequence number. +model Settlement { + id String @id + sequence Int @unique @default(autoincrement()) + venueId String + tabId String + customerName String + settledAt DateTime + // cash | payconiq | payment_pending | other + paymentMethod String + amountInCents Int + // The settled lines, in the shape of Tab.lines with each line's settled and removed units + lines Json + // Who settled it: a terminal, or a dashboard user + deviceId String? + requestedBy String? + commandId String? + createdAt DateTime @default(now()) + + venue Venue @relation(fields: [venueId], references: [id]) + device Device? @relation(fields: [deviceId], references: [id]) + + @@index([venueId, sequence]) + @@index([venueId, settledAt]) +} diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index c484514..f16d4bc 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -145,36 +145,19 @@ export class AuthService { } const { rawToken, hash, prefix, expiresAt } = await this.generateDeviceToken(); - let deviceId: string; - - // One device per venue — update existing or create new - const existing = await this.prisma.device.findFirst({ where: { venueId } }); - if (existing) { - deviceId = existing.id; - await this.prisma.device.update({ - where: { id: existing.id }, - data: { - name, - tokenHash: hash, - tokenPrefix: prefix, - tokenExpiresAt: expiresAt, - revokedAt: null, - lastSeenAt: new Date(), - }, - }); - } else { - deviceId = uuidv4(); - await this.prisma.device.create({ - data: { - id: deviceId, - venueId, - name, - tokenHash: hash, - tokenPrefix: prefix, - tokenExpiresAt: expiresAt, - }, - }); - } + // A venue can have several terminals: every pairing adds one. A terminal that is replaced is unpaired on the + // dashboard + const deviceId = uuidv4(); + await this.prisma.device.create({ + data: { + id: deviceId, + venueId, + name, + tokenHash: hash, + tokenPrefix: prefix, + tokenExpiresAt: expiresAt, + }, + }); await this.auditService.log(venueId, 'device', deviceId, 'device_registered', { deviceName: name }); diff --git a/apps/api/src/commands/commands.service.spec.ts b/apps/api/src/commands/commands.service.spec.ts index 9e18930..98d4fc8 100644 --- a/apps/api/src/commands/commands.service.spec.ts +++ b/apps/api/src/commands/commands.service.spec.ts @@ -1,98 +1,76 @@ import { ConflictException, NotFoundException } from '@nestjs/common'; -import { Prisma } from '@prisma/client'; import { CommandsService } from './commands.service'; +import { createFakePrisma } from '../testing/fake-prisma'; const user = { id: 'u1', name: 'Staff', email: 'staff@example.com' }; const dto = { tabId: 't1', type: 'close_tab', expectedVersion: 3, paymentMethod: 'cash' }; -function createService({ - ownership = { deviceId: 'd1' } as object | null, - device = { id: 'd1', revokedAt: null, lastSeenAt: new Date() } as object | null, - snapshot = { customerName: 'Jan', totalInCents: 2500, version: 3 } as object | null, - pending = null as object | null, -} = {}) { - const tx = { - tabOwnership: { findUnique: jest.fn().mockResolvedValue(ownership) }, - device: { findUnique: jest.fn().mockResolvedValue(device) }, - openTabSnapshot: { findUnique: jest.fn().mockResolvedValue(snapshot) }, - remoteCommand: { - findFirst: jest.fn().mockResolvedValue(pending), - create: jest.fn(async ({ data }) => data), - }, - auditLog: { create: jest.fn() }, - }; - const prisma = { $transaction: jest.fn((fn: (t: typeof tx) => unknown) => Promise.resolve().then(() => fn(tx))) }; +const unit = (id: string, removed = false) => ( + { id, addedAt: '2026-09-24T20:00:00.000Z', removedAt: removed ? '2026-09-24T20:01:00.000Z' : null }); + +function createService({ lines = [] as unknown[] } = {}) { + const prisma = createFakePrisma(); + prisma.venue.rows.push({ id: 'v1', tabsSeededAt: new Date('2026-10-01') }); + prisma.tab.rows.push({ + venueId: 'v1', id: 't1', customerName: 'Jan', openedAt: new Date('2026-09-24T19:00:00Z'), version: 3, lines, + updatedAt: new Date(), + }); const realtime = { notify: jest.fn() }; const service = new CommandsService(prisma as any, { log: jest.fn() } as any, realtime as any); - return { service, prisma, tx, realtime }; + return { service, prisma, realtime }; } +const colaLine = { + id: 'line-1', productId: 'cola', productName: 'Cola', unitPriceInCents: 250, + units: [unit('u1'), unit('u2'), unit('u3', true)], +}; + describe('CommandsService.create', () => { - it('queues a close command with the customer and amount it was sent for', async () => { - const { service, prisma, tx, realtime } = createService(); + it('settles everything on the tab at once and records it as completed', async () => { + const { service, prisma, realtime } = createService({ lines: [colaLine] }); const result = await service.create('v1', dto, user); - expect(result.status).toBe('pending'); - expect(tx.remoteCommand.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ - venueId: 'v1', - deviceId: 'd1', - targetTabId: 't1', - customerName: 'Jan', - amountInCents: 2500, - expectedVersion: 3, - paymentMethod: 'cash', - requestedBy: 'Staff', - }), + expect(result).toMatchObject({ status: 'completed', amountInCents: 500 }); + expect(prisma.tab.rows[0]).toMatchObject({ version: 4, lines: [] }); + expect(prisma.settlement.rows).toHaveLength(1); + expect(prisma.settlement.rows[0]).toMatchObject({ + tabId: 't1', customerName: 'Jan', paymentMethod: 'cash', amountInCents: 500, requestedBy: 'Staff', deviceId: null, }); - expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), { isolationLevel: 'Serializable' }); - expect(realtime.notify).toHaveBeenCalledWith('v1', 'commands'); + // The struck-through drink goes along, so the history shows it + expect(prisma.settlement.rows[0].lines).toMatchObject([{ quantity: 2, units: [{ id: 'u1' }, { id: 'u2' }, { id: 'u3' }] }]); + expect(prisma.remoteCommand.rows[0]).toMatchObject({ + id: result.id, deviceId: null, status: 'completed', customerName: 'Jan', amountInCents: 500, + }); + expect(realtime.notify).toHaveBeenCalledWith('v1', 'commands', 'tabs'); }); - it('rejects a tab no device owns', async () => { - const { service } = createService({ ownership: null }); + it('rejects a tab that is not open', async () => { + const { service } = createService({ lines: [colaLine] }); - await expect(service.create('v1', dto, user)).rejects.toBeInstanceOf(NotFoundException); + await expect(service.create('v1', { ...dto, tabId: 't9' }, user)).rejects.toBeInstanceOf(NotFoundException); }); - it('rejects a tab that changed since it was loaded', async () => { - const { service, tx } = createService({ snapshot: { customerName: 'Jan', totalInCents: 3000, version: 4 } }); + it('rejects a tab that changed since it was loaded, changing nothing', async () => { + const { service, prisma } = createService({ lines: [colaLine] }); - await expect(service.create('v1', dto, user)).rejects.toMatchObject({ response: { code: 'version_mismatch' } }); - expect(tx.remoteCommand.create).not.toHaveBeenCalled(); + await expect(service.create('v1', { ...dto, expectedVersion: 2 }, user)) + .rejects.toMatchObject({ response: { code: 'version_mismatch' } }); + expect(prisma.settlement.rows).toEqual([]); }); - it('rejects a tab whose device was revoked', async () => { - const { service } = createService({ device: { id: 'd1', revokedAt: new Date(), lastSeenAt: new Date() } }); + it('rejects a tab with nothing on it', async () => { + const { service } = createService({ lines: [] }); - await expect(service.create('v1', dto, user)).rejects.toMatchObject({ response: { code: 'device_revoked' } }); + await expect(service.create('v1', dto, user)).rejects.toBeInstanceOf(ConflictException); }); - it('rejects a second command while one is pending', async () => { - const { service, tx } = createService({ pending: { id: 'c0' } }); + it('rejects an unknown payment method or command type', async () => { + const { service } = createService({ lines: [colaLine] }); - await expect(service.create('v1', dto, user)).rejects.toMatchObject({ response: { code: 'command_already_pending' } }); - expect(tx.remoteCommand.create).not.toHaveBeenCalled(); - }); - - it('reports a lost race between two simultaneous requests as already pending', async () => { - const { service, prisma } = createService(); - prisma.$transaction.mockRejectedValue( - new Prisma.PrismaClientKnownRequestError('serialization failure', { code: 'P2034', clientVersion: 'test' }), - ); - - const error = await service.create('v1', dto, user).catch((e) => e); - - expect(error).toBeInstanceOf(ConflictException); - expect(error.response.code).toBe('command_already_pending'); - }); - - it('warns when the terminal has not been seen recently', async () => { - const { service } = createService({ device: { id: 'd1', revokedAt: null, lastSeenAt: new Date(Date.now() - 60 * 60 * 1000) } }); - - const result = await service.create('v1', dto, user); - - expect(result).toHaveProperty('warning'); + await expect(service.create('v1', { ...dto, paymentMethod: 'bitcoin' }, user)) + .rejects.toMatchObject({ response: { code: 'invalid_payment_method' } }); + await expect(service.create('v1', { ...dto, type: 'open_tab' }, user)) + .rejects.toMatchObject({ response: { code: 'invalid_command_type' } }); }); }); diff --git a/apps/api/src/commands/commands.service.ts b/apps/api/src/commands/commands.service.ts index 57d65cf..280f805 100644 --- a/apps/api/src/commands/commands.service.ts +++ b/apps/api/src/commands/commands.service.ts @@ -5,12 +5,11 @@ import { ConflictException, } from '@nestjs/common'; import { v4 as uuidv4 } from 'uuid'; -import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { AuditService } from '../audit/audit.service'; import { RealtimeService } from '../realtime/realtime.service'; import { CreateCommandDto } from './dto/create-command.dto'; -import { COMMAND_EXPIRY_HOURS, DEVICE_OFFLINE_THRESHOLD_MS } from '@kooltap/shared'; +import { VenueLedger } from '../tabs/venue-ledger'; @Injectable() export class CommandsService { @@ -20,6 +19,11 @@ export class CommandsService { private realtime: RealtimeService, ) {} + /** + * Settles a tab on the server, at once, also when every terminal is offline: everything still on the tab is + * settled, and the terminals see the lines disappear on their next sync. Recorded as a completed command for the + * history. + */ async create(venueId: string, dto: CreateCommandDto, user: any) { if (!dto.paymentMethod) { throw new BadRequestException({ @@ -36,72 +40,50 @@ export class CommandsService { }); } - // Serializable so two simultaneous requests for the same tab can't both pass the pending check + const type = dto.type || 'close_tab'; + if (type !== 'close_tab') { + throw new BadRequestException({ code: 'invalid_command_type', message: `Unknown command type: ${type}` }); + } + + const requestedBy = user.name || user.email; const result = await this.prisma.$transaction(async (tx) => { - // Resolve tab ownership - const ownership = await tx.tabOwnership.findUnique({ - where: { venueId_tabId: { venueId, tabId: dto.tabId } }, - }); + const now = new Date(); + const ledger = await VenueLedger.open(tx, venueId); - if (!ownership) { - throw new NotFoundException({ - code: 'tab_not_owned', - message: 'Tab is not associated with any device', - }); + const tab = ledger.tabs.get(dto.tabId); + if (!tab) { + throw new NotFoundException({ code: 'tab_not_found', message: 'This tab is no longer open' }); } - - const device = await tx.device.findUnique({ where: { id: ownership.deviceId } }); - if (!device) { - throw new NotFoundException({ - code: 'device_not_found', - message: 'Owning device not found', - }); - } - - if (device.revokedAt) { - throw new ConflictException({ - code: 'device_revoked', - message: 'The device owning this tab has been revoked', - }); - } - - const snapshot = await tx.openTabSnapshot.findUnique({ - where: { deviceId_tabId: { deviceId: device.id, tabId: dto.tabId } }, - }); - if (snapshot && snapshot.version !== dto.expectedVersion) { + if (tab.version !== dto.expectedVersion) { throw new ConflictException({ code: 'version_mismatch', message: 'The tab has changed since it was loaded. Refresh and try again.', }); } - const alreadyPending = await tx.remoteCommand.findFirst({ - where: { venueId, targetTabId: dto.tabId, status: 'pending' }, - }); - if (alreadyPending) { - throw new ConflictException({ - code: 'command_already_pending', - message: 'A command for this tab is already pending', - }); + const id = uuidv4(); + const settlement = ledger.tabs.settleAll(dto.tabId, { id, settledAt: now, paymentMethod: dto.paymentMethod }); + if (!settlement) { + throw new ConflictException({ code: 'tab_empty', message: 'There is nothing on this tab to settle' }); } - - const now = new Date(); - const expiresAt = new Date(now.getTime() + COMMAND_EXPIRY_HOURS * 60 * 60 * 1000); + ledger.settlements.push({ ...settlement, commandId: id, requestedBy }); + await ledger.save(tx, now); const command = await tx.remoteCommand.create({ data: { - id: uuidv4(), + id, venueId, - deviceId: device.id, + deviceId: null, targetTabId: dto.tabId, - customerName: snapshot?.customerName ?? null, - amountInCents: snapshot?.totalInCents ?? null, - type: dto.type || 'close_tab', + customerName: settlement.customerName, + amountInCents: settlement.amountInCents, + type, expectedVersion: dto.expectedVersion, paymentMethod: dto.paymentMethod, - requestedBy: user.name || user.email, - status: 'pending', - expiresAt, + requestedBy, + status: 'completed', + acknowledgedAt: now, + expiresAt: now, }, }); @@ -111,39 +93,20 @@ export class CommandsService { venueId, actorType: 'client_user', actorId: user.id, - action: 'command_created', + action: 'tab_settled', details: JSON.stringify({ commandId: command.id, tabId: dto.tabId, - type: dto.type, paymentMethod: dto.paymentMethod, - deviceId: device.id, + amountInCents: settlement.amountInCents, }), }, }); - // Check if device is stale - const lastSeenAgo = now.getTime() - new Date(device.lastSeenAt).getTime(); - const warning = lastSeenAgo > DEVICE_OFFLINE_THRESHOLD_MS - ? { warning: 'Device has not been seen recently' } - : {}; - - return { - id: command.id, - status: command.status, - ...warning, - }; - }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }).catch((error) => { - if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2034') { - throw new ConflictException({ - code: 'command_already_pending', - message: 'A command for this tab is already pending', - }); - } - throw error; + return { id: command.id, status: command.status, amountInCents: settlement.amountInCents }; }); - this.realtime.notify(venueId, 'commands'); + this.realtime.notify(venueId, 'commands', 'tabs'); return result; } diff --git a/apps/api/src/devices/devices.service.spec.ts b/apps/api/src/devices/devices.service.spec.ts index 2fdcafa..a22001f 100644 --- a/apps/api/src/devices/devices.service.spec.ts +++ b/apps/api/src/devices/devices.service.spec.ts @@ -1,293 +1,379 @@ -import { Prisma } from '@prisma/client'; +import { v4 as uuidv4 } from 'uuid'; import { DevicesService } from './devices.service'; +import { SyncDto } from './dto/sync.dto'; +import { createFakePrisma, FakePrisma } from '../testing/fake-prisma'; -type Snapshot = { tabId: string; customerName: string; version: number; totalInCents: number; itemCount: number; items?: unknown }; +type Device = { id: string; venueId: string; revokedAt: null; lastSeenAt: Date }; +type SyncResult = Awaited>; -type StoredProduct = { productId: string; name: string; category: string | null; priceInCents: number; stock: number | null }; +// In the past: a time ahead of the server clock would be capped +const at = (minutes: number) => new Date(Date.UTC(2026, 8, 24, 20, minutes)).toISOString(); -function createService({ - previous = [] as Snapshot[], delivered = 0, expired = 0, products = [] as StoredProduct[], pendingStock = [] as unknown[], - recordedResults = 1, -} = {}) { - const tx = { - tabOwnership: { 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 }) }, - device: { update: jest.fn() }, - tabEvent: { createMany: jest.fn() }, - product: { deleteMany: jest.fn(), updateMany: jest.fn(), upsert: jest.fn() }, - stockAdjustment: { updateMany: jest.fn().mockResolvedValue({ count: recordedResults }) }, - }; - const prisma = { - openTabSnapshot: { findMany: jest.fn().mockResolvedValue(previous) }, - tabOwnership: { findMany: jest.fn().mockResolvedValue([]) }, - $transaction: jest.fn((fn: (t: typeof tx) => unknown) => fn(tx)), - remoteCommand: { updateMany: jest.fn().mockResolvedValue({ count: expired }), findMany: jest.fn().mockResolvedValue([]) }, - plannedTab: { findMany: jest.fn().mockResolvedValue([{ customerName: 'Els' }]) }, - product: { findMany: jest.fn().mockResolvedValue(products) }, - stockAdjustment: { findMany: jest.fn().mockResolvedValue(pendingStock) }, - }; - const audit = { log: jest.fn() }; +function setup({ seeded = true } = {}) { + const prisma = createFakePrisma(); + prisma.venue.rows.push({ id: 'v1', tabsSeededAt: seeded ? new Date('2026-10-01') : null }); + const tabletA: Device = { id: 'd1', venueId: 'v1', revokedAt: null, lastSeenAt: new Date() }; + const tabletB: Device = { id: 'd2', venueId: 'v1', revokedAt: null, lastSeenAt: new Date() }; + prisma.device.rows.push({ ...tabletA }, { ...tabletB }); const realtime = { notify: jest.fn() }; - const service = new DevicesService(prisma as any, audit as any, realtime as any); - return { service, prisma, tx, realtime }; + const service = new DevicesService(prisma as any, { log: jest.fn() } as any, realtime as any); + + const sync = (device: Device, body: Partial = {}) => + service.sync(device, { deviceId: device.id, openTabs: [], ...body } as SyncDto); + + return { prisma, service, realtime, sync, tabletA, tabletB }; } -const device = { id: 'd1', venueId: 'v1', revokedAt: null, lastSeenAt: new Date() }; -const tab = (id: string, customerName: string) => ({ id, customerName, version: 1, totalInCents: 0, itemCount: 0 }); +/** Builds the events a terminal sends, with fresh ids. */ +const events = { + opened: (tabId: string, customerName: string, minutes = 0) => ( + { id: uuidv4(), type: 'tab_opened', tabId, customerName, occurredAt: at(minutes), openedAt: at(minutes) }), + renamed: (tabId: string, customerName: string, minutes = 0) => ( + { id: uuidv4(), type: 'tab_renamed', tabId, customerName, occurredAt: at(minutes) }), + deleted: (tabId: string, customerName: string, minutes = 0) => ( + { id: uuidv4(), type: 'tab_deleted', tabId, customerName, occurredAt: at(minutes) }), + line: (type: 'line_added' | 'line_removed', tabId: string, quantity: number, { + minutes = 0, lineId = undefined as string | undefined, productId = 'cola', productName = 'Cola', price = 250, + } = {}) => ({ + id: uuidv4(), type, tabId, customerName: 'Alice', occurredAt: at(minutes), + lineId, productId, productName, unitPriceInCents: price, quantity, + }), + settled: (tabId: string, quantity: number, minutes = 0) => ({ + id: uuidv4(), type: 'settled', tabId, customerName: 'Alice', occurredAt: at(minutes), + amountInCents: quantity * 250, paymentMethod: 'payconiq', + items: [{ productId: 'cola', productName: 'Cola', quantity, unitPriceInCents: 250 }], + }), + stockSet: (productId: string, quantity: number | null, minutes = 0) => ( + { id: uuidv4(), type: 'stock_set', productId, quantity, occurredAt: at(minutes) }), +}; -describe('DevicesService.sync planned customers', () => { - it('marks planned customers delivered once the device reports a tab with their name, ignoring case', async () => { - const { service, tx } = createService(); +const catalog = (stock: number | null = 0) => [ + { id: 'cola', name: 'Cola', category: 'Frisdrank', priceInCents: 250, stock }, +]; - await service.sync(device, { deviceId: 'd1', openTabs: [tab('t1', 'Jan'), tab('t2', ' piet Peeters')] }); +function tab(result: SyncResult, tabId: string) { + return result.tabs.find((t) => t.id === tabId); +} - expect(tx.plannedTab.updateMany).toHaveBeenCalledWith({ - where: { venueId: 'v1', deliveredAt: null, customerNameKey: { in: ['jan', 'piet peeters'] } }, - data: { deliveredAt: expect.any(Date) }, +function colaStock(result: SyncResult) { + return result.stock.find((s) => s.productId === 'cola')?.stock; +} + +describe('DevicesService.sync: the server holds the tabs', () => { + it('shows a newly paired tablet the open tabs of the venue', async () => { + const { sync, tabletA, tabletB } = setup(); + await sync(tabletA, { events: [events.opened('t1', 'Alice'), events.line('line_added', 't1', 2)] }); + + const result = await sync(tabletB); + + expect(result.tabs).toHaveLength(1); + expect(result.tabs[0]).toMatchObject({ id: 't1', customerName: 'Alice', version: 2 }); + expect(result.tabs[0].lines).toMatchObject([{ productId: 'cola', productName: 'Cola', quantity: 2 }]); + }); + + it('gives units the ids the tablets give them when replaying', async () => { + const { sync, tabletA } = setup(); + const added = events.line('line_added', 't1', 2, { lineId: 'line-1' }); + + const result = await sync(tabletA, { events: [events.opened('t1', 'Alice'), added] }); + + expect(tab(result, 't1')!.lines[0]).toMatchObject({ + id: 'line-1', + units: [ + { id: `${added.id}:0`, addedAt: at(0), removedAt: null }, + { id: `${added.id}:1`, addedAt: at(0), removedAt: null }, + ], }); }); - it('only sends customers that have not been delivered yet', async () => { - const { service, prisma } = createService(); + it('adds what two tablets put on one tab', async () => { + const { sync, tabletA, tabletB } = setup(); + await sync(tabletA, { events: [events.opened('t1', 'Alice'), events.line('line_added', 't1', 2, { lineId: 'line-1' })] }); - const result = await service.sync(device, { deviceId: 'd1', openTabs: [] }); + // Tablet B replayed its drink onto the server's line, so it uses that line id + await sync(tabletB, { events: [events.line('line_added', 't1', 1, { lineId: 'line-1', minutes: 1 })] }); + const result = await sync(tabletA); - expect(prisma.plannedTab.findMany).toHaveBeenCalledWith( - expect.objectContaining({ where: { venueId: 'v1', deliveredAt: null } }), - ); - expect(result.plannedTabNames).toEqual(['Els']); + expect(tab(result, 't1')!.lines).toMatchObject([{ id: 'line-1', quantity: 3 }]); }); - it('does not mark anything delivered when the device reports no tabs', async () => { - const { service, tx } = createService(); + it('applies an event once, also when it arrives again in a later sync', async () => { + const { sync, tabletA, prisma } = setup(); + const added = events.line('line_added', 't1', 1); + await sync(tabletA, { events: [events.opened('t1', 'Alice'), added] }); - await service.sync(device, { deviceId: 'd1', openTabs: [] }); + const result = await sync(tabletA, { events: [added, added] }); - expect(tx.plannedTab.updateMany).not.toHaveBeenCalled(); + expect(result.acknowledgedEventIds).toEqual([added.id, added.id]); + expect(tab(result, 't1')!.lines[0].quantity).toBe(1); + expect(prisma.tabEvent.rows).toHaveLength(2); + }); + + it('catches up with everything a tablet did offline, in order', async () => { + const { sync, tabletA, tabletB } = setup(); + await sync(tabletB, { events: [events.opened('bob', 'Bob')] }); + + await sync(tabletA, { + events: [ + events.opened('t1', 'Alice', 1), + events.line('line_added', 't1', 3, { minutes: 2 }), + events.line('line_removed', 't1', 1, { minutes: 3 }), + events.renamed('t1', 'Alice V.', 4), + events.deleted('bob', 'Bob', 5), + ], + }); + const result = await sync(tabletB); + + expect(result.tabs.map((t) => t.customerName)).toEqual(['Alice V.']); + const [line] = tab(result, 't1')!.lines; + expect(line.quantity).toBe(2); + // The latest drink was taken back + expect(line.units.map((u) => u.removedAt)).toEqual([null, null, at(3)]); + }); + + it('settles only what the tablet saw, leaving a drink added elsewhere on the tab', async () => { + const { sync, tabletA, tabletB } = setup(); + await sync(tabletA, { events: [events.opened('t1', 'Alice'), events.line('line_added', 't1', 2)] }); + await sync(tabletB, { events: [events.line('line_added', 't1', 1, { minutes: 5 })] }); + + // Tablet A was offline and settled the 2 Cola it knew about + const result = await sync(tabletA, { events: [events.settled('t1', 2, 3)] }); + + expect(tab(result, 't1')!.lines).toMatchObject([{ quantity: 1, units: [{ addedAt: at(5) }] }]); + expect(result.settlements).toHaveLength(1); + expect(result.settlements[0]).toMatchObject({ tabId: 't1', paymentMethod: 'payconiq', amountInCents: 500 }); + expect(result.settlements[0].lines).toMatchObject([{ productId: 'cola', quantity: 2 }]); + expect(result.settlements[0].lines[0].units.map((u) => u.addedAt)).toEqual([at(0), at(0)]); + }); + + it('brings a tab deleted elsewhere back with the drink that was put on it', async () => { + const { sync, tabletA, tabletB } = setup(); + await sync(tabletA, { events: [events.opened('bob', 'Bob')] }); + await sync(tabletB, { events: [events.deleted('bob', 'Bob', 1)] }); + + const result = await sync(tabletA, { + events: [{ ...events.line('line_added', 'bob', 1, { minutes: 2, productId: 'beer', productName: 'Beer', price: 400 }), customerName: 'Bob' }], + }); + + expect(tab(result, 'bob')).toMatchObject({ customerName: 'Bob', lines: [{ productName: 'Beer', quantity: 1 }] }); + }); + + it('matches events from older app versions by product name and price', async () => { + const { sync, tabletA } = setup(); + const { lineId, productId, ...old } = events.line('line_added', 't1', 2); + await sync(tabletA, { events: [events.opened('t1', 'Alice'), old] }); + + const { lineId: _, productId: __, ...removed } = events.line('line_removed', 't1', 1, { minutes: 1 }); + const result = await sync(tabletA, { events: [removed] }); + + expect(tab(result, 't1')!.lines).toMatchObject([{ productId: null, quantity: 1 }]); + }); + + it('does not apply a rejected event, and still syncs the rest', async () => { + const { sync, tabletA } = setup(); + const invalid = { ...events.line('line_added', 't1', 1), quantity: 0 }; + + const result = await sync(tabletA, { events: [events.opened('t1', 'Alice'), invalid] }); + + expect(result.rejectedEvents).toEqual([{ id: invalid.id, reason: expect.any(String) }]); + expect(tab(result, 't1')!.lines).toEqual([]); + }); + + it('opens planned customers as tabs once, instead of on every tablet', async () => { + const { sync, tabletA, tabletB, prisma } = setup(); + prisma.plannedTab.rows.push( + { id: 'p1', venueId: 'v1', customerName: 'Els', customerNameKey: 'els', deliveredAt: null, position: 0 }, + { id: 'p2', venueId: 'v1', customerName: 'alice', customerNameKey: 'alice', deliveredAt: null, position: 1 }, + ); + await sync(tabletA, { events: [events.opened('t1', 'Alice')] }); + + const result = await sync(tabletB); + + expect(result.plannedTabNames).toEqual([]); + expect(result.tabs.map((t) => t.customerName).sort()).toEqual(['Alice', 'Els']); + expect(prisma.plannedTab.rows.every((p) => p.deliveredAt !== null)).toBe(true); + }); +}); + +describe('DevicesService.sync: taking over a tablet once', () => { + it("takes over the tablet's tabs and stock without applying that request's events again", async () => { + const { sync, tabletA, prisma } = setup({ seeded: false }); + + const result = await sync(tabletA, { + openTabs: [{ + id: 't1', customerName: 'Alice', version: 4, totalInCents: 750, itemCount: 3, + items: [{ productName: 'Cola', quantity: 3, unitPriceInCents: 250 }], + }], + // Already in the tabs and stock above + events: [events.line('line_added', 't1', 1)], + products: catalog(45), + }); + + expect(tab(result, 't1')).toMatchObject({ customerName: 'Alice', version: 4, lines: [{ productName: 'Cola', quantity: 3 }] }); + expect(colaStock(result)).toBe(45); + expect(prisma.venue.rows[0].tabsSeededAt).not.toBeNull(); + + const next = await sync(tabletA, { + openTabs: [], + events: [events.line('line_added', 't1', 1, { productId: 'cola', minutes: 1 })], + products: catalog(44), + }); + expect(tab(next, 't1')!.lines[0].quantity).toBe(4); + expect(colaStock(next)).toBe(44); + }); +}); + +describe('DevicesService.sync: settlements feed', () => { + it("sends every tablet the venue's settlements once, after its cursor", async () => { + const { sync, tabletA, tabletB } = setup(); + const settled = events.settled('t1', 1, 2); + await sync(tabletA, { events: [events.opened('t1', 'Alice'), events.line('line_added', 't1', 1), settled] }); + + const first = await sync(tabletB); + expect(first.settlements.map((s) => s.id)).toEqual([settled.id]); + + const second = await sync(tabletB, { settlementsCursor: first.settlementsCursor }); + expect(second.settlements).toEqual([]); + expect(second.settlementsCursor).toBe(first.settlementsCursor); + }); + + it('starts an empty feed after the latest settlement', async () => { + const { sync, tabletA } = setup(); + + const result = await sync(tabletA); + + expect(result.settlements).toEqual([]); + expect(result.settlementsCursor).toBe('0'); + }); +}); + +describe('DevicesService.sync: the server holds the stock', () => { + async function trackedCola(sync: ReturnType['sync'], tablet: Device, stock = 48) { + return sync(tablet, { products: catalog(), events: [events.stockSet('cola', stock, -10)] }); + } + + it('starts a new product untracked and ignores the stock in the catalog', async () => { + const { sync, tabletA } = setup(); + + const result = await sync(tabletA, { products: catalog(99) }); + + expect(result.stock).toEqual([{ productId: 'cola', stock: null, countedAt: null }]); + }); + + it('counts the sales of every tablet once', async () => { + const { sync, tabletA, tabletB } = setup(); + await trackedCola(sync, tabletA); + const sale = events.line('line_added', 't1', 2, { minutes: 1 }); + + await sync(tabletA, { events: [events.opened('t1', 'Alice'), sale] }); + await sync(tabletA, { events: [sale] }); + const result = await sync(tabletB, { events: [events.line('line_added', 't1', 2, { minutes: 2 })] }); + + expect(colaStock(result)).toBe(44); + }); + + it('gives stock back for drinks taken off, but not for a settlement', async () => { + const { sync, tabletA } = setup(); + await trackedCola(sync, tabletA); + + const result = await sync(tabletA, { + events: [ + events.opened('t1', 'Alice'), + events.line('line_added', 't1', 3, { minutes: 1 }), + events.line('line_removed', 't1', 1, { minutes: 2 }), + events.settled('t1', 2, 3), + ], + }); + + expect(colaStock(result)).toBe(46); + }); + + it('keeps a count when sales from before it arrive later', async () => { + const { sync, tabletA, tabletB } = setup(); + await trackedCola(sync, tabletA); + await sync(tabletB, { events: [events.stockSet('cola', 30, 60)] }); + + const result = await sync(tabletA, { + events: [ + events.opened('t1', 'Alice'), + // Rung up offline before the count: already in it + events.line('line_added', 't1', 2, { minutes: 50 }), + // An older count from an offline tablet doesn't overwrite the newer one + events.stockSet('cola', 70, 55), + events.line('line_added', 't1', 3, { minutes: 70 }), + ], + }); + + expect(colaStock(result)).toBe(27); + expect(result.stock[0].countedAt).toBe(at(60)); + }); + + it('applies a pending dashboard adjustment once and no longer sends adjustments', async () => { + const { sync, tabletA, tabletB, prisma } = setup(); + await trackedCola(sync, tabletA); + prisma.stockAdjustment.rows.push({ + id: 'adj-1', venueId: 'v1', productId: 'cola', productName: 'Cola', type: 'add', quantity: 24, + status: 'pending', createdAt: new Date(), + }); + + const first = await sync(tabletA); + const second = await sync(tabletB); + + expect(first.stockAdjustments).toEqual([]); + expect(colaStock(first)).toBe(72); + expect(colaStock(second)).toBe(72); + expect(prisma.stockAdjustment.rows[0]).toMatchObject({ status: 'applied', stockAfter: 72 }); + }); + + it('records results from older tablets that applied an adjustment themselves', async () => { + const { sync, tabletA, prisma } = setup(); + await trackedCola(sync, tabletA); + const id = uuidv4(); + prisma.stockAdjustment.rows.push({ + id, venueId: 'v1', productId: 'cola', productName: 'Cola', type: 'add', quantity: 24, status: 'pending', createdAt: new Date(), + }); + + const result = await sync(tabletA, { stockAdjustmentResults: [{ id, status: 'applied', stockAfter: 72 }] }); + + expect(result.acknowledgedStockAdjustmentResultIds).toEqual([id]); + // Applied by the tablet, so not again by the server + expect(colaStock(result)).toBe(48); + }); + + it('never removes a product another tablet does not know', async () => { + const { sync, tabletA, tabletB } = setup(); + await trackedCola(sync, tabletA); + + const result = await sync(tabletB, { products: [{ id: 'water', name: 'Water', category: null, priceInCents: 200, stock: 3 }] }); + + expect(result.stock.map((s) => s.productId).sort()).toEqual(['cola', 'water']); }); }); describe('DevicesService.sync live update signals', () => { - const jan = { id: 't1', customerName: 'Jan', version: 2, totalInCents: 500, itemCount: 1 }; - const janSnapshot = { tabId: 't1', customerName: 'Jan', version: 2, totalInCents: 500, itemCount: 1 }; - it('sends nothing when the sync changed nothing', async () => { - const { service, realtime } = createService({ previous: [janSnapshot] }); + const { sync, tabletA, realtime } = setup(); + await sync(tabletA, { products: catalog(), events: [events.opened('t1', 'Alice')] }); + realtime.notify.mockClear(); - await service.sync(device, { deviceId: 'd1', openTabs: [jan] }); + await sync(tabletA, { products: catalog() }); expect(realtime.notify).not.toHaveBeenCalled(); }); - it('signals "tabs" when a tab changed', async () => { - const { service, realtime } = createService({ previous: [janSnapshot] }); + it('signals "tabs" and "stock" when they changed', async () => { + const { sync, tabletA, realtime } = setup(); - await service.sync(device, { deviceId: 'd1', openTabs: [{ ...jan, version: 3, totalInCents: 900, itemCount: 2 }] }); + await sync(tabletA, { products: catalog(), events: [events.opened('t1', 'Alice')] }); - expect(realtime.notify).toHaveBeenCalledWith('v1', 'tabs'); - }); - - it('signals "tabs" when a tab disappeared', async () => { - const { service, realtime } = createService({ previous: [janSnapshot] }); - - await service.sync(device, { deviceId: 'd1', openTabs: [] }); - - expect(realtime.notify).toHaveBeenCalledWith('v1', 'tabs'); - }); - - it('signals delivered customers and expired commands', async () => { - const { service, realtime } = createService({ previous: [janSnapshot], delivered: 1, expired: 2 }); - - await service.sync(device, { deviceId: 'd1', openTabs: [jan] }); - - expect(realtime.notify).toHaveBeenCalledWith('v1', 'planned-tabs', 'commands'); + expect(realtime.notify).toHaveBeenCalledWith('v1', 'tabs', 'stock'); }); it('signals "device" when a terminal comes back online', async () => { - const { service, realtime } = createService({ previous: [janSnapshot] }); + const { sync, realtime } = setup(); - await service.sync({ ...device, lastSeenAt: new Date(Date.now() - 10 * 60 * 1000) }, { deviceId: 'd1', openTabs: [jan] }); + await sync({ id: 'd1', venueId: 'v1', revokedAt: null, lastSeenAt: new Date(Date.now() - 3_600_000) }); expect(realtime.notify).toHaveBeenCalledWith('v1', 'device'); }); }); - -describe('DevicesService.sync tab lines', () => { - const base = { id: 't1', customerName: 'Bram', version: 4, totalInCents: 2500, itemCount: 10 }; - - it('stores the lines a terminal reports', async () => { - const { service, tx } = createService(); - const items = [{ productName: 'Cola', quantity: 10, unitPriceInCents: 250 }]; - - await service.sync(device, { deviceId: 'd1', openTabs: [{ ...base, items }] }); - - const { create, update } = tx.openTabSnapshot.upsert.mock.calls[0][0]; - expect(create.items).toEqual(items); - expect(update.items).toEqual(items); - }); - - it('stores no lines for app versions that do not report them', async () => { - const { service, tx } = createService(); - - await service.sync(device, { deviceId: 'd1', openTabs: [base] }); - - expect(tx.openTabSnapshot.upsert.mock.calls[0][0].update.items).toBe(Prisma.JsonNull); - }); -}); - -describe('DevicesService.sync writes', () => { - const items = [{ productName: 'Cola', quantity: 10, unitPriceInCents: 250 }]; - const jan = { id: 't1', customerName: 'Jan', version: 2, totalInCents: 2500, itemCount: 10, items }; - // JSONB doesn't keep key order - const janSnapshot = { tabId: 't1', customerName: 'Jan', version: 2, totalInCents: 2500, itemCount: 10, items: [{ quantity: 10, productName: 'Cola', unitPriceInCents: 250 }] }; - - it('only bumps the report time of unchanged tabs', async () => { - const { service, tx } = createService({ previous: [janSnapshot] }); - - await service.sync(device, { deviceId: 'd1', openTabs: [jan] }); - - expect(tx.openTabSnapshot.upsert).not.toHaveBeenCalled(); - expect(tx.tabOwnership.upsert).not.toHaveBeenCalled(); - expect(tx.openTabSnapshot.updateMany).toHaveBeenCalledWith({ - where: { deviceId: 'd1', tabId: { in: ['t1'] } }, - data: { reportedAt: expect.any(Date) }, - }); - }); - - it('writes a tab whose lines changed', async () => { - const { service, tx } = createService({ previous: [janSnapshot] }); - - await service.sync(device, { deviceId: 'd1', openTabs: [{ ...jan, items: [{ ...items[0], quantity: 11 }] }] }); - - expect(tx.openTabSnapshot.upsert).toHaveBeenCalledTimes(1); - 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([]); - }); -}); - -describe('DevicesService.sync stock', () => { - const cola = { id: 'p1', name: 'Cola', category: 'Frisdrank', priceInCents: 250, stock: 48 }; - const storedCola = { productId: 'p1', name: 'Cola', category: 'Frisdrank', priceInCents: 250, stock: 48 }; - - it('leaves products alone when the terminal does not report them', async () => { - const { service, prisma, tx } = createService({ products: [storedCola] }); - - await service.sync(device, { deviceId: 'd1', openTabs: [] }); - - expect(prisma.product.findMany).not.toHaveBeenCalled(); - expect(tx.product.deleteMany).not.toHaveBeenCalled(); - }); - - it('only writes changed products, removes missing ones and signals "stock"', async () => { - const storedWater = { productId: 'p2', name: 'Water', category: null, priceInCents: 200, stock: null }; - const { service, tx, realtime } = createService({ products: [storedCola, storedWater] }); - - await service.sync(device, { - deviceId: 'd1', - openTabs: [], - products: [{ ...cola, stock: 46 }, { id: 'p3', name: ' Bier ', priceInCents: 300, stock: null }], - }); - - expect(tx.product.deleteMany).toHaveBeenCalledWith({ where: { venueId: 'v1', productId: { notIn: ['p1', 'p3'] } } }); - expect(tx.product.upsert).toHaveBeenCalledTimes(2); - expect(tx.product.upsert).toHaveBeenCalledWith(expect.objectContaining({ - create: expect.objectContaining({ productId: 'p3', name: 'Bier', category: null, stock: null }), - })); - expect(realtime.notify).toHaveBeenCalledWith('v1', 'stock'); - }); - - it('does not signal anything for an unchanged catalog', async () => { - const { service, tx, realtime } = createService({ products: [storedCola], recordedResults: 0 }); - - await service.sync(device, { deviceId: 'd1', openTabs: [], products: [cola] }); - - expect(tx.product.upsert).not.toHaveBeenCalled(); - expect(realtime.notify).not.toHaveBeenCalled(); - }); - - it('skips an invalid product without failing the sync', async () => { - const { service, tx } = createService(); - - await service.sync(device, { deviceId: 'd1', openTabs: [], products: [cola, { id: 'p9', name: 'Broken', priceInCents: -1, stock: 3 }] }); - - expect(tx.product.deleteMany).toHaveBeenCalledWith({ where: { venueId: 'v1', productId: { notIn: ['p1'] } } }); - }); - - it('records stock adjustment results and acknowledges them', async () => { - const { service, tx, realtime } = createService(); - const id = '5e0b8b4e-1d2c-4f3a-9b8c-7d6e5f4a3b2c'; - - const result = await service.sync(device, { - deviceId: 'd1', - openTabs: [], - stockAdjustmentResults: [{ id, status: 'applied', stockAfter: 72 }, { id: 'nope', status: 'applied' }], - }); - - expect(tx.stockAdjustment.updateMany).toHaveBeenCalledWith({ - where: { id, venueId: 'v1', status: { in: ['pending', 'cancelled'] } }, - data: { status: 'applied', stockAfter: 72, result: null, processedAt: expect.any(Date) }, - }); - expect(result.acknowledgedStockAdjustmentResultIds).toEqual([id]); - expect(result.rejectedStockAdjustmentResults.map((r) => r.id)).toEqual(['nope']); - expect(realtime.notify).toHaveBeenCalledWith('v1', 'stock'); - }); - - it('sends the pending stock adjustments of the venue', async () => { - const pending = [{ id: 'a1', productId: 'p1', type: 'add', quantity: 24, note: null, requestedBy: 'Bram', createdAt: new Date() }]; - const { service, prisma } = createService({ pendingStock: pending }); - - const result = await service.sync(device, { deviceId: 'd1', openTabs: [] }); - - expect(prisma.stockAdjustment.findMany).toHaveBeenCalledWith(expect.objectContaining({ - where: { venueId: 'v1', status: 'pending' }, - orderBy: { createdAt: 'asc' }, - })); - expect(result.stockAdjustments).toEqual(pending); - }); -}); diff --git a/apps/api/src/devices/devices.service.ts b/apps/api/src/devices/devices.service.ts index 39777ff..cd168c6 100644 --- a/apps/api/src/devices/devices.service.ts +++ b/apps/api/src/devices/devices.service.ts @@ -11,11 +11,14 @@ import { AuditService } from '../audit/audit.service'; import { RealtimeService, RealtimeTopic } from '../realtime/realtime.service'; import { DEVICE_ONLINE_THRESHOLD_MS } from '@kooltap/shared'; import { Prisma } from '@prisma/client'; -import { OpenTabDto, OpenTabItemDto, SyncDto } from './dto/sync.dto'; -import { customerNameKey } from '../planned-tabs/customer-name'; +import { SyncDto } from './dto/sync.dto'; import { AckCommandDto } from './dto/ack-command.dto'; -import { parseTabEvents, tabEventRow } from './tab-events'; +import { eventTime, parseTabEvents, tabEventRow } from './tab-events'; import { ParsedProduct, parseProducts, parseStockResults } from './stock-sync'; +import { VenueLedger } from '../tabs/venue-ledger'; +import { seedTabs, tabJson } from '../tabs/tab-state'; +import { openPlannedTabs, settlementsAfter } from '../tabs/tab-sync'; +import { applyPendingAdjustments } from '../stock/stock-adjustments'; @Injectable() export class DevicesService { @@ -36,11 +39,12 @@ export class DevicesService { } const now = new Date(); + const venueId: string = device.venueId; 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}`); } - // Omitted by app versions without stock: the products are then left as they are + // Omitted by app versions without stock const reported = dto.products ? parseProducts(dto.products) : null; for (const { id, reason } of reported?.rejected ?? []) { this.logger.warn(`Device ${device.id} sent an invalid product ${id ?? '(no id)'}: ${reason}`); @@ -49,158 +53,46 @@ export class DevicesService { for (const { id, reason } of stockResults.rejected) { this.logger.warn(`Device ${device.id} sent an invalid stock adjustment result ${id ?? '(no id)'}: ${reason}`); } - const openTabs = dto.openTabs || []; - const openTabIds = openTabs.map((t) => t.id); - // Devices sync often, so dashboards are only told about what actually changed - const previous = await this.prisma.openTabSnapshot.findMany({ - where: { deviceId: device.id }, - select: { tabId: true, customerName: true, version: true, totalInCents: true, itemCount: true, items: true }, - }); - const previousById = new Map(previous.map((s) => [s.tabId, s])); + const synced = await this.prisma.$transaction(async (tx) => { + // Locks the venue: other terminals' syncs wait, so no change is lost + const ledger = await VenueLedger.open(tx, venueId); - // Ownership changes are detected up front so audit logging stays outside the transaction - const takenOver = await this.prisma.tabOwnership.findMany({ - where: { venueId: device.venueId, tabId: { in: openTabIds }, deviceId: { not: device.id } }, - }); - const takenOverIds = new Set(takenOver.map((o) => o.tabId)); + // 1. The catalog: names and prices only, the stock is the server's + const productsChanged = reported ? await this.saveProducts(tx, ledger, reported.products, now) : false; - // Only new or changed tabs are written; unchanged ones just get their report time bumped - const changedTabs = openTabs.filter((t) => { - const p = previousById.get(t.id); - return !p || takenOverIds.has(t.id) || p.customerName !== t.customerName || p.version !== t.version - || p.totalInCents !== t.totalInCents || p.itemCount !== t.itemCount - || !sameItems(p.items as StoredItem[] | null, t.items); - }); - const changedIds = new Set(changedTabs.map((t) => t.id)); - const unchangedIds = openTabIds.filter((id) => !changedIds.has(id)); - const tabsChanged = previous.length !== openTabs.length || changedTabs.length > 0; - let plannedDelivered = 0; - - // Products belong to the venue rather than the device, so they survive pairing a new terminal - const previousProducts = reported - ? await this.prisma.product.findMany({ - where: { venueId: device.venueId }, - select: { productId: true, name: true, category: true, priceInCents: true, stock: true }, - }) - : []; - const previousProductsById = new Map(previousProducts.map((p) => [p.productId, p])); - const changedProducts = (reported?.products ?? []).filter((p) => { - const stored = previousProductsById.get(p.id); - return !stored || !sameProduct(stored, p); - }); - const productsChanged = reported !== null - && (previousProducts.length !== reported.products.length || changedProducts.length > 0); - let stockResultsRecorded = 0; - - for (const ownership of takenOver) { - await this.auditService.log( - device.venueId, - 'device', - device.id, - 'snapshot_conflict', - { tabId: ownership.tabId, previousDevice: ownership.deviceId, newDevice: device.id }, - ); - } - - await this.prisma.$transaction(async (tx) => { - // The device reports its full set of open tabs: anything it no longer reports has been closed - await tx.tabOwnership.deleteMany({ - where: { deviceId: device.id, tabId: { notIn: openTabIds } }, + // 2. Events, each applied once: a terminal resends an event until its acknowledgement arrives + const unique = [...new Map(events.accepted.map((event) => [event.id, event])).values()]; + const stored = unique.length === 0 ? [] : await tx.tabEvent.findMany({ + where: { id: { in: unique.map((event) => event.id) } }, + select: { id: true }, }); - await tx.openTabSnapshot.deleteMany({ - where: { deviceId: device.id, tabId: { notIn: openTabIds } }, - }); - - if (unchangedIds.length > 0) { - await tx.openTabSnapshot.updateMany({ - where: { deviceId: device.id, tabId: { in: unchangedIds } }, - data: { reportedAt: now }, - }); - await tx.tabOwnership.updateMany({ - where: { venueId: device.venueId, deviceId: device.id, tabId: { in: unchangedIds } }, - data: { lastReportedAt: now }, - }); + const storedIds = new Set(stored.map((event) => event.id)); + const fresh = unique.filter((event) => !storedIds.has(event.id)); + if (fresh.length > 0) { + await tx.tabEvent.createMany({ data: fresh.map((event) => tabEventRow(event, venueId, device.id, now)) }); } - for (const tab of changedTabs) { - await tx.openTabSnapshot.upsert({ - where: { - deviceId_tabId: { deviceId: device.id, tabId: tab.id }, - }, - create: { - deviceId: device.id, - tabId: tab.id, - customerName: tab.customerName, - version: tab.version, - totalInCents: tab.totalInCents, - itemCount: tab.itemCount, - items: snapshotItems(tab), - reportedAt: now, - }, - update: { - customerName: tab.customerName, - version: tab.version, - totalInCents: tab.totalInCents, - itemCount: tab.itemCount, - items: snapshotItems(tab), - reportedAt: now, - }, - }); - - await tx.tabOwnership.upsert({ - where: { venueId_tabId: { venueId: device.venueId, tabId: tab.id } }, - create: { - tabId: tab.id, - venueId: device.venueId, - deviceId: device.id, - lastReportedAt: now, - }, - update: { - deviceId: device.id, - lastReportedAt: now, - }, - }); - } - - if (openTabs.length > 0) { - ({ count: plannedDelivered } = await tx.plannedTab.updateMany({ - where: { - venueId: device.venueId, - deliveredAt: null, - customerNameKey: { in: openTabs.map((t) => customerNameKey(t.customerName)) }, - }, - data: { deliveredAt: now }, - })); - } - - if (reported) { - const reportedIds = reported.products.map((p) => p.id); - await tx.product.deleteMany({ where: { venueId: device.venueId, productId: { notIn: reportedIds } } }); - await tx.product.updateMany({ - where: { venueId: device.venueId, productId: { in: reportedIds } }, - data: { reportedAt: now }, - }); - for (const product of changedProducts) { - const data = { - name: product.name, - category: product.category, - priceInCents: product.priceInCents, - stock: product.stock, - reportedAt: now, - }; - await tx.product.upsert({ - where: { venueId_productId: { venueId: device.venueId, productId: product.id } }, - create: { venueId: device.venueId, productId: product.id, ...data }, - update: data, - }); + if (!ledger.seeded) { + // The first sync since the server holds the tabs and stock: the terminal's own tabs and stock already + // include the events of this request, so they are stored but not applied again + ledger.seed(seedTabs(dto.openTabs ?? [], now)); + for (const product of reported?.products ?? []) ledger.stock.seed(product.id, product.stock); + } else { + for (const event of fresh) { + const occurredAt = eventTime(event, now); + const settlement = ledger.tabs.apply(event, occurredAt); + if (settlement) ledger.settlements.push({ ...settlement, deviceId: device.id }); + ledger.stock.apply(event, occurredAt); } } - // The terminal's outcome is the truth, even for an adjustment cancelled while it was on its way + // 3. Outcomes from app versions that applied dashboard adjustments themselves. The terminal's outcome is the + // truth, even for an adjustment cancelled while it was on its way + let stockResultsRecorded = 0; for (const result of stockResults.accepted) { const { count } = await tx.stockAdjustment.updateMany({ - where: { id: result.id, venueId: device.venueId, status: { in: ['pending', 'cancelled'] } }, + where: { id: result.id, venueId, status: { in: ['pending', 'cancelled'] } }, data: { status: result.status, stockAfter: result.status === 'applied' ? result.stockAfter : null, @@ -210,42 +102,39 @@ export class DevicesService { }); stockResultsRecorded += count; } + // Requested before the server applied adjustments itself, and never applied by a terminal + const adjustmentsApplied = await applyPendingAdjustments(tx, ledger, now); - if (events.accepted.length > 0) { - await tx.tabEvent.createMany({ - data: events.accepted.map((event) => tabEventRow(event, device.venueId, device.id, now)), - skipDuplicates: true, - }); - } + // 4. Planned customers become tabs here, once, instead of on every terminal + const plannedDelivered = await openPlannedTabs(tx, ledger, now); - await tx.device.update({ - where: { id: device.id }, - data: { lastSeenAt: now }, - }); + await ledger.save(tx, now); + await tx.device.update({ where: { id: device.id }, data: { lastSeenAt: now } }); + + const feed = await settlementsAfter(tx, venueId, dto.settlementsCursor, now); + + return { + ledger, + feed, + topics: { + tabs: ledger.changedTabs, + plannedTabs: plannedDelivered > 0, + stock: productsChanged || ledger.stock.changed.size > 0 || stockResultsRecorded > 0 || adjustmentsApplied > 0, + }, + }; }, { timeout: 15000 }); // Expire old commands const expired = await this.prisma.remoteCommand.updateMany({ - where: { - deviceId: device.id, - status: 'pending', - expiresAt: { lte: now }, - }, + where: { deviceId: device.id, status: 'pending', expiresAt: { lte: now } }, data: { status: 'expired' }, }); - // The cursor is kept for client compatibility but no longer filters commands: every pending - // command is returned on each sync, so a command committed while a sync was in flight can never - // be skipped. Devices dedupe by command id and re-ack commands they already processed. + // close_tab commands are no longer created (the server settles tabs itself); ones from before that are + // still delivered. Every pending command is returned on each sync; devices dedupe by command id const cursor = Buffer.from(`${now.toISOString()}:${uuidv4().substring(0, 8)}`).toString('base64url'); - const commandWhere = { - deviceId: device.id, - status: 'pending', - }; - - // Find pending commands for this device const commands = await this.prisma.remoteCommand.findMany({ - where: commandWhere, + where: { deviceId: device.id, status: 'pending' }, select: { id: true, type: true, @@ -254,35 +143,20 @@ export class DevicesService { paymentMethod: true, requestedBy: true, createdAt: true, - status: true, }, orderBy: { createdAt: 'asc' }, }); - // Only customers the terminal has not opened a tab for yet - const plannedTabs = await this.prisma.plannedTab.findMany({ - where: { venueId: device.venueId, deliveredAt: null }, - orderBy: { position: 'asc' }, - select: { customerName: true }, - }); - - // Sent on every sync until the terminal reports an outcome; the terminal dedupes by id - const stockAdjustments = await this.prisma.stockAdjustment.findMany({ - where: { venueId: device.venueId, status: 'pending' }, - orderBy: { createdAt: 'asc' }, - take: 500, - select: { id: true, productId: true, type: true, quantity: true, note: true, requestedBy: true, createdAt: true }, - }); - const topics: RealtimeTopic[] = []; - if (tabsChanged) topics.push('tabs'); - if (plannedDelivered > 0) topics.push('planned-tabs'); + if (synced.topics.tabs) topics.push('tabs'); + if (synced.topics.plannedTabs) topics.push('planned-tabs'); if (expired.count > 0) topics.push('commands'); - if (productsChanged || stockResultsRecorded > 0) topics.push('stock'); + if (synced.topics.stock) topics.push('stock'); // Only when the device comes back online; dashboards poll the device status themselves if (now.getTime() - new Date(device.lastSeenAt).getTime() > DEVICE_ONLINE_THRESHOLD_MS) topics.push('device'); - if (topics.length > 0) this.realtime.notify(device.venueId, ...topics); + if (topics.length > 0) this.realtime.notify(venueId, ...topics); + const { ledger, feed } = synced; return { cursor, commands: commands.map((c) => ({ @@ -294,19 +168,68 @@ export class DevicesService { requestedBy: c.requestedBy, createdAt: c.createdAt, })), - plannedTabNames: plannedTabs.map((t) => t.customerName), - // Stored (or already stored earlier): the terminal can drop these from its outbox + // The server opens planned tabs itself; terminals must not open them as well + plannedTabNames: [], + // Applied (or already applied 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 + // Invalid and never applied: the terminal should log and drop these too, re-sending won't help rejectedEvents: events.rejected, - stockAdjustments, + // The server applies dashboard adjustments itself + stockAdjustments: [], // Recorded (or already recorded earlier): the terminal can drop these results from its outbox acknowledgedStockAdjustmentResultIds: stockResults.accepted.map((r) => r.id), // Invalid and never recorded: drop these too, re-sending won't help rejectedStockAdjustmentResults: stockResults.rejected, + // The venue's complete state after this request's events: the terminal replaces its own with it + tabs: ledger.tabs.all().map(tabJson), + stock: ledger.stock.entries().map(([productId, { stock, countedAt }]) => ( + { productId, stock, countedAt: countedAt?.toISOString() ?? null })), + settlements: feed.settlements, + settlementsCursor: feed.settlementsCursor, }; } + /** + * Upserts the reported products without their stock, and never removes one: another terminal may still sell it. + * Returns whether anything the dashboard shows changed. + */ + private async saveProducts(tx: Prisma.TransactionClient, ledger: VenueLedger, products: ParsedProduct[], now: Date) { + const stored = await tx.product.findMany({ + where: { venueId: ledger.venueId }, + select: { productId: true, name: true, category: true, priceInCents: true }, + }); + const storedById = new Map(stored.map((p) => [p.productId, p])); + + let changed = false; + const unchanged: string[] = []; + for (const product of products) { + const previous = storedById.get(product.id); + if (previous && previous.name === product.name && previous.category === product.category + && previous.priceInCents === product.priceInCents) { + unchanged.push(product.id); + continue; + } + + const data = { name: product.name, category: product.category, priceInCents: product.priceInCents, reportedAt: now }; + await tx.product.upsert({ + where: { venueId_productId: { venueId: ledger.venueId, productId: product.id } }, + // Not tracked until counted: its starting stock arrives as a stock_set event + create: { venueId: ledger.venueId, productId: product.id, ...data, stock: null }, + update: data, + }); + ledger.stock.addProduct(product.id); + changed = true; + } + if (unchanged.length > 0) { + await tx.product.updateMany({ + where: { venueId: ledger.venueId, productId: { in: unchanged } }, + data: { reportedAt: now }, + }); + } + + return changed; + } + async ackCommand(device: any, commandId: string, dto: AckCommandDto) { if (!device) { throw new NotFoundException({ code: 'device_not_found', message: 'Device not found' }); @@ -390,29 +313,3 @@ export class DevicesService { return { ok: true }; } } - -type StoredProduct = { name: string; category: string | null; priceInCents: number; stock: number | null }; - -function sameProduct(stored: StoredProduct, reported: ParsedProduct): boolean { - return stored.name === reported.name && stored.category === reported.category - && stored.priceInCents === reported.priceInCents && stored.stock === reported.stock; -} - -type StoredItem = { productName: string; quantity: number; unitPriceInCents: number }; - -function sameItems(stored: StoredItem[] | null, reported: OpenTabItemDto[] | undefined): boolean { - if (!stored || !reported) return !stored && !reported; - return stored.length === reported.length && stored.every((s, i) => - s.productName === reported[i].productName - && s.quantity === reported[i].quantity - && s.unitPriceInCents === reported[i].unitPriceInCents); -} - -function storedItems(items: OpenTabItemDto[]): StoredItem[] { - return items.map(({ productName, quantity, unitPriceInCents }) => ({ productName, quantity, unitPriceInCents })); -} - -/** The tab's lines in the stored shape, or JSON null when the terminal didn't report them. */ -function snapshotItems(tab: OpenTabDto) { - return tab.items ? storedItems(tab.items) : Prisma.JsonNull; -} diff --git a/apps/api/src/devices/dto/sync.dto.ts b/apps/api/src/devices/dto/sync.dto.ts index aeead5a..ed81be5 100644 --- a/apps/api/src/devices/dto/sync.dto.ts +++ b/apps/api/src/devices/dto/sync.dto.ts @@ -64,6 +64,13 @@ export class SyncDto { @IsString() cursor?: string; + @ApiPropertyOptional({ description: 'Where the settlements feed left off; absent on the first sync after pairing' }) + @IsOptional() + @IsString() + @MaxLength(100) + settlementsCursor?: string; + + // The server holds the tabs: only used once, to take over the terminal's tabs on its first sync @ApiProperty({ type: [OpenTabDto] }) @IsArray() @ArrayMaxSize(1000) diff --git a/apps/api/src/devices/dto/tab-event.dto.ts b/apps/api/src/devices/dto/tab-event.dto.ts index b86181d..6cdfaba 100644 --- a/apps/api/src/devices/dto/tab-event.dto.ts +++ b/apps/api/src/devices/dto/tab-event.dto.ts @@ -6,13 +6,25 @@ 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 const TAB_EVENT_TYPES = [ + 'tab_opened', 'tab_renamed', 'tab_deleted', 'line_added', 'line_removed', 'settled', 'stock_set', +] 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 isLine = (event: TabEventDto) => event.type === 'line_added' || event.type === 'line_removed'; const isSettlement = (event: TabEventDto) => event.type === 'settled'; +const isStockSet = (event: TabEventDto) => event.type === 'stock_set'; +const isAboutTab = (event: TabEventDto) => !isStockSet(event); + +export class SettledItemDto extends OpenTabItemDto { + @ApiPropertyOptional({ example: '12', description: 'Missing in events from older app versions' }) + @IsOptional() + @IsString() + @MaxLength(100) + productId?: string; +} export class TabEventDto { @ApiProperty({ description: 'Generated by the terminal; re-sending the same id stores the event once' }) @@ -23,20 +35,41 @@ export class TabEventDto { @IsIn(TAB_EVENT_TYPES) type: TabEventType; - @ApiProperty({ example: 'local-tab-uuid' }) + @ApiPropertyOptional({ example: 'local-tab-uuid', description: 'Every type except stock_set' }) + @ValidateIf(isAboutTab) @IsString() @IsNotEmpty() - tabId: string; + @MaxLength(100) + tabId?: string; - @ApiProperty({ example: 'Jan' }) + @ApiPropertyOptional({ example: 'Jan', description: 'Every type except stock_set; tab_renamed: the new name' }) + @ValidateIf(isAboutTab) @IsString() @MaxLength(200) - customerName: string; + customerName?: string; @ApiProperty({ example: '2026-10-24T21:41:03.120Z', description: 'When it happened on the terminal' }) @IsISO8601({ strict: true }) occurredAt: string; + @ApiPropertyOptional({ example: '2026-10-24T20:00:00.000Z', description: 'tab_opened' }) + @IsOptional() + @IsISO8601({ strict: true }) + openedAt?: string; + + @ApiPropertyOptional({ description: "line_added / line_removed: the terminal's line; missing from older app versions" }) + @IsOptional() + @IsString() + @MaxLength(100) + lineId?: string; + + @ApiPropertyOptional({ example: '12', description: 'line_added / line_removed (missing from older app versions), stock_set' }) + @ValidateIf((event: TabEventDto) => isStockSet(event) || event.productId !== undefined) + @IsString() + @IsNotEmpty() + @MaxLength(100) + productId?: string; + @ApiPropertyOptional({ example: 'Cola', description: 'line_added / line_removed' }) @ValidateIf(isLine) @IsString() @@ -50,11 +83,15 @@ export class TabEventDto { @Min(0) unitPriceInCents?: number; - @ApiPropertyOptional({ example: 2, description: 'line_added / line_removed: how many were added or removed, always positive' }) - @ValidateIf(isLine) + @ApiPropertyOptional({ + example: 2, + description: 'line_added / line_removed: how many were added or removed, at least 1. ' + + 'stock_set: the counted stock (≥ 0), null to stop tracking', + }) + @ValidateIf((event: TabEventDto) => isLine(event) || (isStockSet(event) && event.quantity !== null)) @IsInt() - @Min(1) - quantity?: number; + @Min(0) + quantity?: number | null; @ApiPropertyOptional({ example: 2450, description: 'settled: the amount that was settled' }) @ValidateIf(isSettlement) @@ -72,11 +109,11 @@ export class TabEventDto { @IsUUID() commandId?: string; - @ApiPropertyOptional({ type: [OpenTabItemDto], description: 'settled: the lines that were settled' }) + @ApiPropertyOptional({ type: [SettledItemDto], description: 'settled: the lines that were settled' }) @IsOptional() @IsArray() @ArrayMaxSize(500) @ValidateNested({ each: true }) - @Type(() => OpenTabItemDto) - items?: OpenTabItemDto[]; + @Type(() => SettledItemDto) + items?: SettledItemDto[]; } diff --git a/apps/api/src/devices/tab-events.spec.ts b/apps/api/src/devices/tab-events.spec.ts index 8983d6d..ebb0c29 100644 --- a/apps/api/src/devices/tab-events.spec.ts +++ b/apps/api/src/devices/tab-events.spec.ts @@ -33,6 +33,39 @@ describe('parseTabEvents', () => { expect(rejected).toEqual([]); }); + it('accepts tab and stock events, and a stock count of zero', () => { + const base = { occurredAt: '2026-10-24T21:00:00.000Z' }; + const { accepted, rejected } = parseTabEvents([ + { ...base, id: '1b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e', type: 'tab_opened', tabId: 't1', customerName: 'Jan', openedAt: base.occurredAt }, + { ...base, id: '2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f', type: 'tab_renamed', tabId: 't1', customerName: 'Jan P.' }, + { ...base, id: '3d4e5f6a-7b8c-4d9e-8f1a-2b3c4d5e6f7a', type: 'stock_set', productId: '12', quantity: 0 }, + { ...base, id: '4e5f6a7b-8c9d-4e0f-9a2b-3c4d5e6f7a8b', type: 'stock_set', productId: '12', quantity: null }, + ]); + + expect(rejected).toEqual([]); + expect(accepted.map((e) => e.type)).toEqual(['tab_opened', 'tab_renamed', 'stock_set', 'stock_set']); + }); + + it('rejects a stock count without a product or below zero, and a tab event without a tab', () => { + const base = { occurredAt: '2026-10-24T21:00:00.000Z' }; + const { accepted, rejected } = parseTabEvents([ + { ...base, id: '5f6a7b8c-9d0e-4f1a-8b3c-4d5e6f7a8b9c', type: 'stock_set', quantity: 3 }, + { ...base, id: '6a7b8c9d-0e1f-4a2b-9c4d-5e6f7a8b9c0d', type: 'stock_set', productId: '12', quantity: -1 }, + { ...base, id: '7b8c9d0e-1f2a-4b3c-8d5e-6f7a8b9c0d1e', type: 'tab_opened', customerName: 'Jan' }, + ]); + + expect(accepted).toEqual([]); + expect(rejected).toHaveLength(3); + }); + + it('stores a stock count without a tab', () => { + const [event] = parseTabEvents([ + { id: '8c9d0e1f-2a3b-4c4d-9e6f-7a8b9c0d1e2f', type: 'stock_set', productId: '12', quantity: 30, occurredAt: '2026-10-24T21:00:00.000Z' }, + ]).accepted; + + expect(tabEventRow(event, 'v1', 'd1', now)).toMatchObject({ type: 'stock_set', tabId: null, productId: '12', stockSetTo: 30 }); + }); + it('rejects invalid events one by one, keeping their id', () => { const { accepted, rejected } = parseTabEvents([ lineAdded, @@ -87,7 +120,7 @@ describe('tabEventRow', () => { 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.items).toEqual([{ productId: null, productName: 'Cola', quantity: 2, unitPriceInCents: 250 }]); expect(row.quantityDelta).toBeUndefined(); }); diff --git a/apps/api/src/devices/tab-events.ts b/apps/api/src/devices/tab-events.ts index 611aa88..0380df4 100644 --- a/apps/api/src/devices/tab-events.ts +++ b/apps/api/src/devices/tab-events.ts @@ -25,25 +25,42 @@ export function parseTabEvents(raw: unknown[]): { accepted: TabEventDto[]; rejec rejected.push({ id, reason: describe(errors) }); continue; } + // Validated here: 0 is a valid stock count, but not a valid number of drinks + if ((event.type === 'line_added' || event.type === 'line_removed') && event.quantity! < 1) { + rejected.push({ id, reason: 'quantity must not be less than 1' }); + continue; + } accepted.push(event); } return { accepted, rejected }; } -export function tabEventRow(event: TabEventDto, venueId: string, deviceId: string, now: Date): Prisma.TabEventCreateManyInput { +/** When the event happened, as stored and applied: a terminal clock running ahead is capped at [now]. */ +export function eventTime(event: TabEventDto, now: Date): Date { const occurredAt = new Date(event.occurredAt); + return occurredAt.getTime() > now.getTime() + MAX_CLOCK_AHEAD_MS ? now : occurredAt; +} + +export function tabEventRow(event: TabEventDto, venueId: string, deviceId: string, now: Date): Prisma.TabEventCreateManyInput { const base = { id: event.id, venueId, deviceId, - tabId: event.tabId, - customerName: event.customerName, + tabId: event.tabId ?? null, + customerName: event.customerName ?? null, type: event.type, - occurredAt: occurredAt.getTime() > now.getTime() + MAX_CLOCK_AHEAD_MS ? now : occurredAt, + occurredAt: eventTime(event, now), receivedAt: now, }; + if (event.type === 'stock_set') { + return { ...base, productId: event.productId, stockSetTo: event.quantity ?? null }; + } + if (event.type === 'tab_opened' || event.type === 'tab_renamed' || event.type === 'tab_deleted') { + return base; + } + if (event.type === 'settled') { return { ...base, @@ -51,13 +68,16 @@ export function tabEventRow(event: TabEventDto, venueId: string, deviceId: strin paymentMethod: event.paymentMethod, commandId: event.commandId ?? null, items: event.items - ? event.items.map(({ productName, quantity, unitPriceInCents }) => ({ productName, quantity, unitPriceInCents })) + ? event.items.map(({ productId, productName, quantity, unitPriceInCents }) => ( + { productId: productId ?? null, productName, quantity, unitPriceInCents })) : Prisma.JsonNull, }; } return { ...base, + lineId: event.lineId ?? null, + productId: event.productId ?? null, productName: event.productName, unitPriceInCents: event.unitPriceInCents, quantityDelta: event.type === 'line_removed' ? -event.quantity! : event.quantity, diff --git a/apps/api/src/reminders/reminders.service.spec.ts b/apps/api/src/reminders/reminders.service.spec.ts index 72cae55..6d68003 100644 --- a/apps/api/src/reminders/reminders.service.spec.ts +++ b/apps/api/src/reminders/reminders.service.spec.ts @@ -4,12 +4,30 @@ import { RemindersService } from './reminders.service'; type SentRow = { customerName: string; _count: { _all: number }; _max: { sentAt: Date | null } }; -function createService(items: unknown, { plannedName = 'Bram', sent = [] as SentRow[] } = {}) { +type Item = { productName: string; quantity: number; unitPriceInCents: number }; + +/** € 29,00 for 12 drinks. */ +const defaultItems: Item[] = [ + { productName: 'Cola', quantity: 10, unitPriceInCents: 250 }, + { productName: 'Chips', quantity: 2, unitPriceInCents: 200 }, +]; + +/** The tab's lines as the server holds them: one unit per drink. */ +function lines(items: Item[]) { + return items.map((item, i) => ({ + id: `line-${i}`, + productId: null, + productName: item.productName, + unitPriceInCents: item.unitPriceInCents, + units: Array.from({ length: item.quantity }, (_, n) => ({ id: `u${i}:${n}`, addedAt: '2026-09-24T20:00:00.000Z', removedAt: null })), + })); +} + +function createService(items: Item[] | null, { plannedName = 'Bram', sent = [] as SentRow[] } = {}) { const prisma = { - tabOwnership: { findMany: jest.fn().mockResolvedValue([{ tabId: 't1', deviceId: 'd1' }]) }, - openTabSnapshot: { + tab: { findMany: jest.fn().mockResolvedValue([ - { tabId: 't1', deviceId: 'd1', customerName: 'Bram', totalInCents: 2900, itemCount: 12, items }, + { id: 't1', customerName: 'Bram', version: 3, lines: lines(items ?? defaultItems), updatedAt: new Date() }, ]), }, plannedTab: { findMany: jest.fn().mockResolvedValue([{ customerName: plannedName, email: 'bram@example.com' }]) }, @@ -39,10 +57,7 @@ describe('RemindersService.sendReminders', () => { }); it('includes the tab summary with Belgian amounts', async () => { - const { service } = createService([ - { productName: 'Cola', quantity: 10, unitPriceInCents: 250 }, - { productName: 'Chips', quantity: 2, unitPriceInCents: 200 }, - ]); + const { service } = createService(defaultItems); const result = await service.sendReminders('v1'); @@ -55,16 +70,6 @@ describe('RemindersService.sendReminders', () => { expect(email.subject).toContain('De Kroon'); }); - it('leaves the summary out when the terminal did not report lines', async () => { - const { service } = createService(null); - - await service.sendReminders('v1'); - - const [email] = requests[0].body; - expect(email.text).not.toContain('10x'); - expect(email.text).toMatch(/€\s29,00/); - }); - it('sends one batch request with an idempotency key and records the reminders', async () => { const { service, prisma } = createService(null); diff --git a/apps/api/src/reminders/reminders.service.ts b/apps/api/src/reminders/reminders.service.ts index 8ac4c35..e387fae 100644 --- a/apps/api/src/reminders/reminders.service.ts +++ b/apps/api/src/reminders/reminders.service.ts @@ -4,6 +4,7 @@ import { PrismaService } from '../prisma/prisma.service'; import { RealtimeService } from '../realtime/realtime.service'; import { EmailTemplateError, EmailTemplatesService, RenderedEmail } from '../email/email-templates.service'; import { customerNameKey } from '../planned-tabs/customer-name'; +import { tabSummary } from '../tabs/tab-state'; // Tabs stay open per customer (closing only settles them), so a customer is eligible again // once this many days have passed since their last reminder. Admins can still pick them by hand. @@ -48,16 +49,7 @@ export class RemindersService { /** Every customer with an unpaid open tab, with their reminder track record. */ async listCandidates(venueId: string): Promise<{ candidates: Candidate[]; cooldownDays: number }> { - const ownerships = await this.prisma.tabOwnership.findMany({ - where: { venueId }, - }); - - // Only the owning device's snapshot is authoritative for a tab - const snapshots = ownerships.length === 0 ? [] : await this.prisma.openTabSnapshot.findMany({ - where: { - OR: ownerships.map((o) => ({ deviceId: o.deviceId, tabId: o.tabId })), - }, - }); + const tabs = (await this.prisma.tab.findMany({ where: { venueId }, orderBy: { openedAt: 'asc' } })).map(tabSummary); const plannedTabs = await this.prisma.plannedTab.findMany({ where: { venueId, email: { not: null } }, @@ -85,7 +77,7 @@ export class RemindersService { const now = Date.now(); const candidates: Candidate[] = []; - for (const snap of snapshots) { + for (const snap of tabs) { if (snap.totalInCents <= 0) continue; const key = customerNameKey(snap.customerName); const email = emailByKey.get(key) ?? null; @@ -97,8 +89,7 @@ export class RemindersService { email, amountInCents: snap.totalInCents, itemCount: snap.itemCount, - // Null when the terminal's app version doesn't report lines - items: (snap.items as TabLine[] | null) ?? null, + items: snap.items, reminderCount: sent?.count ?? 0, lastReminderAt: sent?.last ?? null, nextEligibleAt, diff --git a/apps/api/src/stock/stock-adjustments.ts b/apps/api/src/stock/stock-adjustments.ts new file mode 100644 index 0000000..09a4339 --- /dev/null +++ b/apps/api/src/stock/stock-adjustments.ts @@ -0,0 +1,38 @@ +import { Prisma } from '@prisma/client'; +import { VenueLedger } from '../tabs/venue-ledger'; + +type Tx = Prisma.TransactionClient; + +/** + * Applies the venue's pending dashboard adjustments (or only [ids]) to the stock, oldest first, and records the + * outcome. Returns how many were handled. + */ +export async function applyPendingAdjustments(tx: Tx, ledger: VenueLedger, now: Date, ids?: string[]): Promise { + const pending = await tx.stockAdjustment.findMany({ + where: { venueId: ledger.venueId, status: 'pending', ...(ids ? { id: { in: ids } } : {}) }, + orderBy: { createdAt: 'asc' }, + }); + // Created in one go share their time: keep the order they were entered in + if (ids) pending.sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id)); + + for (const adjustment of pending) { + let stockAfter: number | null; + if (adjustment.type === 'set') { + // A count on the dashboard is the latest count, also when a terminal's clock runs a little ahead + ledger.stock.count(adjustment.productId, adjustment.quantity, now, { force: true }); + stockAfter = ledger.stock.get(adjustment.productId) ? adjustment.quantity : null; + } else { + stockAfter = ledger.stock.add(adjustment.productId, adjustment.quantity); + } + + const rejected = stockAfter === null + ? (ledger.stock.get(adjustment.productId) ? 'stock_not_tracked' : 'unknown_product') + : null; + await tx.stockAdjustment.update({ + where: { id: adjustment.id }, + data: { status: rejected ? 'rejected' : 'applied', stockAfter, result: rejected, processedAt: now }, + }); + } + + return pending.length; +} diff --git a/apps/api/src/stock/stock-rules.spec.ts b/apps/api/src/stock/stock-rules.spec.ts new file mode 100644 index 0000000..00c7dad --- /dev/null +++ b/apps/api/src/stock/stock-rules.spec.ts @@ -0,0 +1,67 @@ +import { TabEventDto } from '../devices/dto/tab-event.dto'; +import { VenueStock } from './stock-rules'; + +const at = (minutes: number) => new Date(Date.UTC(2026, 8, 24, 20, minutes)); + +const sale = (type: 'line_added' | 'line_removed', quantity: number, productId = 'cola') => ( + { id: 'e', type, productId, quantity } as TabEventDto); + +function stockOf(stock: number | null, countedAt: Date | null = null) { + return new VenueStock(new Map([['cola', { stock, countedAt }]])); +} + +describe('VenueStock', () => { + it('counts sales down and drinks taken off back up', () => { + const stock = stockOf(10); + + stock.apply(sale('line_added', 3), at(1)); + stock.apply(sale('line_removed', 1), at(2)); + + expect(stock.get('cola')!.stock).toBe(8); + }); + + it('may go negative: a sale is never blocked', () => { + const stock = stockOf(1); + + stock.apply(sale('line_added', 3), at(1)); + + expect(stock.get('cola')!.stock).toBe(-2); + }); + + it('ignores untracked and unknown products, and events without a product', () => { + const stock = stockOf(null); + + stock.apply(sale('line_added', 3), at(1)); + stock.apply(sale('line_added', 3, 'unknown'), at(1)); + stock.apply({ ...sale('line_added', 3), productId: undefined }, at(1)); + + expect(stock.get('cola')!.stock).toBeNull(); + expect(stock.changed.size).toBe(0); + }); + + it('skips sales from before the latest count, which already has them', () => { + const stock = stockOf(30, at(10)); + + stock.apply(sale('line_added', 2), at(10)); + stock.apply(sale('line_added', 2), at(11)); + + expect(stock.get('cola')!.stock).toBe(28); + }); + + it('lets the latest count win, unless forced by the dashboard', () => { + const stock = stockOf(30, at(10)); + + expect(stock.count('cola', 70, at(5))).toBe(false); + expect(stock.count('cola', null, at(12))).toBe(true); + expect(stock.get('cola')).toEqual({ stock: null, countedAt: at(12) }); + + stock.count('cola', 40, at(11), { force: true }); + expect(stock.get('cola')).toEqual({ stock: 40, countedAt: at(12) }); + }); + + it('only adds to a tracked product', () => { + expect(stockOf(10).add('cola', 24)).toBe(34); + expect(stockOf(null).add('cola', 24)).toBeNull(); + expect(stockOf(10).add('unknown', 24)).toBeNull(); + }); +}); diff --git a/apps/api/src/stock/stock-rules.ts b/apps/api/src/stock/stock-rules.ts new file mode 100644 index 0000000..3b3e0bf --- /dev/null +++ b/apps/api/src/stock/stock-rules.ts @@ -0,0 +1,96 @@ +import { TabEventDto } from '../devices/dto/tab-event.dto'; + +/** One product's stock: null when not tracked, may be negative. */ +export type ProductStock = { stock: number | null; countedAt: Date | null }; + +/** + * The venue's stock and the rules that change it. Pure, like the tab state. The terminals replay their unsent events + * with the same rules on top of the stock the sync returns. + */ +export class VenueStock { + private readonly products: Map; + /** Products to write back. */ + readonly changed = new Set(); + + constructor(products: Map) { + this.products = products; + } + + get(productId: string): ProductStock | undefined { + return this.products.get(productId); + } + + entries(): [string, ProductStock][] { + return [...this.products.entries()]; + } + + /** A product a terminal reported for the first time: not tracked until it is counted. */ + addProduct(productId: string) { + if (!this.products.has(productId)) this.products.set(productId, { stock: null, countedAt: null }); + } + + /** Takes over a terminal's own stock, once, when the server starts owning the stock. */ + seed(productId: string, stock: number | null) { + this.products.set(productId, { stock, countedAt: null }); + this.changed.add(productId); + } + + /** + * Applies a tab event that happened at [occurredAt]. Only line events and stock counts change stock; a settlement + * doesn't, the drinks were counted down when they were put on the tab. + */ + apply(event: TabEventDto, occurredAt: Date) { + if (!event.productId) return; + + switch (event.type) { + case 'line_added': + this.sold(event.productId, event.quantity!, occurredAt); + return; + case 'line_removed': + this.sold(event.productId, -event.quantity!, occurredAt); + return; + case 'stock_set': + this.count(event.productId, event.quantity ?? null, occurredAt); + return; + default: + return; + } + } + + /** + * A count: the latest one wins, also when an older one arrives later (a terminal that was offline). [force] applies + * it regardless and makes it the latest. + */ + count(productId: string, stock: number | null, countedAt: Date, { force = false } = {}): boolean { + const product = this.products.get(productId); + if (!product) return false; + if (product.countedAt && countedAt < product.countedAt) { + if (!force) return false; + countedAt = product.countedAt; + } + + this.products.set(productId, { stock, countedAt }); + this.changed.add(productId); + return true; + } + + /** Adds [quantity] (signed) to a tracked product: a delivery or breakage. Returns the new stock. */ + add(productId: string, quantity: number): number | null { + const product = this.products.get(productId); + if (!product || product.stock === null) return null; + + product.stock += quantity; + this.changed.add(productId); + return product.stock; + } + + /** A sale before the latest count is already in that count. */ + private sold(productId: string, quantity: number, occurredAt: Date) { + const product = this.products.get(productId); + if (!product || product.stock === null) return; + if (product.countedAt && occurredAt <= product.countedAt) return; + + product.stock -= quantity; + this.changed.add(productId); + } +} diff --git a/apps/api/src/stock/stock.service.spec.ts b/apps/api/src/stock/stock.service.spec.ts index 1e1b0eb..fde324e 100644 --- a/apps/api/src/stock/stock.service.spec.ts +++ b/apps/api/src/stock/stock.service.spec.ts @@ -1,4 +1,5 @@ import { StockService } from './stock.service'; +import { createFakePrisma } from '../testing/fake-prisma'; const user = { id: 'u1', email: 'bram@example.com' }; @@ -21,19 +22,35 @@ function createService({ products = [] as { productId: string; name: string; sto const cola = { productId: 'p1', name: 'Cola', stock: 10 }; const water = { productId: 'p2', name: 'Water', stock: null }; +function createWithVenue(products: { productId: string; name: string; stock: number | null }[]) { + const prisma = createFakePrisma(); + prisma.venue.rows.push({ id: 'v1', tabsSeededAt: new Date('2026-10-01') }); + for (const p of products) { + prisma.product.rows.push({ venueId: 'v1', ...p, category: null, priceInCents: 250, countedAt: null, reportedAt: new Date() }); + } + const realtime = { notify: jest.fn() }; + const service = new StockService(prisma as any, { log: jest.fn() } as any, realtime as any); + return { service, prisma, realtime }; +} + describe('StockService.createAdjustments', () => { - it('creates one pending adjustment per entry with the product name and requester', async () => { - const { service, prisma, realtime } = createService({ products: [cola, water] }); + it('applies the adjustments at once, in the order they were entered', async () => { + const { service, prisma, realtime } = createWithVenue([cola, water]); const result = await service.createAdjustments('v1', [ { productId: 'p1', type: 'add', quantity: 24, note: 'Delivery' }, { productId: 'p2', type: 'set', quantity: 12 }, + { productId: 'p1', type: 'add', quantity: -4, note: 'Broken' }, ], user); - expect(prisma.stockAdjustment.create).toHaveBeenCalledWith(expect.objectContaining({ - data: expect.objectContaining({ venueId: 'v1', productId: 'p1', productName: 'Cola', type: 'add', quantity: 24, note: 'Delivery', requestedBy: 'bram@example.com' }), - })); - expect(result.adjustments).toHaveLength(2); + expect(result.adjustments).toMatchObject([ + { productId: 'p1', productName: 'Cola', status: 'applied', stockAfter: 34, note: 'Delivery', requestedBy: 'bram@example.com' }, + { productId: 'p2', status: 'applied', stockAfter: 12 }, + { productId: 'p1', status: 'applied', stockAfter: 30 }, + ]); + expect(prisma.product.rows.map((p) => p.stock)).toEqual([30, 12]); + // A count starts tracking and becomes the latest count + expect(prisma.product.rows[1].countedAt).toBeInstanceOf(Date); expect(realtime.notify).toHaveBeenCalledWith('v1', 'stock'); }); diff --git a/apps/api/src/stock/stock.service.ts b/apps/api/src/stock/stock.service.ts index 12b73ac..f7ab677 100644 --- a/apps/api/src/stock/stock.service.ts +++ b/apps/api/src/stock/stock.service.ts @@ -3,6 +3,8 @@ import { PrismaService } from '../prisma/prisma.service'; import { AuditService } from '../audit/audit.service'; import { RealtimeService } from '../realtime/realtime.service'; import { StockAdjustmentEntryDto } from './dto/create-stock-adjustments.dto'; +import { VenueLedger } from '../tabs/venue-ledger'; +import { applyPendingAdjustments } from './stock-adjustments'; const adjustmentSelect = { id: true, @@ -106,22 +108,36 @@ export class StockService { } } + // Applied by the server right away, once, also when every terminal is offline; the terminals get the new stock + // on their next sync const requestedBy = user.name || user.email; - const created = await this.prisma.$transaction( - entries.map((entry) => this.prisma.stockAdjustment.create({ - data: { - venueId, - productId: entry.productId, - productName: productsById.get(entry.productId)!.name, - type: entry.type, - quantity: entry.quantity, - note: entry.note ?? null, - requestedBy, - requestedById: user.id, - }, - select: adjustmentSelect, - })), - ); + const created = await this.prisma.$transaction(async (tx) => { + const now = new Date(); + const ledger = await VenueLedger.open(tx, venueId); + const ids: string[] = []; + for (const entry of entries) { + const { id } = await tx.stockAdjustment.create({ + data: { + venueId, + productId: entry.productId, + productName: productsById.get(entry.productId)!.name, + type: entry.type, + quantity: entry.quantity, + note: entry.note ?? null, + requestedBy, + requestedById: user.id, + createdAt: now, + }, + select: { id: true }, + }); + ids.push(id); + } + await applyPendingAdjustments(tx, ledger, now, ids); + await ledger.save(tx, now); + + const adjustments = await tx.stockAdjustment.findMany({ where: { id: { in: ids } }, select: adjustmentSelect }); + return ids.map((id) => adjustments.find((a) => a.id === id)!); + }); await this.auditService.log(venueId, 'client_user', user.id, 'stock_adjustments_created', { adjustments: created.map((a) => ({ id: a.id, productId: a.productId, type: a.type, quantity: a.quantity })), diff --git a/apps/api/src/tabs/tab-state.spec.ts b/apps/api/src/tabs/tab-state.spec.ts new file mode 100644 index 0000000..fa9494e --- /dev/null +++ b/apps/api/src/tabs/tab-state.spec.ts @@ -0,0 +1,141 @@ +import { TabEventDto } from '../devices/dto/tab-event.dto'; +import { findLine, seedTabs, tabJson, tabSummary, VenueTabs } from './tab-state'; + +const time = new Date('2026-09-24T20:00:00.000Z'); +let counter = 0; + +function event(fields: Partial & { type: TabEventDto['type'] }): TabEventDto { + return { id: `e${++counter}`, tabId: 't1', customerName: 'Jan', occurredAt: time.toISOString(), ...fields } as TabEventDto; +} + +const cola = { productId: 'cola', productName: 'Cola', unitPriceInCents: 250 }; + +function withCola(quantity: number) { + const tabs = new VenueTabs([]); + tabs.apply(event({ type: 'tab_opened' }), time); + tabs.apply(event({ type: 'line_added', lineId: 'line-1', quantity, ...cola }), time); + return tabs; +} + +describe('VenueTabs.apply', () => { + it('ignores a second tab_opened for a tab that exists', () => { + const tabs = withCola(1); + + tabs.apply(event({ type: 'tab_opened', customerName: 'Other' }), time); + + expect(tabs.get('t1')).toMatchObject({ customerName: 'Jan', version: 2 }); + }); + + it('gives a line a new id when its id is taken on another tab', () => { + const tabs = withCola(1); + const added = event({ type: 'line_added', tabId: 't2', lineId: 'line-1', quantity: 1, ...cola }); + + tabs.apply(added, time); + + expect(tabs.get('t2')!.lines[0].id).toBe(`${added.id}:line`); + }); + + it('removes no more drinks than are on the line', () => { + const tabs = withCola(2); + + tabs.apply(event({ type: 'line_removed', lineId: 'line-1', quantity: 5, ...cola }), time); + + expect(tabs.get('t1')!.lines[0].units.every((unit) => unit.removedAt !== null)).toBe(true); + }); + + it('keeps settled lines the server never saw in the settlement, with made-up units', () => { + const tabs = withCola(1); + const settled = event({ + type: 'settled', amountInCents: 650, paymentMethod: 'cash', + items: [ + { productId: 'cola', productName: 'Cola', quantity: 1, unitPriceInCents: 250 }, + { productName: 'Chips', quantity: 2, unitPriceInCents: 200 }, + ], + }); + + const settlement = tabs.apply(settled, time)!; + + expect(settlement.amountInCents).toBe(650); + expect(settlement.lines).toMatchObject([ + { id: 'line-1', quantity: 1 }, + { productId: null, productName: 'Chips', quantity: 2, units: [{ id: `${settled.id}:item:1:0` }, { id: `${settled.id}:item:1:1` }] }, + ]); + expect(tabs.get('t1')!.lines).toEqual([]); + }); + + it('records a settlement of a tab that is gone', () => { + const tabs = new VenueTabs([]); + + const settlement = tabs.apply(event({ + type: 'settled', amountInCents: 250, paymentMethod: 'cash', + items: [{ productName: 'Cola', quantity: 1, unitPriceInCents: 250 }], + }), time); + + expect(settlement).toMatchObject({ tabId: 't1', customerName: 'Jan', lines: [{ quantity: 1 }] }); + expect(tabs.all()).toEqual([]); + }); + + it('changes nothing for events about tabs that are gone', () => { + const tabs = new VenueTabs([]); + + tabs.apply(event({ type: 'tab_renamed' }), time); + tabs.apply(event({ type: 'tab_deleted' }), time); + tabs.apply(event({ type: 'line_removed', quantity: 1, ...cola }), time); + + expect(tabs.changed.size + tabs.deleted.size).toBe(0); + }); +}); + +describe('VenueTabs.settleAll', () => { + it('settles every drink, keeping the tab open and empty', () => { + const tabs = withCola(3); + tabs.apply(event({ type: 'line_removed', lineId: 'line-1', quantity: 1, ...cola }), time); + + const settlement = tabs.settleAll('t1', { id: 's1', settledAt: time, paymentMethod: 'cash' })!; + + expect(settlement).toMatchObject({ amountInCents: 500, lines: [{ quantity: 2 }] }); + expect(settlement.lines[0].units).toHaveLength(3); + expect(tabJson(tabs.get('t1')!)).toMatchObject({ version: 4, lines: [] }); + }); + + it('returns null for an empty tab', () => { + const tabs = new VenueTabs([]); + tabs.apply(event({ type: 'tab_opened' }), time); + + expect(tabs.settleAll('t1', { id: 's1', settledAt: time, paymentMethod: 'cash' })).toBeNull(); + }); +}); + +describe('findLine', () => { + it('prefers the line id, then the product, then the name, at the same price', () => { + const tab = { + id: 't1', customerName: 'Jan', openedAt: time, version: 0, + lines: [ + { id: 'a', productId: null, productName: 'Cola', unitPriceInCents: 250, units: [] }, + { id: 'b', productId: 'cola', productName: 'Coca-Cola', unitPriceInCents: 250, units: [] }, + { id: 'c', productId: 'cola', productName: 'Cola', unitPriceInCents: 300, units: [] }, + ], + }; + + expect(findLine(tab, { lineId: 'c', productId: 'cola', productName: 'Cola', unitPriceInCents: 250 })!.id).toBe('c'); + expect(findLine(tab, { productId: 'cola', productName: 'Cola', unitPriceInCents: 250 })!.id).toBe('b'); + expect(findLine(tab, { productName: 'Cola', unitPriceInCents: 250 })!.id).toBe('a'); + expect(findLine(tab, { productName: 'Cola', unitPriceInCents: 100 })).toBeUndefined(); + }); +}); + +describe('seedTabs and tabSummary', () => { + it("turns a terminal's snapshot into tabs the dashboard sums up the same", () => { + const [seeded] = seedTabs([{ + id: 't1', customerName: 'Jan', version: 7, totalInCents: 900, itemCount: 4, + items: [ + { productName: 'Cola', quantity: 2, unitPriceInCents: 250 }, + { productName: 'Chips', quantity: 2, unitPriceInCents: 200 }, + { productName: 'Gone', quantity: 0, unitPriceInCents: 100 }, + ], + }], time); + + expect(seeded.lines.map((line) => line.units.length)).toEqual([2, 2]); + expect(tabSummary({ ...seeded, updatedAt: time })).toMatchObject({ totalInCents: 900, itemCount: 4, version: 7 }); + }); +}); diff --git a/apps/api/src/tabs/tab-state.ts b/apps/api/src/tabs/tab-state.ts new file mode 100644 index 0000000..235e26e --- /dev/null +++ b/apps/api/src/tabs/tab-state.ts @@ -0,0 +1,301 @@ +import { TabEventDto } from '../devices/dto/tab-event.dto'; +import { OpenTabDto } from '../devices/dto/sync.dto'; + +/** + * The venue's open tabs and the rules that change them. Pure: the ledger loads the state, applies events here and + * stores what changed. The terminals apply the same rules to replay their unsent events, so a unit's id must be + * derived the same way on both sides (`:`). + */ + +/** One piece ever put on a line. */ +export type Unit = { id: string; addedAt: string; removedAt: string | null }; + +export type Line = { + id: string; + productId: string | null; + productName: string; + unitPriceInCents: number; + units: Unit[]; +}; + +export type TabState = { + id: string; + customerName: string; + openedAt: Date; + version: number; + lines: Line[]; +}; + +export type SettledLine = Line & { quantity: number }; + +export type SettlementDraft = { + id: string; + tabId: string; + customerName: string; + settledAt: Date; + paymentMethod: string; + amountInCents: number; + lines: SettledLine[]; + commandId: string | null; +}; + +/** What an event needs to find its line. */ +type LineRef = { lineId?: string; productId?: string | null; productName?: string; unitPriceInCents?: number }; + +export const isActive = (unit: Unit) => unit.removedAt === null; + +export const lineQuantity = (line: Line) => line.units.filter(isActive).length; + +export class VenueTabs { + private readonly tabs: Map; + /** Tabs to write back, and tabs to delete. */ + readonly changed = new Set(); + readonly deleted = new Set(); + + constructor(tabs: TabState[]) { + this.tabs = new Map(tabs.map((tab) => [tab.id, tab])); + } + + get(tabId: string): TabState | undefined { + return this.tabs.get(tabId); + } + + all(): TabState[] { + return [...this.tabs.values()]; + } + + /** + * Applies one event that happened at [occurredAt]. Returns the settlement a `settled` event made. Events about a + * tab or line that is gone change nothing (they are acknowledged all the same). + */ + apply(event: TabEventDto, occurredAt: Date): SettlementDraft | null { + const tabId = event.tabId; + if (!tabId) return null; + const customerName = event.customerName ?? ''; + + switch (event.type) { + case 'tab_opened': + if (!this.tabs.has(tabId)) { + this.open(tabId, customerName, event.openedAt ? new Date(event.openedAt) : occurredAt); + this.touch(tabId); + } + return null; + + case 'tab_renamed': { + const tab = this.tabs.get(tabId); + if (tab) { + tab.customerName = customerName; + this.touch(tabId); + } + return null; + } + + case 'tab_deleted': + if (this.tabs.delete(tabId)) { + this.changed.delete(tabId); + this.deleted.add(tabId); + } + return null; + + case 'line_added': { + // Deleted elsewhere: bring it back, so no sale is lost + const tab = this.tabs.get(tabId) ?? this.open(tabId, customerName, occurredAt); + let line = findLine(tab, event); + if (!line) { + const lineIdTaken = event.lineId && this.all().some((t) => t.lines.some((l) => l.id === event.lineId)); + line = { + id: event.lineId && !lineIdTaken ? event.lineId : `${event.id}:line`, + productId: event.productId ?? null, + productName: event.productName!, + unitPriceInCents: event.unitPriceInCents!, + units: [], + }; + tab.lines.push(line); + } + const addedAt = occurredAt.toISOString(); + for (let index = 0; index < event.quantity!; index++) { + line.units.push({ id: `${event.id}:${index}`, addedAt, removedAt: null }); + } + this.touch(tabId); + return null; + } + + case 'line_removed': { + const tab = this.tabs.get(tabId); + const line = tab && findLine(tab, event); + if (!line) return null; + // The latest ones: the drinks that were just rung up by mistake + const active = line.units.filter(isActive); + for (const unit of active.slice(Math.max(0, active.length - event.quantity!))) { + unit.removedAt = occurredAt.toISOString(); + } + this.touch(tabId); + return null; + } + + case 'settled': { + const tab = this.tabs.get(tabId); + const settledAt = occurredAt; + const lines: SettledLine[] = []; + (event.items ?? []).forEach((item, index) => { + const line = tab && findLine(tab, item); + if (line) { + const settled = settleLine(tab, line, item.quantity); + if (settled.units.length > 0) lines.push(settled); + } else if (item.quantity > 0) { + // Settled on the terminal but unknown here: keep it in the history all the same + lines.push({ + id: `${event.id}:item:${index}`, + productId: item.productId ?? null, + productName: item.productName, + unitPriceInCents: item.unitPriceInCents, + quantity: item.quantity, + units: Array.from({ length: item.quantity }, (_, n) => ( + { id: `${event.id}:item:${index}:${n}`, addedAt: settledAt.toISOString(), removedAt: null })), + }); + } + }); + if (tab) this.touch(tabId); + + return { + id: event.id, + tabId, + customerName: tab?.customerName ?? customerName, + settledAt, + paymentMethod: event.paymentMethod!, + amountInCents: event.amountInCents!, + lines, + commandId: event.commandId ?? null, + }; + } + + default: + return null; + } + } + + /** Settles everything still on the tab, as the dashboard does. Null when there is nothing to settle. */ + settleAll(tabId: string, settlement: { id: string; settledAt: Date; paymentMethod: string }): SettlementDraft | null { + const tab = this.tabs.get(tabId); + if (!tab) return null; + + const lines = tab.lines + .filter((line) => lineQuantity(line) > 0) + .map((line) => settleLine(tab, line, lineQuantity(line))); + if (lines.length === 0) return null; + this.touch(tabId); + + return { + ...settlement, + tabId, + customerName: tab.customerName, + amountInCents: lines.reduce((sum, line) => sum + line.quantity * line.unitPriceInCents, 0), + lines, + commandId: null, + }; + } + + /** Opens a tab without an event: planned customers, and taking over a terminal's tabs. */ + open(tabId: string, customerName: string, openedAt: Date): TabState { + const tab: TabState = { id: tabId, customerName, openedAt, version: 0, lines: [] }; + this.tabs.set(tabId, tab); + this.deleted.delete(tabId); + this.changed.add(tabId); + return tab; + } + + private touch(tabId: string) { + this.tabs.get(tabId)!.version++; + this.changed.add(tabId); + } +} + +/** + * The line with the event's line id; else the oldest line with the same product and price; else the oldest with the + * same name and price (events from app versions without line or product ids). + */ +export function findLine(tab: TabState, ref: LineRef): Line | undefined { + const samePrice = (line: Line) => line.unitPriceInCents === ref.unitPriceInCents; + return (ref.lineId ? tab.lines.find((line) => line.id === ref.lineId) : undefined) + ?? (ref.productId ? tab.lines.find((line) => line.productId === ref.productId && samePrice(line)) : undefined) + ?? tab.lines.find((line) => line.productName === ref.productName && samePrice(line)); +} + +/** + * Takes the [quantity] oldest active units off [line], together with its removed units, and drops the line when + * nothing is left. Drinks added after the settlement (by another terminal) stay on the tab. + */ +function settleLine(tab: TabState, line: Line, quantity: number): SettledLine { + const settled = line.units.filter(isActive).slice(0, quantity); + const removed = line.units.filter((unit) => !isActive(unit)); + const taken = new Set([...settled, ...removed]); + + line.units = line.units.filter((unit) => !taken.has(unit)); + if (line.units.length === 0) tab.lines = tab.lines.filter((l) => l !== line); + + return { + id: line.id, + productId: line.productId, + productName: line.productName, + unitPriceInCents: line.unitPriceInCents, + quantity: settled.length, + units: [...settled, ...removed].sort(byAddedAt), + }; +} + +const byAddedAt = (a: Unit, b: Unit) => a.addedAt.localeCompare(b.addedAt); + +/** + * The terminal's own open tabs, taken over once when the server starts holding the tabs. The snapshot has no line or + * unit ids, so they are made up from the tab id. + */ +export function seedTabs(openTabs: OpenTabDto[], now: Date): TabState[] { + return openTabs.map((tab) => ({ + id: tab.id, + customerName: tab.customerName, + openedAt: now, + version: tab.version, + lines: (tab.items ?? []) + .filter((item) => item.quantity > 0) + .map((item, index) => ({ + id: `seed:${tab.id}:${index}`, + productId: null, + productName: item.productName, + unitPriceInCents: item.unitPriceInCents, + units: Array.from({ length: item.quantity }, (_, n) => ( + { id: `seed:${tab.id}:${index}:${n}`, addedAt: now.toISOString(), removedAt: null })), + })), + })); +} + +/** A tab as sent to terminals and stored: lines carry their quantity. */ +export function tabJson(tab: TabState) { + return { + id: tab.id, + customerName: tab.customerName, + openedAt: tab.openedAt.toISOString(), + version: tab.version, + lines: tab.lines.map(lineJson), + }; +} + +export function lineJson(line: Line) { + return { ...line, quantity: lineQuantity(line) }; +} + +/** A tab as the dashboard shows it: totals, and the lines that are still on it. */ +export function tabSummary(tab: { id: string; customerName: string; version: number; lines: unknown; updatedAt: Date }) { + const lines = (tab.lines as Line[]) + .map((line) => ({ productName: line.productName, quantity: lineQuantity(line), unitPriceInCents: line.unitPriceInCents })) + .filter((line) => line.quantity > 0); + + return { + tabId: tab.id, + customerName: tab.customerName, + version: tab.version, + totalInCents: lines.reduce((sum, line) => sum + line.quantity * line.unitPriceInCents, 0), + itemCount: lines.reduce((sum, line) => sum + line.quantity, 0), + items: lines, + updatedAt: tab.updatedAt, + }; +} diff --git a/apps/api/src/tabs/tab-sync.ts b/apps/api/src/tabs/tab-sync.ts new file mode 100644 index 0000000..93844dc --- /dev/null +++ b/apps/api/src/tabs/tab-sync.ts @@ -0,0 +1,75 @@ +import { Prisma } from '@prisma/client'; +import { v4 as uuidv4 } from 'uuid'; +import { customerNameKey } from '../planned-tabs/customer-name'; +import { VenueLedger } from './venue-ledger'; +import { SettledLine } from './tab-state'; + +type Tx = Prisma.TransactionClient; + +/** Most settlements sent per sync; a longer feed continues in the next sync. */ +export const MAX_SETTLEMENTS_PER_SYNC = 500; + +/** What a terminal without a cursor gets: enough to fill a new terminal's history. */ +const FIRST_FEED_DAYS = 30; + +/** + * Opens a tab for every planned customer that has none yet, and marks them delivered. Returns how many were + * delivered. + */ +export async function openPlannedTabs(tx: Tx, ledger: VenueLedger, now: Date): Promise { + const planned = await tx.plannedTab.findMany({ + where: { venueId: ledger.venueId, deliveredAt: null }, + orderBy: { position: 'asc' }, + select: { id: true, customerName: true, customerNameKey: true }, + }); + if (planned.length === 0) return 0; + + const open = new Set(ledger.tabs.all().map((tab) => customerNameKey(tab.customerName))); + for (const customer of planned) { + if (open.has(customer.customerNameKey)) continue; + ledger.tabs.open(uuidv4(), customer.customerName, now); + open.add(customer.customerNameKey); + } + + const { count } = await tx.plannedTab.updateMany({ + where: { id: { in: planned.map((p) => p.id) } }, + data: { deliveredAt: now }, + }); + return count; +} + +/** + * The venue's settlements after [cursor] (oldest first), or of the last days when there is none. The cursor is the + * last sequence number sent. Settlements are written while the venue is locked, so sequence numbers of one venue + * become visible in order and the feed never skips one. + */ +export async function settlementsAfter(tx: Tx, venueId: string, cursor: string | undefined, now: Date) { + const after = cursor && /^\d+$/.test(cursor) ? Number(cursor) : null; + const rows = await tx.settlement.findMany({ + where: after !== null + ? { venueId, sequence: { gt: after } } + : { venueId, settledAt: { gte: new Date(now.getTime() - FIRST_FEED_DAYS * 24 * 60 * 60 * 1000) } }, + orderBy: { sequence: 'asc' }, + take: MAX_SETTLEMENTS_PER_SYNC, + }); + + let next = rows.at(-1)?.sequence ?? after; + if (next === null) { + // Nothing yet: start after the venue's latest settlement, so the next sync only asks for new ones + const latest = await tx.settlement.aggregate({ where: { venueId }, _max: { sequence: true } }); + next = latest._max.sequence ?? 0; + } + + return { + settlements: rows.map((row) => ({ + id: row.id, + tabId: row.tabId, + customerName: row.customerName, + settledAt: row.settledAt.toISOString(), + paymentMethod: row.paymentMethod, + amountInCents: row.amountInCents, + lines: row.lines as unknown as SettledLine[], + })), + settlementsCursor: String(next), + }; +} diff --git a/apps/api/src/tabs/tabs.service.ts b/apps/api/src/tabs/tabs.service.ts index 9587eee..9025331 100644 --- a/apps/api/src/tabs/tabs.service.ts +++ b/apps/api/src/tabs/tabs.service.ts @@ -1,27 +1,15 @@ import { Injectable } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { customerNameKey } from '../planned-tabs/customer-name'; +import { tabSummary } from './tab-state'; @Injectable() export class TabsService { constructor(private prisma: PrismaService) {} + /** The venue's open tabs as the server holds them; every terminal shows the same. */ async findAll(venueId: string) { - const ownerships = await this.prisma.tabOwnership.findMany({ - where: { venueId }, - include: { - device: { - select: { id: true, name: true, lastSeenAt: true, revokedAt: true }, - }, - }, - }); - - const snapshots = ownerships.length === 0 ? [] : await this.prisma.openTabSnapshot.findMany({ - where: { - OR: ownerships.map((o) => ({ deviceId: o.deviceId, tabId: o.tabId })), - }, - }); - const snapshotByKey = new Map(snapshots.map((s) => [`${s.deviceId}:${s.tabId}`, s])); + const tabs = await this.prisma.tab.findMany({ where: { venueId }, orderBy: { openedAt: 'asc' } }); // Customer details (email for reminders) live on the planned customer with the same name const planned = await this.prisma.plannedTab.findMany({ @@ -30,31 +18,16 @@ export class TabsService { }); const plannedByKey = new Map(planned.map((p) => [p.customerNameKey, p])); - const tabs = []; - for (const ownership of ownerships) { - const snapshot = snapshotByKey.get(`${ownership.deviceId}:${ownership.tabId}`); - - if (snapshot) { - const plannedTab = plannedByKey.get(customerNameKey(snapshot.customerName)); - tabs.push({ - tabId: snapshot.tabId, - customerName: snapshot.customerName, - version: snapshot.version, - totalInCents: snapshot.totalInCents, - itemCount: snapshot.itemCount, - // null: the terminal's app version doesn't report lines yet - items: snapshot.items ?? null, - deviceId: ownership.device.id, - deviceName: ownership.device.name, - deviceLastSeenAt: ownership.device.lastSeenAt, - deviceRevokedAt: ownership.device.revokedAt, - lastReportedAt: snapshot.reportedAt, + return { + tabs: tabs.map(tabSummary).map(({ updatedAt, ...tab }) => { + const plannedTab = plannedByKey.get(customerNameKey(tab.customerName)); + return { + ...tab, + lastChangedAt: updatedAt, plannedTabId: plannedTab?.id ?? null, email: plannedTab?.email ?? null, - }); - } - } - - return { tabs }; + }; + }), + }; } -} \ No newline at end of file +} diff --git a/apps/api/src/tabs/venue-ledger.ts b/apps/api/src/tabs/venue-ledger.ts new file mode 100644 index 0000000..3186517 --- /dev/null +++ b/apps/api/src/tabs/venue-ledger.ts @@ -0,0 +1,118 @@ +import { Prisma } from '@prisma/client'; +import { Line, SettlementDraft, TabState, VenueTabs } from './tab-state'; +import { ProductStock, VenueStock } from '../stock/stock-rules'; + +type Tx = Prisma.TransactionClient; + +export type RecordedSettlement = SettlementDraft & { deviceId?: string | null; requestedBy?: string | null }; + +/** + * A venue's open tabs, stock and new settlements inside one transaction. Opening it locks the venue, so terminals + * syncing at the same time and the dashboard apply their changes one after the other instead of overwriting each + * other. Everything is loaded at once: a venue has tens of open tabs and products, not thousands. + */ +export class VenueLedger { + readonly settlements: RecordedSettlement[] = []; + private seededNow = false; + + private constructor( + readonly venueId: string, + readonly tabs: VenueTabs, + readonly stock: VenueStock, + /** False until the server took over the terminal's open tabs. */ + readonly seeded: boolean, + ) {} + + static async open(tx: Tx, venueId: string): Promise { + const [venue] = await tx.$queryRaw<{ tabsSeededAt: Date | null }[]>` + SELECT "tabsSeededAt" FROM "Venue" WHERE "id" = ${venueId} FOR UPDATE`; + + const [tabs, products] = await Promise.all([ + tx.tab.findMany({ where: { venueId }, orderBy: { openedAt: 'asc' } }), + tx.product.findMany({ where: { venueId }, select: { productId: true, stock: true, countedAt: true } }), + ]); + + return new VenueLedger( + venueId, + new VenueTabs(tabs.map((tab) => ({ + id: tab.id, + customerName: tab.customerName, + openedAt: tab.openedAt, + version: tab.version, + lines: tab.lines as unknown as Line[], + }))), + new VenueStock(new Map(products.map((p): [string, ProductStock] => ( + [p.productId, { stock: p.stock, countedAt: p.countedAt }])))), + venue?.tabsSeededAt != null, + ); + } + + /** Writes what changed. Call once, at the end of the transaction. */ + async save(tx: Tx, now: Date) { + const { venueId } = this; + + if (this.tabs.deleted.size > 0) { + await tx.tab.deleteMany({ where: { venueId, id: { in: [...this.tabs.deleted] } } }); + } + for (const tabId of this.tabs.changed) { + const tab = this.tabs.get(tabId)!; + const data = { + customerName: tab.customerName, + openedAt: tab.openedAt, + version: tab.version, + lines: tab.lines as unknown as Prisma.InputJsonValue, + }; + await tx.tab.upsert({ + where: { venueId_id: { venueId, id: tabId } }, + create: { venueId, id: tabId, ...data }, + update: data, + }); + } + + for (const productId of this.stock.changed) { + const { stock, countedAt } = this.stock.get(productId)!; + await tx.product.update({ + where: { venueId_productId: { venueId, productId } }, + data: { stock, countedAt }, + }); + } + + if (this.settlements.length > 0) { + await tx.settlement.createMany({ + data: this.settlements.map((s) => ({ + id: s.id, + venueId, + tabId: s.tabId, + customerName: s.customerName, + settledAt: s.settledAt, + paymentMethod: s.paymentMethod, + amountInCents: s.amountInCents, + lines: s.lines as unknown as Prisma.InputJsonValue, + deviceId: s.deviceId ?? null, + requestedBy: s.requestedBy ?? null, + commandId: s.commandId, + })), + skipDuplicates: true, + }); + } + + if (this.seededNow) { + await tx.venue.update({ where: { id: venueId }, data: { tabsSeededAt: now } }); + } + } + + get changedTabs(): boolean { + return this.tabs.changed.size > 0 || this.tabs.deleted.size > 0; + } + + /** Takes over a terminal's own open tabs, once. */ + seed(tabs: TabState[]) { + this.seededNow = true; + for (const tab of tabs) { + if (this.tabs.get(tab.id)) continue; + const opened = this.tabs.open(tab.id, tab.customerName, tab.openedAt); + opened.version = tab.version; + opened.lines = tab.lines; + } + } +} diff --git a/apps/api/src/testing/fake-prisma.ts b/apps/api/src/testing/fake-prisma.ts new file mode 100644 index 0000000..8dc4938 --- /dev/null +++ b/apps/api/src/testing/fake-prisma.ts @@ -0,0 +1,188 @@ +/** + * An in-memory stand-in for the Prisma client, for tests that follow a venue through several syncs. It supports the + * queries the tab, stock and device services use: equality, `in`, `notIn`, `not`, `gt`, `gte`, `lte` and compound + * unique keys, `orderBy` on one or more fields, `take` and `select`. Transactions run directly on the same data. + */ + +import { randomUUID } from 'crypto'; + +type Row = Record; + +const COMPOUND_KEYS: Record = { + venueId_id: ['venueId', 'id'], + venueId_productId: ['venueId', 'productId'], +}; + +function matches(row: Row, where: Row = {}): boolean { + return Object.entries(where).every(([field, condition]) => { + if (field in COMPOUND_KEYS) return COMPOUND_KEYS[field].every((key) => row[key] === condition[key]); + const value = row[field]; + if (condition === null || typeof condition !== 'object' || condition instanceof Date) { + return equal(value, condition); + } + return Object.entries(condition).every(([op, operand]: [string, any]) => { + switch (op) { + case 'in': return operand.some((o: unknown) => equal(value, o)); + case 'notIn': return !operand.some((o: unknown) => equal(value, o)); + case 'not': return !equal(value, operand); + case 'gt': return value !== null && value > operand; + case 'gte': return value !== null && value >= operand; + case 'lte': return value !== null && value <= operand; + default: throw new Error(`fake-prisma: unsupported operator ${op}`); + } + }); + }); +} + +function equal(a: unknown, b: unknown) { + if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime(); + return (a ?? null) === (b ?? null); +} + +/** A deep copy, so tests can't change stored rows by accident. structuredClone's Dates fail instanceof under Jest. */ +function clone(value: T): T { + if (value instanceof Date) return new Date(value) as T; + if (Array.isArray(value)) return value.map(clone) as T; + if (value && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, clone(v)])) as T; + } + return value; +} + +function pick(row: Row, select?: Row): Row { + const copy = clone(row); + if (!select) return copy; + return Object.fromEntries(Object.keys(select).filter((key) => select[key]).map((key) => [key, copy[key]])); +} + +function sort(rows: Row[], orderBy?: Row | Row[]) { + const orders = orderBy ? (Array.isArray(orderBy) ? orderBy : [orderBy]) : []; + return [...rows].sort((a, b) => { + for (const order of orders) { + const [field, direction] = Object.entries(order)[0]; + if (a[field] < b[field]) return direction === 'asc' ? -1 : 1; + if (a[field] > b[field]) return direction === 'asc' ? 1 : -1; + } + return 0; + }); +} + +class FakeTable { + rows: Row[] = []; + private sequence = 0; + + constructor(private defaults: () => Row = () => ({}), private sequenceField?: string) {} + + private insert(data: Row) { + // Like @default(uuid()); harmless on tables keyed otherwise + const row: Row = { id: randomUUID(), ...this.defaults(), ...clone(data) }; + if (this.sequenceField) row[this.sequenceField] = ++this.sequence; + this.rows.push(row); + return row; + } + + async findMany(args: Row = {}) { + const found = sort(this.rows.filter((row) => matches(row, args.where)), args.orderBy); + return found.slice(0, args.take ?? found.length).map((row) => pick(row, args.select)); + } + + async findUnique(args: Row) { + const row = this.rows.find((r) => matches(r, args.where)); + return row ? pick(row, args.select) : null; + } + + async findFirst(args: Row = {}) { + return (await this.findMany({ ...args, take: 1 }))[0] ?? null; + } + + async create(args: Row) { + return pick(this.insert(args.data), args.select); + } + + async createMany(args: Row) { + let count = 0; + for (const data of args.data) { + if (args.skipDuplicates && this.rows.some((row) => row.id !== undefined && row.id === data.id)) continue; + this.insert(data); + count++; + } + return { count }; + } + + async update(args: Row) { + const row = this.rows.find((r) => matches(r, args.where)); + if (!row) throw new Error('fake-prisma: record to update not found'); + Object.assign(row, clone(args.data)); + return pick(row, args.select); + } + + async updateMany(args: Row) { + const found = this.rows.filter((row) => matches(row, args.where)); + for (const row of found) Object.assign(row, clone(args.data)); + return { count: found.length }; + } + + async upsert(args: Row) { + const row = this.rows.find((r) => matches(r, args.where)); + if (row) { + Object.assign(row, clone(args.update)); + return pick(row); + } + return pick(this.insert(args.create)); + } + + async deleteMany(args: Row = {}) { + const before = this.rows.length; + this.rows = this.rows.filter((row) => !matches(row, args.where)); + return { count: before - this.rows.length }; + } + + async aggregate(args: Row) { + const found = this.rows.filter((row) => matches(row, args.where)); + const max = Object.fromEntries(Object.keys(args._max ?? {}).map((field) => [ + field, + found.length === 0 ? null : Math.max(...found.map((row) => row[field])), + ])); + return { _max: max }; + } +} + +export function createFakePrisma() { + const now = () => new Date(); + const prisma = { + venue: new FakeTable(() => ({ tabsSeededAt: null })), + device: new FakeTable(), + tab: new FakeTable(() => ({ updatedAt: now() })), + product: new FakeTable(() => ({ stock: null, countedAt: null, lowStockThreshold: null })), + tabEvent: new FakeTable(), + settlement: new FakeTable(() => ({ createdAt: now() }), 'sequence'), + stockAdjustment: new FakeTable(() => ({ status: 'pending', stockAfter: null, result: null, processedAt: null, createdAt: now() })), + plannedTab: new FakeTable(() => ({ deliveredAt: null, email: null, position: 0 })), + remoteCommand: new FakeTable(), + auditLog: new FakeTable(), + tabReminder: new FakeTable(), + + /** Only the venue lock of VenueLedger.open. */ + async $queryRaw(strings: TemplateStringsArray, venueId: string) { + const venue = prisma.venue.rows.find((row) => row.id === venueId); + return venue ? [{ tabsSeededAt: venue.tabsSeededAt }] : []; + }, + + async $transaction(fn: unknown) { + if (typeof fn === 'function') return fn(prisma); + return Promise.all(fn as Promise[]); + }, + }; + + // Tab rows bump their update time like Prisma's @updatedAt + const upsertTab = prisma.tab.upsert.bind(prisma.tab); + prisma.tab.upsert = (args: Row) => upsertTab({ + ...args, + create: { ...args.create, updatedAt: now() }, + update: { ...args.update, updatedAt: now() }, + }); + + return prisma; +} + +export type FakePrisma = ReturnType; diff --git a/apps/api/src/venues/venues.controller.ts b/apps/api/src/venues/venues.controller.ts index 98dc1e4..857ebfc 100644 --- a/apps/api/src/venues/venues.controller.ts +++ b/apps/api/src/venues/venues.controller.ts @@ -61,12 +61,13 @@ export class VenuesController { @Get(':venueId/devices') @ApiBearerAuth() @UseGuards(JwtAuthGuard, VenueAccessGuard) - @ApiOperation({ summary: 'Get venue device info' }) + @ApiOperation({ summary: "The venue's paired terminals" }) async findDevices(@Param('venueId') venueId: string) { - const device = await this.prisma.device.findFirst({ + const devices = await this.prisma.device.findMany({ where: { venueId, revokedAt: null }, select: { id: true, name: true, lastSeenAt: true, createdAt: true }, + orderBy: { createdAt: 'asc' }, }); - return { device }; + return { devices }; } } \ No newline at end of file diff --git a/apps/dashboard/src/app/dashboard/page.tsx b/apps/dashboard/src/app/dashboard/page.tsx index 3631033..e2886ad 100644 --- a/apps/dashboard/src/app/dashboard/page.tsx +++ b/apps/dashboard/src/app/dashboard/page.tsx @@ -27,7 +27,7 @@ export default function DashboardPage() { const deviceQuery = useQuery({ queryKey: ['device', venueId], - queryFn: () => devicesApi.get(venueId), + queryFn: () => devicesApi.list(venueId), // A terminal going offline sends no event, so its status is always polled refetchInterval: 30000, }); @@ -45,7 +45,12 @@ export default function DashboardPage() { }); const tabs = tabsQuery.data?.tabs ?? []; - const device = deviceQuery.data?.device; + const devices = deviceQuery.data?.devices ?? []; + // The terminal heard from most recently + const device = devices.reduce<(typeof devices)[number] | undefined>( + (latest, d) => (!latest || d.lastSeenAt > latest.lastSeenAt ? d : latest), + undefined, + ); const commands = recentQuery.data?.commands ?? []; const outstanding = tabs.reduce((sum, tab) => sum + tab.totalInCents, 0); const isLoading = tabsQuery.isLoading || deviceQuery.isLoading || pendingQuery.isLoading; @@ -88,7 +93,11 @@ export default function DashboardPage() { {device.name} -

{t('overview.last_seen', { time: relativeTime(device.lastSeenAt) })}

+

+ {devices.length > 1 + ? t('overview.terminals', { count: devices.length, time: relativeTime(device.lastSeenAt) }) + : t('overview.last_seen', { time: relativeTime(device.lastSeenAt) })} +

) : (
{t('overview.not_connected')}
diff --git a/apps/dashboard/src/app/dashboard/settings/page.tsx b/apps/dashboard/src/app/dashboard/settings/page.tsx index 275f54c..b13f539 100644 --- a/apps/dashboard/src/app/dashboard/settings/page.tsx +++ b/apps/dashboard/src/app/dashboard/settings/page.tsx @@ -1,13 +1,14 @@ 'use client'; import { useState } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import { devicesApi, venuesApi } from '@/lib/api/client'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { devicesApi, venuesApi, type Device } from '@/lib/api/client'; import { PageHeader } from '@/components/page-header'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { DeviceStatusBadge } from '@/components/device-status-badge'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; +import { ConfirmDialog } from '@/components/confirm-dialog'; import { toast } from '@/components/ui/toast'; import { useLang } from '@/lib/i18n'; import { useSession } from '@/lib/session'; @@ -17,6 +18,8 @@ export default function SettingsPage() { const { venueId, role } = useSession(); const [regDialog, setRegDialog] = useState<{ open: boolean; code: string; expiresAt: string } | null>(null); const [generating, setGenerating] = useState(false); + const [unpairing, setUnpairing] = useState(null); + const queryClient = useQueryClient(); const { t, te, relativeTime, dateTime, time } = useLang(); const { data: venue } = useQuery({ @@ -26,12 +29,22 @@ export default function SettingsPage() { const { data: deviceData } = useQuery({ queryKey: ['device', venueId], - queryFn: () => devicesApi.get(venueId), + queryFn: () => devicesApi.list(venueId), // A terminal going offline sends no event, so its status is always polled refetchInterval: 15000, }); - const device = deviceData?.device; + const devices = deviceData?.devices ?? []; + + const unpairMutation = useMutation({ + mutationFn: (device: Device) => devicesApi.unpair(venueId, device.id), + onSuccess: (_, device) => toast({ title: t('settings.unpaired', { name: device.name }), variant: 'success' }), + onError: (err) => toast({ title: t('common.failed'), description: te(err), variant: 'destructive' }), + onSettled: () => { + setUnpairing(null); + queryClient.invalidateQueries({ queryKey: ['device'] }); + }, + }); async function handleGenerateCode() { setGenerating(true); @@ -71,17 +84,29 @@ export default function SettingsPage() { {t('settings.device')} - {device - ? t('settings.paired', { name: device.name, time: relativeTime(device.lastSeenAt) }) - : t('settings.not_paired')} + {devices.length === 0 + ? t('settings.not_paired') + : devices.length === 1 + ? t('settings.paired', { name: devices[0].name, time: relativeTime(devices[0].lastSeenAt) }) + : t('settings.terminals', { count: devices.length })} - {device ? ( -
- - - {t('settings.since', { date: dateTime(device.createdAt) })} + {devices.length > 0 ? ( +
+ {devices.map((device) => ( +
+ + {device.name} + + {t('settings.since', { date: dateTime(device.createdAt) })} + {role === 'admin' && ( + + )} +
+ ))}
) : (

@@ -91,7 +116,7 @@ export default function SettingsPage() { {role === 'admin' && ( )} @@ -99,6 +124,18 @@ export default function SettingsPage() {

+ !open && setUnpairing(null)} + title={t('settings.unpair_title', { name: unpairing?.name ?? '' })} + description={t('settings.unpair_desc')} + confirmLabel={t('settings.unpair')} + cancelLabel={t('common.cancel')} + variant="destructive" + loading={unpairMutation.isPending} + onConfirm={() => unpairing && unpairMutation.mutate(unpairing)} + /> + {regDialog && ( !open && setRegDialog(null)}> diff --git a/apps/dashboard/src/app/dashboard/tabs/page.tsx b/apps/dashboard/src/app/dashboard/tabs/page.tsx index 4100fc5..14edb62 100644 --- a/apps/dashboard/src/app/dashboard/tabs/page.tsx +++ b/apps/dashboard/src/app/dashboard/tabs/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Fragment, useEffect, useState } from 'react'; +import { Fragment, useState } from 'react'; import { commandsApi, plannedTabsApi, remindersApi, tabsApi, type Tab, type TabLine } from '@/lib/api/client'; import { PageHeader } from '@/components/page-header'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -40,7 +40,6 @@ export default function TabsPage() { const [closeTab, setCloseTab] = useState(null); const [paymentMethod, setPaymentMethod] = useState(''); const [expandedTabs, setExpandedTabs] = useState>(new Set()); - const [watchedCommandIds, setWatchedCommandIds] = useState>(new Set()); const [search, setSearch] = useState(''); const [sort, setSort] = useState('name'); const [plannedName, setPlannedName] = useState(''); @@ -61,14 +60,6 @@ export default function TabsPage() { }); const closingTabIds = new Set((pendingQuery.data?.commands ?? []).map((c) => c.tabId)); - // Only needed to report the outcome of close requests sent from this page - const resultsQuery = useQuery({ - queryKey: ['commands', venueId, 'results'], - queryFn: () => commandsApi.list(venueId, { limit: 50 }), - enabled: watchedCommandIds.size > 0, - refetchInterval: live ? false : 3000, - }); - const plannedQuery = useQuery({ queryKey: ['planned-tabs', venueId], queryFn: () => plannedTabsApi.list(venueId), @@ -78,31 +69,16 @@ export default function TabsPage() { // Tiles only show customers the terminal has not opened a tab for yet const pendingPlanned = (plannedQuery.data?.plannedTabs ?? []).filter((pt) => !pt.deliveredAt); - useEffect(() => { - const finished = (resultsQuery.data?.commands ?? []).filter((c) => watchedCommandIds.has(c.id) && c.status !== 'pending'); - if (finished.length === 0) return; - for (const cmd of finished) { - const status = t(`status.${cmd.status}`); - toast({ - title: status, - description: t('tabs.request_result', { name: cmd.customerName ?? cmd.tabId.substring(0, 8), status }), - variant: cmd.status === 'completed' ? 'success' : 'destructive', - }); - } - setWatchedCommandIds((current) => { - const next = new Set(current); - for (const cmd of finished) next.delete(cmd.id); - return next; - }); - queryClient.invalidateQueries({ queryKey: ['tabs'] }); - }, [resultsQuery.data, watchedCommandIds, queryClient, t]); - const createCommandMutation = useMutation({ mutationFn: ({ tab, paymentMethod }: { tab: Tab; paymentMethod: string }) => commandsApi.create(venueId, { tabId: tab.tabId, expectedVersion: tab.version, paymentMethod, type: 'close_tab' }), - onSuccess: (result) => { - setWatchedCommandIds((current) => new Set(current).add(result.id)); - toast({ title: t('tabs.request_sent'), description: result.warning || t('tabs.request_sent_desc'), variant: result.warning ? 'default' : 'success' }); + onSuccess: (result, { tab }) => { + toast({ + title: t('tabs.settled'), + description: t('tabs.settled_desc', { name: tab.customerName, amount: formatCents(result.amountInCents) }), + variant: 'success', + }); + queryClient.invalidateQueries({ queryKey: ['tabs'] }); }, onError: (err) => { toast({ title: t('common.failed'), description: te(err), variant: 'destructive' }); @@ -325,7 +301,7 @@ export default function TabsPage() { {formatCents(tab.totalInCents)} {tab.itemCount} - {relativeTime(tab.lastReportedAt)} + {relativeTime(tab.lastChangedAt)} {closingTabIds.has(tab.tabId) ? ( {t('tabs.closing')} diff --git a/apps/dashboard/src/lib/api/client.ts b/apps/dashboard/src/lib/api/client.ts index e9ce24d..a3d8f09 100644 --- a/apps/dashboard/src/lib/api/client.ts +++ b/apps/dashboard/src/lib/api/client.ts @@ -9,6 +9,7 @@ export type Role = 'admin' | 'viewer'; export type TabLine = { productName: string; quantity: number; unitPriceInCents: number }; +/** An open tab as the server holds it: every terminal of the venue shows the same. */ export interface Tab { tabId: string; customerName: string; @@ -16,11 +17,7 @@ export interface Tab { totalInCents: number; itemCount: number; items: TabLine[] | null; - deviceId: string; - deviceName: string; - deviceLastSeenAt: string; - deviceRevokedAt: string | null; - lastReportedAt: string; + lastChangedAt: string; plannedTabId: string | null; email: string | null; } @@ -40,7 +37,8 @@ export interface Command { createdAt: string; processedAt: string | null; result: string | null; - deviceId: string; + /** Null for a tab the server settled itself */ + deviceId: string | null; } export interface Device { @@ -259,8 +257,10 @@ export const tabsApi = { // Devices API export const devicesApi = { - get: (venueId: string) => - api<{ device: Device | null }>(`/venues/${venueId}/devices`), + list: (venueId: string) => + api<{ devices: Device[] }>(`/venues/${venueId}/devices`), + unpair: (venueId: string, deviceId: string) => + api(`/venues/${venueId}/commands/devices/${deviceId}/revoke`, { method: 'POST' }), generateCode: () => api<{ code: string; expiresAt: string }>('/devices/generation-code'), }; @@ -285,7 +285,7 @@ export const commandsApi = { ); }, create: (venueId: string, data: { tabId: string; type: string; expectedVersion: number; paymentMethod: string }) => - api<{ id: string; status: string; warning?: string }>(`/venues/${venueId}/commands`, { + api<{ id: string; status: string; amountInCents: number }>(`/venues/${venueId}/commands`, { method: 'POST', body: JSON.stringify(data), }), diff --git a/apps/dashboard/src/lib/i18n.tsx b/apps/dashboard/src/lib/i18n.tsx index 3b3a716..7f05e98 100644 --- a/apps/dashboard/src/lib/i18n.tsx +++ b/apps/dashboard/src/lib/i18n.tsx @@ -29,6 +29,8 @@ const translations: Record> = { 'errors.version_mismatch': 'The tab changed since it was loaded. Check it and try again.', 'errors.command_already_pending': 'A close request for this tab is already waiting for the terminal', 'errors.tab_not_owned': 'This tab is no longer open on the terminal', + 'errors.tab_not_found': 'This tab is no longer open', + 'errors.tab_empty': 'There is nothing on this tab to settle', 'errors.device_revoked': 'The terminal for this tab has been unpaired', 'errors.name_exists': 'This customer already exists', 'errors.email_exists': 'A user with this email already exists', @@ -90,14 +92,13 @@ const translations: Record> = { 'tabs.lines_empty': 'No items on this tab.', 'tabs.each': 'each', 'tabs.close_title': "Close {name}'s tab?", - 'tabs.close_desc': 'Send a close request to the terminal.', + 'tabs.close_desc': 'Settles everything on the tab now. The terminals show it after their next sync.', 'tabs.payment': 'Payment method', 'tabs.select_payment': 'Please select a payment method', 'tabs.send': 'Send close request', 'tabs.sending': 'Sending...', - 'tabs.request_sent': 'Close request sent', - 'tabs.request_sent_desc': 'Sent to the terminal', - 'tabs.request_result': 'Close request for {name}: {status}', + 'tabs.settled': 'Tab settled', + 'tabs.settled_desc': '{name}: {amount}', 'tabs.send_test': 'Send test email', 'tabs.test_sent': 'Test email sent', 'tabs.test_sent_desc': 'Sent to {email}', @@ -185,7 +186,12 @@ const translations: Record> = { 'settings.since': 'Since {date}', 'settings.pair_desc': 'Pair a bar terminal to start receiving tabs.', 'settings.pair': 'Pair Terminal', - 'settings.repair': 'Re-pair Terminal', + 'settings.repair': 'Pair Another Terminal', + 'settings.unpair': 'Unpair', + 'settings.unpair_title': 'Unpair {name}?', + 'settings.unpair_desc': 'The terminal stops syncing and has to be paired again to reconnect. Its changes that have not been synced yet are lost.', + 'settings.unpaired': '{name} is unpaired', + 'settings.terminals': '{count} terminals paired', 'settings.generating': 'Generating...', 'settings.pair_title': 'Pair a Terminal', 'settings.pair_instructions': 'Enter this code on the bar terminal to connect it to your venue.', @@ -208,6 +214,7 @@ const translations: Record> = { 'overview.no_commands': 'No commands yet', 'overview.not_connected': 'Not connected', 'overview.last_seen': 'Last seen {time}', + 'overview.terminals': '{count} terminals, last seen {time}', 'auth.login': 'Sign in', 'auth.logging_in': 'Signing in...', 'auth.email': 'Email', @@ -293,27 +300,27 @@ const translations: Record> = { 'stock.out': 'Out', 'stock.adjust': 'Adjust', 'stock.start_tracking': 'Track stock', - 'stock.start_tracking_desc': "The terminal doesn't track stock for this product yet. Enter how many you have to start tracking it.", + 'stock.start_tracking_desc': "Stock isn't tracked for this product yet. Enter how many you have to start tracking it.", 'stock.cancel_adjustment': 'Cancel {change} for {name}', 'stock.cancelled': 'Change cancelled', - 'stock.current': 'Currently {stock} in stock on the terminal.', + 'stock.current': 'Currently {stock} in stock.', 'stock.mode_add': 'Add (delivery)', 'stock.mode_remove': 'Take out (breakage, own use…)', 'stock.mode_count': 'Count (set the stock to what you counted)', 'stock.quantity': 'Quantity', 'stock.counted': 'Counted', 'stock.preview': '{from} → {to}', - 'stock.count_hint': 'Drinks sold between your count and the terminal receiving it are not subtracted.', + 'stock.count_hint': 'Drinks rung up before your count are in it: they are not subtracted again, also not when an offline terminal sends them later.', 'stock.note': 'Note (optional)', 'stock.note_placeholder': 'e.g. delivery from the brewery', 'stock.threshold': 'Warn when stock is at or below', 'stock.threshold_none': 'No warning', 'stock.threshold_save': 'Save', 'stock.threshold_saved': 'Warning level saved', - 'stock.send': 'Send to terminal', + 'stock.send': 'Save', 'stock.sending': 'Sending…', - 'stock.sent': 'Sent to the terminal', - 'stock.sent_desc': 'The stock changes on the terminal at its next sync.', + 'stock.sent': 'Stock updated', + 'stock.sent_desc': 'The terminals show it after their next sync.', 'stock.delivery': 'Register delivery', 'stock.delivery_title': 'Register delivery', 'stock.delivery_desc': 'Enter how many of each product were delivered. Products you leave empty are not changed.', @@ -355,6 +362,8 @@ const translations: Record> = { 'errors.version_mismatch': 'De poef is gewijzigd sinds ze geladen werd. Controleer en probeer opnieuw.', 'errors.command_already_pending': 'Er wacht al een sluitverzoek voor deze poef op de kassa', 'errors.tab_not_owned': 'Deze poef staat niet meer open op de kassa', + 'errors.tab_not_found': 'Deze poef staat niet meer open', + 'errors.tab_empty': 'Er staat niets op deze poef om af te rekenen', 'errors.device_revoked': 'De kassa van deze poef is ontkoppeld', 'errors.name_exists': 'Deze klant bestaat al', 'errors.email_exists': 'Er bestaat al een gebruiker met dit e-mailadres', @@ -416,14 +425,13 @@ const translations: Record> = { 'tabs.lines_empty': 'Geen items op deze poef.', 'tabs.each': 'per stuk', 'tabs.close_title': 'Poef van {name} sluiten?', - 'tabs.close_desc': 'Stuur een verzoek naar de terminal om deze poef te sluiten.', + 'tabs.close_desc': 'Rekent alles op de poef nu af. De terminals tonen het na hun volgende synchronisatie.', 'tabs.payment': 'Betaalmethode', 'tabs.select_payment': 'Kies een betaalmethode', 'tabs.send': 'Sluitverzoek versturen', 'tabs.sending': 'Bezig met versturen...', - 'tabs.request_sent': 'Sluitverzoek verstuurd', - 'tabs.request_sent_desc': 'Verstuurd naar de terminal', - 'tabs.request_result': 'Sluitverzoek voor {name}: {status}', + 'tabs.settled': 'Poef afgerekend', + 'tabs.settled_desc': '{name}: {amount}', 'tabs.send_test': 'Testmail versturen', 'tabs.test_sent': 'Testmail verstuurd', 'tabs.test_sent_desc': 'Verstuurd naar {email}', @@ -511,7 +519,12 @@ const translations: Record> = { 'settings.since': 'Sinds {date}', 'settings.pair_desc': 'Koppel een barterminal om poefs te ontvangen.', 'settings.pair': 'Terminal koppelen', - 'settings.repair': 'Terminal opnieuw koppelen', + 'settings.repair': 'Nog een terminal koppelen', + 'settings.unpair': 'Ontkoppelen', + 'settings.unpair_title': '{name} ontkoppelen?', + 'settings.unpair_desc': 'De terminal synchroniseert niet meer en moet opnieuw gekoppeld worden. Wijzigingen die nog niet gesynchroniseerd zijn, gaan verloren.', + 'settings.unpaired': '{name} is ontkoppeld', + 'settings.terminals': '{count} terminals gekoppeld', 'settings.generating': 'Code aanmaken...', 'settings.pair_title': 'Terminal koppelen', 'settings.pair_instructions': 'Voer deze code in op de barterminal om hem met je zaak te verbinden.', @@ -534,6 +547,7 @@ const translations: Record> = { 'overview.no_commands': 'Nog geen opdrachten', 'overview.not_connected': 'Niet verbonden', 'overview.last_seen': 'Laatst gezien {time}', + 'overview.terminals': '{count} terminals, laatst gezien {time}', 'auth.login': 'Inloggen', 'auth.logging_in': 'Bezig met inloggen...', 'auth.email': 'E-mail', @@ -619,27 +633,27 @@ const translations: Record> = { 'stock.out': 'Op', 'stock.adjust': 'Aanpassen', 'stock.start_tracking': 'Voorraad bijhouden', - 'stock.start_tracking_desc': 'De kassa houdt voor dit product nog geen voorraad bij. Vul in hoeveel je er hebt om te beginnen.', + 'stock.start_tracking_desc': 'Voor dit product wordt nog geen voorraad bijgehouden. Vul in hoeveel je er hebt om te beginnen.', 'stock.cancel_adjustment': '{change} voor {name} annuleren', 'stock.cancelled': 'Wijziging geannuleerd', - 'stock.current': 'Nu {stock} in voorraad op de kassa.', + 'stock.current': 'Nu {stock} in voorraad.', 'stock.mode_add': 'Bijvullen (levering)', 'stock.mode_remove': 'Afboeken (breuk, eigen gebruik…)', 'stock.mode_count': 'Tellen (voorraad wordt wat je telde)', 'stock.quantity': 'Aantal', 'stock.counted': 'Geteld', 'stock.preview': '{from} → {to}', - 'stock.count_hint': 'Wat verkocht wordt tussen je telling en het moment dat de kassa ze ontvangt, wordt niet afgetrokken.', + 'stock.count_hint': 'Wat voor je telling aangeslagen werd, zit erin: het wordt niet nog eens afgetrokken, ook niet als een offline kassa het later doorstuurt.', 'stock.note': 'Notitie (optioneel)', 'stock.note_placeholder': 'bv. levering brouwerij', 'stock.threshold': 'Waarschuw vanaf', 'stock.threshold_none': 'Geen waarschuwing', 'stock.threshold_save': 'Opslaan', 'stock.threshold_saved': 'Waarschuwingsniveau opgeslagen', - 'stock.send': 'Naar kassa sturen', + 'stock.send': 'Opslaan', 'stock.sending': 'Versturen…', - 'stock.sent': 'Naar de kassa gestuurd', - 'stock.sent_desc': 'De voorraad wijzigt op de kassa bij de volgende synchronisatie.', + 'stock.sent': 'Voorraad bijgewerkt', + 'stock.sent_desc': 'De kassa\'s tonen het na hun volgende synchronisatie.', 'stock.delivery': 'Levering invoeren', 'stock.delivery_title': 'Levering invoeren', 'stock.delivery_desc': 'Vul in hoeveel er van elk product geleverd is. Lege producten blijven ongewijzigd.', diff --git a/docs/api.md b/docs/api.md index bbe07cf..d0c3974 100644 --- a/docs/api.md +++ b/docs/api.md @@ -28,47 +28,48 @@ Swagger UI: `http://localhost:3001/api/docs` ### Venues `GET /venues` - List venues `GET /venues/:venueId` - Get venue details -`GET /venues/:venueId/devices` - List devices in venue +`GET /venues/:venueId/devices` - The venue's paired terminals `{ devices: [...] }` ### Devices -`POST /devices/register` - Register a new device +`POST /devices/register` - Pair a terminal; every pairing adds one `POST /devices/:deviceId/sync` - Sync device state (device auth) `POST /devices/:deviceId/commands/:commandId/ack` - Acknowledge command (device auth) `GET /devices/generation-code` - Generate registration code (admin) -Sync semantics: -- `openTabs` must be the device's **complete** list of open tabs. Tabs it no longer reports - (e.g. deleted on the terminal) are removed from the dashboard. Settling a tab on the terminal - keeps the tab open under the same name with a zero balance, so the name stays listed. -- Every sync returns **all** pending commands for the device; `cursor` is still returned but no longer - 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 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) +Sync semantics. The server holds the venue's open tabs and stock; a venue can have several terminals. Each terminal +works offline, queues what it does as **events**, and on every sync replaces its tabs and stock with the server's and +replays its unsent events on top. The full rules are in the app repository: `docs/server-tabs-source-of-truth.md` +and `docs/server-stock-source-of-truth.md`. +- Every write to a venue's tabs, stock and settlements (syncs, dashboard settles, stock adjustments) runs in one + transaction that locks the venue row, so simultaneous terminals apply their changes one after the other. +- `events` (at most 1000): terminal-generated UUID `id`, `type`, `occurredAt` (ISO-8601, UTC; capped at the server + time when more than 5 minutes ahead) and per type: + - `tab_opened` (`tabId`, `customerName`, `openedAt`), `tab_renamed` (new `customerName`), `tab_deleted` + - `line_added` / `line_removed`: `lineId`, `productId`, `productName`, `unitPriceInCents`, `quantity` (≥ 1) + - `settled`: `amountInCents`, `paymentMethod` (`cash`, `payconiq`, `payment_pending` or `other`), optional + `commandId` and `items` (`[{ productId?, productName, quantity, unitPriceInCents }]`) + - `stock_set`: `productId`, `quantity` (a count ≥ 0, or `null` to stop tracking); no `tabId` - 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. -- `products` (optional) is the terminal's **complete** product catalog: - `[{ id, name, category?, priceInCents, stock }]`, where `stock` is an integer (may be negative) or `null` when - the terminal doesn't track stock for that product. Products left out are removed from the dashboard. When the - field is omitted (older app versions) the stored products are left alone. An invalid product is skipped and - logged, never failing the sync. At most 2000 products. -- `stockAdjustments` in the response lists **every** pending stock change requested from the dashboard - for the venue, oldest first: `[{ id, productId, type, quantity, note, requestedBy, createdAt }]`. - `type: "add"` changes the stock by `quantity` (negative takes stock out); `type: "set"` makes the stock - `quantity` (a count, ≥ 0, and starts tracking an untracked product). They are re-sent on every sync until the - terminal reports an outcome, so the terminal must apply each id only once. -- `stockAdjustmentResults` (optional) reports outcomes: `[{ id, status: "applied" | "rejected", stockAfter?, reason? }]` - (`stockAfter` is required for `applied`). The response lists `acknowledgedStockAdjustmentResultIds` and - `rejectedStockAdjustmentResults` (`[{ id, reason }]`); the terminal drops both from its outbox. The terminal's - outcome is recorded even if the adjustment was cancelled on the dashboard in the meantime. + Each id is applied once (tab state and stock in the same transaction). `acknowledgedEventIds` lists the applied + ones, including repeats; `rejectedEvents` (`[{ id, reason }]`) were invalid and never applied. The terminal drops + both from its outbox. An invalid event never fails the sync. +- The response's `tabs` is the venue's **complete** open tab state after this request's events: + `[{ id, customerName, openedAt, version, lines: [{ id, productId, productName, unitPriceInCents, quantity, + units: [{ id: ":", addedAt, removedAt }] }] }]`. +- `stock` is every product of the venue: `[{ productId, stock, countedAt }]` (`stock` null when not tracked, may be + negative). Line events change it unless they happened before `countedAt`; the latest count wins. +- `settlements` lists the venue's settlements after `settlementsCursor` (or of the last 30 days without one), oldest + first, at most 500; the response's `settlementsCursor` is sent back next time. +- `openTabs` is only used once per venue, on the first sync after the server started holding the tabs: the terminal's + own tabs (and catalog stock) are taken over and the events of that request are stored without applying them again. +- `products` (optional, at most 2000) upserts names, categories and prices. Its `stock` is ignored (counts arrive as + `stock_set`), a new product starts untracked, and products are never removed because one terminal doesn't report + them. An invalid product is skipped and logged. +- Planned customers are opened as tabs by the server, once; `plannedTabNames` is always empty. +- Dashboard stock adjustments are applied by the server right away; `stockAdjustments` is always empty. + `stockAdjustmentResults` from older app versions are still recorded and acknowledged. +- Every sync returns **all** pending commands for the device (only `close_tab` commands created before the server + settled tabs itself); `cursor` is still returned but no longer filters. The first ack for a command wins. ### Tabs `GET /venues/:venueId/tabs` - List open tabs @@ -98,9 +99,11 @@ Names match case-insensitively and keep their existing spelling; `replace` also `PUT /venues/:venueId/stock/products/:productId` - Set `{ lowStockThreshold: number | null }` (admin, dashboard only) ### Commands -`POST /venues/:venueId/commands` - Create a command +`POST /venues/:venueId/commands` - Settle a tab: `{ tabId, type: "close_tab", expectedVersion, paymentMethod }`. The + server settles everything on the tab at once (also with every terminal offline) and records the command as + `completed`. `version_mismatch` when the tab changed since it was shown, `tab_empty` when there is nothing on it. `GET /venues/:venueId/commands` - List command history -`POST /venues/:venueId/commands/devices/:deviceId/revoke` - Revoke device (admin) +`POST /venues/:venueId/commands/devices/:deviceId/revoke` - Unpair a terminal (admin) All `/venues/:venueId/...` routes return `403 venue_forbidden` unless the user belongs to that venue.