feat: make server own tabs
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-10-20 16:18:36 +02:00
parent 1e4438ec5a
commit 0ca9f4d16a
31 changed files with 2101 additions and 892 deletions
@@ -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;
+78 -14
View File
@@ -24,11 +24,17 @@ model Venue {
tabEvents TabEvent[] tabEvents TabEvent[]
products Product[] products Product[]
stockAdjustments StockAdjustment[] 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 { model Device {
id String @id @default(uuid()) id String @id @default(uuid())
venueId String @unique // A venue can have several terminals
venueId String
name String name String
tokenHash String @unique tokenHash String @unique
tokenPrefix String tokenPrefix String
@@ -42,6 +48,7 @@ model Device {
tabOwnerships TabOwnership[] tabOwnerships TabOwnership[]
remoteCommands RemoteCommand[] remoteCommands RemoteCommand[]
tabEvents TabEvent[] tabEvents TabEvent[]
settlements Settlement[]
@@index([venueId, revokedAt]) @@index([venueId, revokedAt])
} }
@@ -105,10 +112,13 @@ model TabOwnership {
@@id([venueId, tabId]) @@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 { model RemoteCommand {
id String @id @default(uuid()) id String @id @default(uuid())
venueId String venueId String
deviceId String // Null for a command the server carried out itself
deviceId String?
targetTabId String targetTabId String
customerName String? customerName String?
amountInCents Int? amountInCents Int?
@@ -123,7 +133,7 @@ model RemoteCommand {
expiresAt DateTime expiresAt DateTime
venue Venue @relation(fields: [venueId], references: [id]) venue Venue @relation(fields: [venueId], references: [id])
device Device @relation(fields: [deviceId], references: [id]) device Device? @relation(fields: [deviceId], references: [id])
@@index([deviceId, status, createdAt]) @@index([deviceId, status, createdAt])
@@index([venueId, status, createdAt]) @@index([venueId, status, createdAt])
@@ -188,16 +198,19 @@ model TabReminder {
@@index([venueId, tabId]) @@index([venueId, tabId])
} }
// What happened on a tab, as reported by the terminal: lines added or removed, and settlements. // What happened on a tab, as reported by the terminal. The id is generated by the terminal so a re-sent event is
// The id is generated by the terminal so a re-sent event is stored only once. // stored, and applied to the tab state and stock, only once.
model TabEvent { model TabEvent {
id String @id id String @id
venueId String venueId String
deviceId String deviceId String
tabId String // Null for stock_set, which is not about a tab
customerName String tabId String?
// line_added | line_removed | settled customerName String?
// tab_opened | tab_renamed | tab_deleted | line_added | line_removed | settled | stock_set
type String type String
lineId String?
productId String?
productName String? productName String?
unitPriceInCents Int? unitPriceInCents Int?
// Signed: positive for line_added, negative for line_removed // Signed: positive for line_added, negative for line_removed
@@ -207,6 +220,8 @@ model TabEvent {
paymentMethod String? paymentMethod String?
commandId String? commandId String?
items Json? items Json?
// stock_set: the counted stock, null to stop tracking
stockSetTo Int?
occurredAt DateTime occurredAt DateTime
receivedAt DateTime @default(now()) receivedAt DateTime @default(now())
@@ -217,8 +232,9 @@ model TabEvent {
@@index([venueId, type, occurredAt]) @@index([venueId, type, occurredAt])
} }
// The terminal's product catalog with its stock, as last reported by the terminal. The terminal owns the // The venue's products as reported by its terminals, with the stock. The server owns the stock: it follows the
// stock: it counts sales down offline, so the dashboard only changes it through StockAdjustments. // 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 { model Product {
venueId String venueId String
// Generated by the terminal // Generated by the terminal
@@ -226,8 +242,10 @@ model Product {
name String name String
category String? category String?
priceInCents Int 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? 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 // Dashboard-only setting, never sent to or overwritten by the terminal
lowStockThreshold Int? lowStockThreshold Int?
reportedAt DateTime reportedAt DateTime
@@ -237,8 +255,8 @@ model Product {
@@id([venueId, productId]) @@id([venueId, productId])
} }
// A stock change requested from the dashboard. It is sent to the terminal on every sync until the terminal // A stock change made on the dashboard. The server applies it right away. Older ones may still have been applied
// reports it applied or rejected. // by a terminal, which reported the outcome.
model StockAdjustment { model StockAdjustment {
id String @id @default(uuid()) id String @id @default(uuid())
venueId String venueId String
@@ -252,7 +270,7 @@ model StockAdjustment {
requestedById String requestedById String
// pending | applied | rejected | cancelled // pending | applied | rejected | cancelled
status String @default("pending") status String @default("pending")
// The terminal's stock right after applying it // The stock right after applying it
stockAfter Int? stockAfter Int?
result String? result String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -263,3 +281,49 @@ model StockAdjustment {
@@index([venueId, status, createdAt]) @@index([venueId, status, createdAt])
@@index([venueId, productId, 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])
}
+3 -20
View File
@@ -145,25 +145,9 @@ export class AuthService {
} }
const { rawToken, hash, prefix, expiresAt } = await this.generateDeviceToken(); const { rawToken, hash, prefix, expiresAt } = await this.generateDeviceToken();
let deviceId: string; // A venue can have several terminals: every pairing adds one. A terminal that is replaced is unpaired on the
// dashboard
// One device per venue — update existing or create new const deviceId = uuidv4();
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({ await this.prisma.device.create({
data: { data: {
id: deviceId, id: deviceId,
@@ -174,7 +158,6 @@ export class AuthService {
tokenExpiresAt: expiresAt, tokenExpiresAt: expiresAt,
}, },
}); });
}
await this.auditService.log(venueId, 'device', deviceId, 'device_registered', { deviceName: name }); await this.auditService.log(venueId, 'device', deviceId, 'device_registered', { deviceName: name });
+47 -69
View File
@@ -1,98 +1,76 @@
import { ConflictException, NotFoundException } from '@nestjs/common'; import { ConflictException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { CommandsService } from './commands.service'; import { CommandsService } from './commands.service';
import { createFakePrisma } from '../testing/fake-prisma';
const user = { id: 'u1', name: 'Staff', email: 'staff@example.com' }; const user = { id: 'u1', name: 'Staff', email: 'staff@example.com' };
const dto = { tabId: 't1', type: 'close_tab', expectedVersion: 3, paymentMethod: 'cash' }; const dto = { tabId: 't1', type: 'close_tab', expectedVersion: 3, paymentMethod: 'cash' };
function createService({ const unit = (id: string, removed = false) => (
ownership = { deviceId: 'd1' } as object | null, { id, addedAt: '2026-09-24T20:00:00.000Z', removedAt: removed ? '2026-09-24T20:01:00.000Z' : null });
device = { id: 'd1', revokedAt: null, lastSeenAt: new Date() } as object | null,
snapshot = { customerName: 'Jan', totalInCents: 2500, version: 3 } as object | null, function createService({ lines = [] as unknown[] } = {}) {
pending = null as object | null, const prisma = createFakePrisma();
} = {}) { prisma.venue.rows.push({ id: 'v1', tabsSeededAt: new Date('2026-10-01') });
const tx = { prisma.tab.rows.push({
tabOwnership: { findUnique: jest.fn().mockResolvedValue(ownership) }, venueId: 'v1', id: 't1', customerName: 'Jan', openedAt: new Date('2026-09-24T19:00:00Z'), version: 3, lines,
device: { findUnique: jest.fn().mockResolvedValue(device) }, updatedAt: new Date(),
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 realtime = { notify: jest.fn() }; const realtime = { notify: jest.fn() };
const service = new CommandsService(prisma as any, { log: jest.fn() } as any, realtime as any); 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', () => { describe('CommandsService.create', () => {
it('queues a close command with the customer and amount it was sent for', async () => { it('settles everything on the tab at once and records it as completed', async () => {
const { service, prisma, tx, realtime } = createService(); const { service, prisma, realtime } = createService({ lines: [colaLine] });
const result = await service.create('v1', dto, user); const result = await service.create('v1', dto, user);
expect(result.status).toBe('pending'); expect(result).toMatchObject({ status: 'completed', amountInCents: 500 });
expect(tx.remoteCommand.create).toHaveBeenCalledWith({ expect(prisma.tab.rows[0]).toMatchObject({ version: 4, lines: [] });
data: expect.objectContaining({ expect(prisma.settlement.rows).toHaveLength(1);
venueId: 'v1', expect(prisma.settlement.rows[0]).toMatchObject({
deviceId: 'd1', tabId: 't1', customerName: 'Jan', paymentMethod: 'cash', amountInCents: 500, requestedBy: 'Staff', deviceId: null,
targetTabId: 't1',
customerName: 'Jan',
amountInCents: 2500,
expectedVersion: 3,
paymentMethod: 'cash',
requestedBy: 'Staff',
}),
}); });
expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), { isolationLevel: 'Serializable' }); // The struck-through drink goes along, so the history shows it
expect(realtime.notify).toHaveBeenCalledWith('v1', 'commands'); 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 () => { it('rejects a tab that is not open', async () => {
const { service } = createService({ ownership: null }); 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 () => { it('rejects a tab that changed since it was loaded, changing nothing', async () => {
const { service, tx } = createService({ snapshot: { customerName: 'Jan', totalInCents: 3000, version: 4 } }); const { service, prisma } = createService({ lines: [colaLine] });
await expect(service.create('v1', dto, user)).rejects.toMatchObject({ response: { code: 'version_mismatch' } }); await expect(service.create('v1', { ...dto, expectedVersion: 2 }, user))
expect(tx.remoteCommand.create).not.toHaveBeenCalled(); .rejects.toMatchObject({ response: { code: 'version_mismatch' } });
expect(prisma.settlement.rows).toEqual([]);
}); });
it('rejects a tab whose device was revoked', async () => { it('rejects a tab with nothing on it', async () => {
const { service } = createService({ device: { id: 'd1', revokedAt: new Date(), lastSeenAt: new Date() } }); 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 () => { it('rejects an unknown payment method or command type', async () => {
const { service, tx } = createService({ pending: { id: 'c0' } }); const { service } = createService({ lines: [colaLine] });
await expect(service.create('v1', dto, user)).rejects.toMatchObject({ response: { code: 'command_already_pending' } }); await expect(service.create('v1', { ...dto, paymentMethod: 'bitcoin' }, user))
expect(tx.remoteCommand.create).not.toHaveBeenCalled(); .rejects.toMatchObject({ response: { code: 'invalid_payment_method' } });
}); await expect(service.create('v1', { ...dto, type: 'open_tab' }, user))
.rejects.toMatchObject({ response: { code: 'invalid_command_type' } });
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');
}); });
}); });
+37 -74
View File
@@ -5,12 +5,11 @@ import {
ConflictException, ConflictException,
} from '@nestjs/common'; } from '@nestjs/common';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { AuditService } from '../audit/audit.service'; import { AuditService } from '../audit/audit.service';
import { RealtimeService } from '../realtime/realtime.service'; import { RealtimeService } from '../realtime/realtime.service';
import { CreateCommandDto } from './dto/create-command.dto'; 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() @Injectable()
export class CommandsService { export class CommandsService {
@@ -20,6 +19,11 @@ export class CommandsService {
private realtime: RealtimeService, 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) { async create(venueId: string, dto: CreateCommandDto, user: any) {
if (!dto.paymentMethod) { if (!dto.paymentMethod) {
throw new BadRequestException({ 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) => { const result = await this.prisma.$transaction(async (tx) => {
// Resolve tab ownership const now = new Date();
const ownership = await tx.tabOwnership.findUnique({ const ledger = await VenueLedger.open(tx, venueId);
where: { venueId_tabId: { venueId, tabId: dto.tabId } },
});
if (!ownership) { const tab = ledger.tabs.get(dto.tabId);
throw new NotFoundException({ if (!tab) {
code: 'tab_not_owned', throw new NotFoundException({ code: 'tab_not_found', message: 'This tab is no longer open' });
message: 'Tab is not associated with any device',
});
} }
if (tab.version !== dto.expectedVersion) {
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) {
throw new ConflictException({ throw new ConflictException({
code: 'version_mismatch', code: 'version_mismatch',
message: 'The tab has changed since it was loaded. Refresh and try again.', message: 'The tab has changed since it was loaded. Refresh and try again.',
}); });
} }
const alreadyPending = await tx.remoteCommand.findFirst({ const id = uuidv4();
where: { venueId, targetTabId: dto.tabId, status: 'pending' }, const settlement = ledger.tabs.settleAll(dto.tabId, { id, settledAt: now, paymentMethod: dto.paymentMethod });
}); if (!settlement) {
if (alreadyPending) { throw new ConflictException({ code: 'tab_empty', message: 'There is nothing on this tab to settle' });
throw new ConflictException({
code: 'command_already_pending',
message: 'A command for this tab is already pending',
});
} }
ledger.settlements.push({ ...settlement, commandId: id, requestedBy });
const now = new Date(); await ledger.save(tx, now);
const expiresAt = new Date(now.getTime() + COMMAND_EXPIRY_HOURS * 60 * 60 * 1000);
const command = await tx.remoteCommand.create({ const command = await tx.remoteCommand.create({
data: { data: {
id: uuidv4(), id,
venueId, venueId,
deviceId: device.id, deviceId: null,
targetTabId: dto.tabId, targetTabId: dto.tabId,
customerName: snapshot?.customerName ?? null, customerName: settlement.customerName,
amountInCents: snapshot?.totalInCents ?? null, amountInCents: settlement.amountInCents,
type: dto.type || 'close_tab', type,
expectedVersion: dto.expectedVersion, expectedVersion: dto.expectedVersion,
paymentMethod: dto.paymentMethod, paymentMethod: dto.paymentMethod,
requestedBy: user.name || user.email, requestedBy,
status: 'pending', status: 'completed',
expiresAt, acknowledgedAt: now,
expiresAt: now,
}, },
}); });
@@ -111,39 +93,20 @@ export class CommandsService {
venueId, venueId,
actorType: 'client_user', actorType: 'client_user',
actorId: user.id, actorId: user.id,
action: 'command_created', action: 'tab_settled',
details: JSON.stringify({ details: JSON.stringify({
commandId: command.id, commandId: command.id,
tabId: dto.tabId, tabId: dto.tabId,
type: dto.type,
paymentMethod: dto.paymentMethod, paymentMethod: dto.paymentMethod,
deviceId: device.id, amountInCents: settlement.amountInCents,
}), }),
}, },
}); });
// Check if device is stale return { id: command.id, status: command.status, amountInCents: settlement.amountInCents };
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;
}); });
this.realtime.notify(venueId, 'commands'); this.realtime.notify(venueId, 'commands', 'tabs');
return result; return result;
} }
+339 -253
View File
@@ -1,293 +1,379 @@
import { Prisma } from '@prisma/client'; import { v4 as uuidv4 } from 'uuid';
import { DevicesService } from './devices.service'; 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<ReturnType<DevicesService['sync']>>;
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({ function setup({ seeded = true } = {}) {
previous = [] as Snapshot[], delivered = 0, expired = 0, products = [] as StoredProduct[], pendingStock = [] as unknown[], const prisma = createFakePrisma();
recordedResults = 1, 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 tx = { const tabletB: Device = { id: 'd2', venueId: 'v1', revokedAt: null, lastSeenAt: new Date() };
tabOwnership: { deleteMany: jest.fn(), upsert: jest.fn(), updateMany: jest.fn() }, prisma.device.rows.push({ ...tabletA }, { ...tabletB });
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() };
const realtime = { notify: jest.fn() }; const realtime = { notify: jest.fn() };
const service = new DevicesService(prisma as any, audit as any, realtime as any); const service = new DevicesService(prisma as any, { log: jest.fn() } as any, realtime as any);
return { service, prisma, tx, realtime };
const sync = (device: Device, body: Partial<SyncDto> = {}) =>
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() }; /** Builds the events a terminal sends, with fresh ids. */
const tab = (id: string, customerName: string) => ({ id, customerName, version: 1, totalInCents: 0, itemCount: 0 }); 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', () => { const catalog = (stock: number | null = 0) => [
it('marks planned customers delivered once the device reports a tab with their name, ignoring case', async () => { { id: 'cola', name: 'Cola', category: 'Frisdrank', priceInCents: 250, stock },
const { service, tx } = createService(); ];
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({ function colaStock(result: SyncResult) {
where: { venueId: 'v1', deliveredAt: null, customerNameKey: { in: ['jan', 'piet peeters'] } }, return result.stock.find((s) => s.productId === 'cola')?.stock;
data: { deliveredAt: expect.any(Date) }, }
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 () => { it('adds what two tablets put on one tab', async () => {
const { service, prisma } = createService(); 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(tab(result, 't1')!.lines).toMatchObject([{ id: 'line-1', quantity: 3 }]);
expect.objectContaining({ where: { venueId: 'v1', deliveredAt: null } }), });
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] });
const result = await sync(tabletA, { events: [added, added] });
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 },
); );
expect(result.plannedTabNames).toEqual(['Els']); 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);
});
}); });
it('does not mark anything delivered when the device reports no tabs', async () => { describe('DevicesService.sync: taking over a tablet once', () => {
const { service, tx } = createService(); it("takes over the tablet's tabs and stock without applying that request's events again", async () => {
const { sync, tabletA, prisma } = setup({ seeded: false });
await service.sync(device, { deviceId: 'd1', openTabs: [] }); 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(tx.plannedTab.updateMany).not.toHaveBeenCalled(); 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<typeof setup>['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', () => { 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 () => { 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(); expect(realtime.notify).not.toHaveBeenCalled();
}); });
it('signals "tabs" when a tab changed', async () => { it('signals "tabs" and "stock" when they changed', async () => {
const { service, realtime } = createService({ previous: [janSnapshot] }); 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'); expect(realtime.notify).toHaveBeenCalledWith('v1', 'tabs', 'stock');
});
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');
}); });
it('signals "device" when a terminal comes back online', async () => { 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'); 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);
});
});
+120 -223
View File
@@ -11,11 +11,14 @@ import { AuditService } from '../audit/audit.service';
import { RealtimeService, RealtimeTopic } from '../realtime/realtime.service'; import { RealtimeService, RealtimeTopic } from '../realtime/realtime.service';
import { DEVICE_ONLINE_THRESHOLD_MS } from '@kooltap/shared'; import { DEVICE_ONLINE_THRESHOLD_MS } from '@kooltap/shared';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { OpenTabDto, OpenTabItemDto, SyncDto } from './dto/sync.dto'; import { SyncDto } from './dto/sync.dto';
import { customerNameKey } from '../planned-tabs/customer-name';
import { AckCommandDto } from './dto/ack-command.dto'; import { AckCommandDto } from './dto/ack-command.dto';
import { parseTabEvents, tabEventRow } from './tab-events'; import { eventTime, parseTabEvents, tabEventRow } from './tab-events';
import { ParsedProduct, parseProducts, parseStockResults } from './stock-sync'; 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() @Injectable()
export class DevicesService { export class DevicesService {
@@ -36,11 +39,12 @@ export class DevicesService {
} }
const now = new Date(); const now = new Date();
const venueId: string = device.venueId;
const events = parseTabEvents(dto.events ?? []); const events = parseTabEvents(dto.events ?? []);
for (const { id, reason } of events.rejected) { for (const { id, reason } of events.rejected) {
this.logger.warn(`Device ${device.id} sent an invalid tab event ${id ?? '(no id)'}: ${reason}`); 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; const reported = dto.products ? parseProducts(dto.products) : null;
for (const { id, reason } of reported?.rejected ?? []) { for (const { id, reason } of reported?.rejected ?? []) {
this.logger.warn(`Device ${device.id} sent an invalid product ${id ?? '(no id)'}: ${reason}`); 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) { for (const { id, reason } of stockResults.rejected) {
this.logger.warn(`Device ${device.id} sent an invalid stock adjustment result ${id ?? '(no id)'}: ${reason}`); 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 synced = await this.prisma.$transaction(async (tx) => {
const previous = await this.prisma.openTabSnapshot.findMany({ // Locks the venue: other terminals' syncs wait, so no change is lost
where: { deviceId: device.id }, const ledger = await VenueLedger.open(tx, venueId);
select: { tabId: true, customerName: true, version: true, totalInCents: true, itemCount: true, items: true },
});
const previousById = new Map(previous.map((s) => [s.tabId, s]));
// Ownership changes are detected up front so audit logging stays outside the transaction // 1. The catalog: names and prices only, the stock is the server's
const takenOver = await this.prisma.tabOwnership.findMany({ const productsChanged = reported ? await this.saveProducts(tx, ledger, reported.products, now) : false;
where: { venueId: device.venueId, tabId: { in: openTabIds }, deviceId: { not: device.id } },
});
const takenOverIds = new Set(takenOver.map((o) => o.tabId));
// Only new or changed tabs are written; unchanged ones just get their report time bumped // 2. Events, each applied once: a terminal resends an event until its acknowledgement arrives
const changedTabs = openTabs.filter((t) => { const unique = [...new Map(events.accepted.map((event) => [event.id, event])).values()];
const p = previousById.get(t.id); const stored = unique.length === 0 ? [] : await tx.tabEvent.findMany({
return !p || takenOverIds.has(t.id) || p.customerName !== t.customerName || p.version !== t.version where: { id: { in: unique.map((event) => event.id) } },
|| p.totalInCents !== t.totalInCents || p.itemCount !== t.itemCount select: { id: true },
|| !sameItems(p.items as StoredItem[] | null, t.items);
}); });
const changedIds = new Set(changedTabs.map((t) => t.id)); const storedIds = new Set(stored.map((event) => event.id));
const unchangedIds = openTabIds.filter((id) => !changedIds.has(id)); const fresh = unique.filter((event) => !storedIds.has(event.id));
const tabsChanged = previous.length !== openTabs.length || changedTabs.length > 0; if (fresh.length > 0) {
let plannedDelivered = 0; await tx.tabEvent.createMany({ data: fresh.map((event) => tabEventRow(event, venueId, device.id, now)) });
}
// Products belong to the venue rather than the device, so they survive pairing a new terminal if (!ledger.seeded) {
const previousProducts = reported // The first sync since the server holds the tabs and stock: the terminal's own tabs and stock already
? await this.prisma.product.findMany({ // include the events of this request, so they are stored but not applied again
where: { venueId: device.venueId }, ledger.seed(seedTabs(dto.openTabs ?? [], now));
select: { productId: true, name: true, category: true, priceInCents: true, stock: true }, for (const product of reported?.products ?? []) ledger.stock.seed(product.id, product.stock);
}) } else {
: []; for (const event of fresh) {
const previousProductsById = new Map(previousProducts.map((p) => [p.productId, p])); const occurredAt = eventTime(event, now);
const changedProducts = (reported?.products ?? []).filter((p) => { const settlement = ledger.tabs.apply(event, occurredAt);
const stored = previousProductsById.get(p.id); if (settlement) ledger.settlements.push({ ...settlement, deviceId: device.id });
return !stored || !sameProduct(stored, p); ledger.stock.apply(event, occurredAt);
}); }
const productsChanged = reported !== null }
&& (previousProducts.length !== reported.products.length || changedProducts.length > 0);
// 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; 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 } },
});
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 },
});
}
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,
});
}
}
// The terminal's outcome is the truth, even for an adjustment cancelled while it was on its way
for (const result of stockResults.accepted) { for (const result of stockResults.accepted) {
const { count } = await tx.stockAdjustment.updateMany({ 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: { data: {
status: result.status, status: result.status,
stockAfter: result.status === 'applied' ? result.stockAfter : null, stockAfter: result.status === 'applied' ? result.stockAfter : null,
@@ -210,42 +102,39 @@ export class DevicesService {
}); });
stockResultsRecorded += count; 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) { // 4. Planned customers become tabs here, once, instead of on every terminal
await tx.tabEvent.createMany({ const plannedDelivered = await openPlannedTabs(tx, ledger, now);
data: events.accepted.map((event) => tabEventRow(event, device.venueId, device.id, now)),
skipDuplicates: true,
});
}
await tx.device.update({ await ledger.save(tx, now);
where: { id: device.id }, await tx.device.update({ where: { id: device.id }, data: { lastSeenAt: now } });
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 }); }, { timeout: 15000 });
// Expire old commands // Expire old commands
const expired = await this.prisma.remoteCommand.updateMany({ const expired = await this.prisma.remoteCommand.updateMany({
where: { where: { deviceId: device.id, status: 'pending', expiresAt: { lte: now } },
deviceId: device.id,
status: 'pending',
expiresAt: { lte: now },
},
data: { status: 'expired' }, data: { status: 'expired' },
}); });
// The cursor is kept for client compatibility but no longer filters commands: every pending // close_tab commands are no longer created (the server settles tabs itself); ones from before that are
// command is returned on each sync, so a command committed while a sync was in flight can never // still delivered. Every pending command is returned on each sync; devices dedupe by command id
// be skipped. Devices dedupe by command id and re-ack commands they already processed.
const cursor = Buffer.from(`${now.toISOString()}:${uuidv4().substring(0, 8)}`).toString('base64url'); 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({ const commands = await this.prisma.remoteCommand.findMany({
where: commandWhere, where: { deviceId: device.id, status: 'pending' },
select: { select: {
id: true, id: true,
type: true, type: true,
@@ -254,35 +143,20 @@ export class DevicesService {
paymentMethod: true, paymentMethod: true,
requestedBy: true, requestedBy: true,
createdAt: true, createdAt: true,
status: true,
}, },
orderBy: { createdAt: 'asc' }, 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[] = []; const topics: RealtimeTopic[] = [];
if (tabsChanged) topics.push('tabs'); if (synced.topics.tabs) topics.push('tabs');
if (plannedDelivered > 0) topics.push('planned-tabs'); if (synced.topics.plannedTabs) topics.push('planned-tabs');
if (expired.count > 0) topics.push('commands'); 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 // 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 (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 { return {
cursor, cursor,
commands: commands.map((c) => ({ commands: commands.map((c) => ({
@@ -294,19 +168,68 @@ export class DevicesService {
requestedBy: c.requestedBy, requestedBy: c.requestedBy,
createdAt: c.createdAt, createdAt: c.createdAt,
})), })),
plannedTabNames: plannedTabs.map((t) => t.customerName), // The server opens planned tabs itself; terminals must not open them as well
// Stored (or already stored earlier): the terminal can drop these from its outbox plannedTabNames: [],
// Applied (or already applied earlier): the terminal can drop these from its outbox
acknowledgedEventIds: events.accepted.map((e) => e.id), 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, 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 // Recorded (or already recorded earlier): the terminal can drop these results from its outbox
acknowledgedStockAdjustmentResultIds: stockResults.accepted.map((r) => r.id), acknowledgedStockAdjustmentResultIds: stockResults.accepted.map((r) => r.id),
// Invalid and never recorded: drop these too, re-sending won't help // Invalid and never recorded: drop these too, re-sending won't help
rejectedStockAdjustmentResults: stockResults.rejected, 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) { async ackCommand(device: any, commandId: string, dto: AckCommandDto) {
if (!device) { if (!device) {
throw new NotFoundException({ code: 'device_not_found', message: 'Device not found' }); throw new NotFoundException({ code: 'device_not_found', message: 'Device not found' });
@@ -390,29 +313,3 @@ export class DevicesService {
return { ok: true }; 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;
}
+7
View File
@@ -64,6 +64,13 @@ export class SyncDto {
@IsString() @IsString()
cursor?: string; 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] }) @ApiProperty({ type: [OpenTabDto] })
@IsArray() @IsArray()
@ArrayMaxSize(1000) @ArrayMaxSize(1000)
+50 -13
View File
@@ -6,13 +6,25 @@ import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { OpenTabItemDto } from './sync.dto'; 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 type TabEventType = (typeof TAB_EVENT_TYPES)[number];
export const SETTLEMENT_PAYMENT_METHODS = ['cash', 'payconiq', 'payment_pending', 'other'] as const; 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 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 { export class TabEventDto {
@ApiProperty({ description: 'Generated by the terminal; re-sending the same id stores the event once' }) @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) @IsIn(TAB_EVENT_TYPES)
type: TabEventType; type: TabEventType;
@ApiProperty({ example: 'local-tab-uuid' }) @ApiPropertyOptional({ example: 'local-tab-uuid', description: 'Every type except stock_set' })
@ValidateIf(isAboutTab)
@IsString() @IsString()
@IsNotEmpty() @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() @IsString()
@MaxLength(200) @MaxLength(200)
customerName: string; customerName?: string;
@ApiProperty({ example: '2026-10-24T21:41:03.120Z', description: 'When it happened on the terminal' }) @ApiProperty({ example: '2026-10-24T21:41:03.120Z', description: 'When it happened on the terminal' })
@IsISO8601({ strict: true }) @IsISO8601({ strict: true })
occurredAt: string; 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' }) @ApiPropertyOptional({ example: 'Cola', description: 'line_added / line_removed' })
@ValidateIf(isLine) @ValidateIf(isLine)
@IsString() @IsString()
@@ -50,11 +83,15 @@ export class TabEventDto {
@Min(0) @Min(0)
unitPriceInCents?: number; unitPriceInCents?: number;
@ApiPropertyOptional({ example: 2, description: 'line_added / line_removed: how many were added or removed, always positive' }) @ApiPropertyOptional({
@ValidateIf(isLine) 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() @IsInt()
@Min(1) @Min(0)
quantity?: number; quantity?: number | null;
@ApiPropertyOptional({ example: 2450, description: 'settled: the amount that was settled' }) @ApiPropertyOptional({ example: 2450, description: 'settled: the amount that was settled' })
@ValidateIf(isSettlement) @ValidateIf(isSettlement)
@@ -72,11 +109,11 @@ export class TabEventDto {
@IsUUID() @IsUUID()
commandId?: string; commandId?: string;
@ApiPropertyOptional({ type: [OpenTabItemDto], description: 'settled: the lines that were settled' }) @ApiPropertyOptional({ type: [SettledItemDto], description: 'settled: the lines that were settled' })
@IsOptional() @IsOptional()
@IsArray() @IsArray()
@ArrayMaxSize(500) @ArrayMaxSize(500)
@ValidateNested({ each: true }) @ValidateNested({ each: true })
@Type(() => OpenTabItemDto) @Type(() => SettledItemDto)
items?: OpenTabItemDto[]; items?: SettledItemDto[];
} }
+34 -1
View File
@@ -33,6 +33,39 @@ describe('parseTabEvents', () => {
expect(rejected).toEqual([]); 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', () => { it('rejects invalid events one by one, keeping their id', () => {
const { accepted, rejected } = parseTabEvents([ const { accepted, rejected } = parseTabEvents([
lineAdded, lineAdded,
@@ -87,7 +120,7 @@ describe('tabEventRow', () => {
const row = tabEventRow(event, 'v1', 'd1', now); const row = tabEventRow(event, 'v1', 'd1', now);
expect(row).toMatchObject({ type: 'settled', amountInCents: 500, paymentMethod: 'payconiq', commandId: null }); 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(); expect(row.quantityDelta).toBeUndefined();
}); });
+25 -5
View File
@@ -25,25 +25,42 @@ export function parseTabEvents(raw: unknown[]): { accepted: TabEventDto[]; rejec
rejected.push({ id, reason: describe(errors) }); rejected.push({ id, reason: describe(errors) });
continue; 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); accepted.push(event);
} }
return { accepted, rejected }; 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); 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 = { const base = {
id: event.id, id: event.id,
venueId, venueId,
deviceId, deviceId,
tabId: event.tabId, tabId: event.tabId ?? null,
customerName: event.customerName, customerName: event.customerName ?? null,
type: event.type, type: event.type,
occurredAt: occurredAt.getTime() > now.getTime() + MAX_CLOCK_AHEAD_MS ? now : occurredAt, occurredAt: eventTime(event, now),
receivedAt: 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') { if (event.type === 'settled') {
return { return {
...base, ...base,
@@ -51,13 +68,16 @@ export function tabEventRow(event: TabEventDto, venueId: string, deviceId: strin
paymentMethod: event.paymentMethod, paymentMethod: event.paymentMethod,
commandId: event.commandId ?? null, commandId: event.commandId ?? null,
items: event.items 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, : Prisma.JsonNull,
}; };
} }
return { return {
...base, ...base,
lineId: event.lineId ?? null,
productId: event.productId ?? null,
productName: event.productName, productName: event.productName,
unitPriceInCents: event.unitPriceInCents, unitPriceInCents: event.unitPriceInCents,
quantityDelta: event.type === 'line_removed' ? -event.quantity! : event.quantity, quantityDelta: event.type === 'line_removed' ? -event.quantity! : event.quantity,
@@ -4,12 +4,30 @@ import { RemindersService } from './reminders.service';
type SentRow = { customerName: string; _count: { _all: number }; _max: { sentAt: Date | null } }; 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 = { const prisma = {
tabOwnership: { findMany: jest.fn().mockResolvedValue([{ tabId: 't1', deviceId: 'd1' }]) }, tab: {
openTabSnapshot: {
findMany: jest.fn().mockResolvedValue([ 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' }]) }, 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 () => { it('includes the tab summary with Belgian amounts', async () => {
const { service } = createService([ const { service } = createService(defaultItems);
{ productName: 'Cola', quantity: 10, unitPriceInCents: 250 },
{ productName: 'Chips', quantity: 2, unitPriceInCents: 200 },
]);
const result = await service.sendReminders('v1'); const result = await service.sendReminders('v1');
@@ -55,16 +70,6 @@ describe('RemindersService.sendReminders', () => {
expect(email.subject).toContain('De Kroon'); 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 () => { it('sends one batch request with an idempotency key and records the reminders', async () => {
const { service, prisma } = createService(null); const { service, prisma } = createService(null);
+4 -13
View File
@@ -4,6 +4,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { RealtimeService } from '../realtime/realtime.service'; import { RealtimeService } from '../realtime/realtime.service';
import { EmailTemplateError, EmailTemplatesService, RenderedEmail } from '../email/email-templates.service'; import { EmailTemplateError, EmailTemplatesService, RenderedEmail } from '../email/email-templates.service';
import { customerNameKey } from '../planned-tabs/customer-name'; 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 // 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. // 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. */ /** Every customer with an unpaid open tab, with their reminder track record. */
async listCandidates(venueId: string): Promise<{ candidates: Candidate[]; cooldownDays: number }> { async listCandidates(venueId: string): Promise<{ candidates: Candidate[]; cooldownDays: number }> {
const ownerships = await this.prisma.tabOwnership.findMany({ const tabs = (await this.prisma.tab.findMany({ where: { venueId }, orderBy: { openedAt: 'asc' } })).map(tabSummary);
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 plannedTabs = await this.prisma.plannedTab.findMany({ const plannedTabs = await this.prisma.plannedTab.findMany({
where: { venueId, email: { not: null } }, where: { venueId, email: { not: null } },
@@ -85,7 +77,7 @@ export class RemindersService {
const now = Date.now(); const now = Date.now();
const candidates: Candidate[] = []; const candidates: Candidate[] = [];
for (const snap of snapshots) { for (const snap of tabs) {
if (snap.totalInCents <= 0) continue; if (snap.totalInCents <= 0) continue;
const key = customerNameKey(snap.customerName); const key = customerNameKey(snap.customerName);
const email = emailByKey.get(key) ?? null; const email = emailByKey.get(key) ?? null;
@@ -97,8 +89,7 @@ export class RemindersService {
email, email,
amountInCents: snap.totalInCents, amountInCents: snap.totalInCents,
itemCount: snap.itemCount, itemCount: snap.itemCount,
// Null when the terminal's app version doesn't report lines items: snap.items,
items: (snap.items as TabLine[] | null) ?? null,
reminderCount: sent?.count ?? 0, reminderCount: sent?.count ?? 0,
lastReminderAt: sent?.last ?? null, lastReminderAt: sent?.last ?? null,
nextEligibleAt, nextEligibleAt,
+38
View File
@@ -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<number> {
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;
}
+67
View File
@@ -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();
});
});
+96
View File
@@ -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<string, ProductStock>;
/** Products to write back. */
readonly changed = new Set<string>();
constructor(products: Map<string, ProductStock>) {
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);
}
}
+23 -6
View File
@@ -1,4 +1,5 @@
import { StockService } from './stock.service'; import { StockService } from './stock.service';
import { createFakePrisma } from '../testing/fake-prisma';
const user = { id: 'u1', email: 'bram@example.com' }; 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 cola = { productId: 'p1', name: 'Cola', stock: 10 };
const water = { productId: 'p2', name: 'Water', stock: null }; 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', () => { describe('StockService.createAdjustments', () => {
it('creates one pending adjustment per entry with the product name and requester', async () => { it('applies the adjustments at once, in the order they were entered', async () => {
const { service, prisma, realtime } = createService({ products: [cola, water] }); const { service, prisma, realtime } = createWithVenue([cola, water]);
const result = await service.createAdjustments('v1', [ const result = await service.createAdjustments('v1', [
{ productId: 'p1', type: 'add', quantity: 24, note: 'Delivery' }, { productId: 'p1', type: 'add', quantity: 24, note: 'Delivery' },
{ productId: 'p2', type: 'set', quantity: 12 }, { productId: 'p2', type: 'set', quantity: 12 },
{ productId: 'p1', type: 'add', quantity: -4, note: 'Broken' },
], user); ], user);
expect(prisma.stockAdjustment.create).toHaveBeenCalledWith(expect.objectContaining({ expect(result.adjustments).toMatchObject([
data: expect.objectContaining({ venueId: 'v1', productId: 'p1', productName: 'Cola', type: 'add', quantity: 24, note: 'Delivery', requestedBy: 'bram@example.com' }), { productId: 'p1', productName: 'Cola', status: 'applied', stockAfter: 34, note: 'Delivery', requestedBy: 'bram@example.com' },
})); { productId: 'p2', status: 'applied', stockAfter: 12 },
expect(result.adjustments).toHaveLength(2); { 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'); expect(realtime.notify).toHaveBeenCalledWith('v1', 'stock');
}); });
+21 -5
View File
@@ -3,6 +3,8 @@ import { PrismaService } from '../prisma/prisma.service';
import { AuditService } from '../audit/audit.service'; import { AuditService } from '../audit/audit.service';
import { RealtimeService } from '../realtime/realtime.service'; import { RealtimeService } from '../realtime/realtime.service';
import { StockAdjustmentEntryDto } from './dto/create-stock-adjustments.dto'; import { StockAdjustmentEntryDto } from './dto/create-stock-adjustments.dto';
import { VenueLedger } from '../tabs/venue-ledger';
import { applyPendingAdjustments } from './stock-adjustments';
const adjustmentSelect = { const adjustmentSelect = {
id: true, id: true,
@@ -106,9 +108,15 @@ 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 requestedBy = user.name || user.email;
const created = await this.prisma.$transaction( const created = await this.prisma.$transaction(async (tx) => {
entries.map((entry) => this.prisma.stockAdjustment.create({ 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: { data: {
venueId, venueId,
productId: entry.productId, productId: entry.productId,
@@ -118,10 +126,18 @@ export class StockService {
note: entry.note ?? null, note: entry.note ?? null,
requestedBy, requestedBy,
requestedById: user.id, requestedById: user.id,
createdAt: now,
}, },
select: adjustmentSelect, 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', { 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 })), adjustments: created.map((a) => ({ id: a.id, productId: a.productId, type: a.type, quantity: a.quantity })),
+141
View File
@@ -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<TabEventDto> & { 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 });
});
});
+301
View File
@@ -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 (`<event id>:<index>`).
*/
/** 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<string, TabState>;
/** Tabs to write back, and tabs to delete. */
readonly changed = new Set<string>();
readonly deleted = new Set<string>();
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,
};
}
+75
View File
@@ -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<number> {
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),
};
}
+12 -39
View File
@@ -1,27 +1,15 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { customerNameKey } from '../planned-tabs/customer-name'; import { customerNameKey } from '../planned-tabs/customer-name';
import { tabSummary } from './tab-state';
@Injectable() @Injectable()
export class TabsService { export class TabsService {
constructor(private prisma: PrismaService) {} constructor(private prisma: PrismaService) {}
/** The venue's open tabs as the server holds them; every terminal shows the same. */
async findAll(venueId: string) { async findAll(venueId: string) {
const ownerships = await this.prisma.tabOwnership.findMany({ const tabs = await this.prisma.tab.findMany({ where: { venueId }, orderBy: { openedAt: 'asc' } });
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]));
// Customer details (email for reminders) live on the planned customer with the same name // Customer details (email for reminders) live on the planned customer with the same name
const planned = await this.prisma.plannedTab.findMany({ 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 plannedByKey = new Map(planned.map((p) => [p.customerNameKey, p]));
const tabs = []; return {
for (const ownership of ownerships) { tabs: tabs.map(tabSummary).map(({ updatedAt, ...tab }) => {
const snapshot = snapshotByKey.get(`${ownership.deviceId}:${ownership.tabId}`); const plannedTab = plannedByKey.get(customerNameKey(tab.customerName));
return {
if (snapshot) { ...tab,
const plannedTab = plannedByKey.get(customerNameKey(snapshot.customerName)); lastChangedAt: updatedAt,
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,
plannedTabId: plannedTab?.id ?? null, plannedTabId: plannedTab?.id ?? null,
email: plannedTab?.email ?? null, email: plannedTab?.email ?? null,
}); };
} }),
} };
return { tabs };
} }
} }
+118
View File
@@ -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<VenueLedger> {
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;
}
}
}
+188
View File
@@ -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<string, any>;
const COMPOUND_KEYS: Record<string, string[]> = {
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<T>(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<unknown>[]);
},
};
// 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<typeof createFakePrisma>;
+4 -3
View File
@@ -61,12 +61,13 @@ export class VenuesController {
@Get(':venueId/devices') @Get(':venueId/devices')
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard, VenueAccessGuard) @UseGuards(JwtAuthGuard, VenueAccessGuard)
@ApiOperation({ summary: 'Get venue device info' }) @ApiOperation({ summary: "The venue's paired terminals" })
async findDevices(@Param('venueId') venueId: string) { async findDevices(@Param('venueId') venueId: string) {
const device = await this.prisma.device.findFirst({ const devices = await this.prisma.device.findMany({
where: { venueId, revokedAt: null }, where: { venueId, revokedAt: null },
select: { id: true, name: true, lastSeenAt: true, createdAt: true }, select: { id: true, name: true, lastSeenAt: true, createdAt: true },
orderBy: { createdAt: 'asc' },
}); });
return { device }; return { devices };
} }
} }
+12 -3
View File
@@ -27,7 +27,7 @@ export default function DashboardPage() {
const deviceQuery = useQuery({ const deviceQuery = useQuery({
queryKey: ['device', venueId], queryKey: ['device', venueId],
queryFn: () => devicesApi.get(venueId), queryFn: () => devicesApi.list(venueId),
// A terminal going offline sends no event, so its status is always polled // A terminal going offline sends no event, so its status is always polled
refetchInterval: 30000, refetchInterval: 30000,
}); });
@@ -45,7 +45,12 @@ export default function DashboardPage() {
}); });
const tabs = tabsQuery.data?.tabs ?? []; 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 commands = recentQuery.data?.commands ?? [];
const outstanding = tabs.reduce((sum, tab) => sum + tab.totalInCents, 0); const outstanding = tabs.reduce((sum, tab) => sum + tab.totalInCents, 0);
const isLoading = tabsQuery.isLoading || deviceQuery.isLoading || pendingQuery.isLoading; const isLoading = tabsQuery.isLoading || deviceQuery.isLoading || pendingQuery.isLoading;
@@ -88,7 +93,11 @@ export default function DashboardPage() {
<span className="text-2xl font-bold truncate">{device.name}</span> <span className="text-2xl font-bold truncate">{device.name}</span>
<DeviceStatusBadge lastSeenAt={device.lastSeenAt} /> <DeviceStatusBadge lastSeenAt={device.lastSeenAt} />
</div> </div>
<p className="text-xs text-muted-foreground mt-1">{t('overview.last_seen', { time: relativeTime(device.lastSeenAt) })}</p> <p className="text-xs text-muted-foreground mt-1">
{devices.length > 1
? t('overview.terminals', { count: devices.length, time: relativeTime(device.lastSeenAt) })
: t('overview.last_seen', { time: relativeTime(device.lastSeenAt) })}
</p>
</> </>
) : ( ) : (
<div className="text-2xl font-bold">{t('overview.not_connected')}</div> <div className="text-2xl font-bold">{t('overview.not_connected')}</div>
@@ -1,13 +1,14 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { devicesApi, venuesApi } from '@/lib/api/client'; import { devicesApi, venuesApi, type Device } from '@/lib/api/client';
import { PageHeader } from '@/components/page-header'; import { PageHeader } from '@/components/page-header';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { DeviceStatusBadge } from '@/components/device-status-badge'; import { DeviceStatusBadge } from '@/components/device-status-badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { toast } from '@/components/ui/toast'; import { toast } from '@/components/ui/toast';
import { useLang } from '@/lib/i18n'; import { useLang } from '@/lib/i18n';
import { useSession } from '@/lib/session'; import { useSession } from '@/lib/session';
@@ -17,6 +18,8 @@ export default function SettingsPage() {
const { venueId, role } = useSession(); const { venueId, role } = useSession();
const [regDialog, setRegDialog] = useState<{ open: boolean; code: string; expiresAt: string } | null>(null); const [regDialog, setRegDialog] = useState<{ open: boolean; code: string; expiresAt: string } | null>(null);
const [generating, setGenerating] = useState(false); const [generating, setGenerating] = useState(false);
const [unpairing, setUnpairing] = useState<Device | null>(null);
const queryClient = useQueryClient();
const { t, te, relativeTime, dateTime, time } = useLang(); const { t, te, relativeTime, dateTime, time } = useLang();
const { data: venue } = useQuery({ const { data: venue } = useQuery({
@@ -26,12 +29,22 @@ export default function SettingsPage() {
const { data: deviceData } = useQuery({ const { data: deviceData } = useQuery({
queryKey: ['device', venueId], queryKey: ['device', venueId],
queryFn: () => devicesApi.get(venueId), queryFn: () => devicesApi.list(venueId),
// A terminal going offline sends no event, so its status is always polled // A terminal going offline sends no event, so its status is always polled
refetchInterval: 15000, 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() { async function handleGenerateCode() {
setGenerating(true); setGenerating(true);
@@ -71,17 +84,29 @@ export default function SettingsPage() {
<CardHeader> <CardHeader>
<CardTitle>{t('settings.device')}</CardTitle> <CardTitle>{t('settings.device')}</CardTitle>
<CardDescription> <CardDescription>
{device {devices.length === 0
? t('settings.paired', { name: device.name, time: relativeTime(device.lastSeenAt) }) ? t('settings.not_paired')
: 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 })}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{device ? ( {devices.length > 0 ? (
<div className="flex items-center gap-2 text-sm"> <div className="space-y-2">
{devices.map((device) => (
<div key={device.id} className="flex flex-wrap items-center gap-2 text-sm">
<Monitor className="h-4 w-4 text-muted-foreground" /> <Monitor className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{device.name}</span>
<DeviceStatusBadge lastSeenAt={device.lastSeenAt} /> <DeviceStatusBadge lastSeenAt={device.lastSeenAt} />
<span className="text-muted-foreground">{t('settings.since', { date: dateTime(device.createdAt) })}</span> <span className="text-muted-foreground">{t('settings.since', { date: dateTime(device.createdAt) })}</span>
{role === 'admin' && (
<Button variant="ghost" size="sm" className="ml-auto" onClick={() => setUnpairing(device)}>
{t('settings.unpair')}
</Button>
)}
</div>
))}
</div> </div>
) : ( ) : (
<p className="text-sm text-muted-foreground mb-3"> <p className="text-sm text-muted-foreground mb-3">
@@ -91,7 +116,7 @@ export default function SettingsPage() {
{role === 'admin' && ( {role === 'admin' && (
<Button onClick={handleGenerateCode} disabled={generating} size="sm" className="mt-3"> <Button onClick={handleGenerateCode} disabled={generating} size="sm" className="mt-3">
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
{generating ? t('settings.generating') : device ? t('settings.repair') : t('settings.pair')} {generating ? t('settings.generating') : devices.length > 0 ? t('settings.repair') : t('settings.pair')}
</Button> </Button>
)} )}
</CardContent> </CardContent>
@@ -99,6 +124,18 @@ export default function SettingsPage() {
</div> </div>
<ConfirmDialog
open={unpairing !== null}
onOpenChange={(open) => !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 && ( {regDialog && (
<Dialog open={regDialog.open} onOpenChange={(open) => !open && setRegDialog(null)}> <Dialog open={regDialog.open} onOpenChange={(open) => !open && setRegDialog(null)}>
<DialogContent> <DialogContent>
+9 -33
View File
@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; 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 { commandsApi, plannedTabsApi, remindersApi, tabsApi, type Tab, type TabLine } from '@/lib/api/client';
import { PageHeader } from '@/components/page-header'; import { PageHeader } from '@/components/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -40,7 +40,6 @@ export default function TabsPage() {
const [closeTab, setCloseTab] = useState<Tab | null>(null); const [closeTab, setCloseTab] = useState<Tab | null>(null);
const [paymentMethod, setPaymentMethod] = useState(''); const [paymentMethod, setPaymentMethod] = useState('');
const [expandedTabs, setExpandedTabs] = useState<Set<string>>(new Set()); const [expandedTabs, setExpandedTabs] = useState<Set<string>>(new Set());
const [watchedCommandIds, setWatchedCommandIds] = useState<Set<string>>(new Set());
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [sort, setSort] = useState<SortKey>('name'); const [sort, setSort] = useState<SortKey>('name');
const [plannedName, setPlannedName] = useState(''); const [plannedName, setPlannedName] = useState('');
@@ -61,14 +60,6 @@ export default function TabsPage() {
}); });
const closingTabIds = new Set((pendingQuery.data?.commands ?? []).map((c) => c.tabId)); 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({ const plannedQuery = useQuery({
queryKey: ['planned-tabs', venueId], queryKey: ['planned-tabs', venueId],
queryFn: () => plannedTabsApi.list(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 // Tiles only show customers the terminal has not opened a tab for yet
const pendingPlanned = (plannedQuery.data?.plannedTabs ?? []).filter((pt) => !pt.deliveredAt); 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({ const createCommandMutation = useMutation({
mutationFn: ({ tab, paymentMethod }: { tab: Tab; paymentMethod: string }) => mutationFn: ({ tab, paymentMethod }: { tab: Tab; paymentMethod: string }) =>
commandsApi.create(venueId, { tabId: tab.tabId, expectedVersion: tab.version, paymentMethod, type: 'close_tab' }), commandsApi.create(venueId, { tabId: tab.tabId, expectedVersion: tab.version, paymentMethod, type: 'close_tab' }),
onSuccess: (result) => { onSuccess: (result, { tab }) => {
setWatchedCommandIds((current) => new Set(current).add(result.id)); toast({
toast({ title: t('tabs.request_sent'), description: result.warning || t('tabs.request_sent_desc'), variant: result.warning ? 'default' : 'success' }); title: t('tabs.settled'),
description: t('tabs.settled_desc', { name: tab.customerName, amount: formatCents(result.amountInCents) }),
variant: 'success',
});
queryClient.invalidateQueries({ queryKey: ['tabs'] });
}, },
onError: (err) => { onError: (err) => {
toast({ title: t('common.failed'), description: te(err), variant: 'destructive' }); toast({ title: t('common.failed'), description: te(err), variant: 'destructive' });
@@ -325,7 +301,7 @@ export default function TabsPage() {
</TableCell> </TableCell>
<TableCell className="tabular-nums">{formatCents(tab.totalInCents)}</TableCell> <TableCell className="tabular-nums">{formatCents(tab.totalInCents)}</TableCell>
<TableCell>{tab.itemCount}</TableCell> <TableCell>{tab.itemCount}</TableCell>
<TableCell>{relativeTime(tab.lastReportedAt)}</TableCell> <TableCell>{relativeTime(tab.lastChangedAt)}</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
{closingTabIds.has(tab.tabId) ? ( {closingTabIds.has(tab.tabId) ? (
<Badge variant="warning">{t('tabs.closing')}</Badge> <Badge variant="warning">{t('tabs.closing')}</Badge>
+9 -9
View File
@@ -9,6 +9,7 @@ export type Role = 'admin' | 'viewer';
export type TabLine = { productName: string; quantity: number; unitPriceInCents: number }; 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 { export interface Tab {
tabId: string; tabId: string;
customerName: string; customerName: string;
@@ -16,11 +17,7 @@ export interface Tab {
totalInCents: number; totalInCents: number;
itemCount: number; itemCount: number;
items: TabLine[] | null; items: TabLine[] | null;
deviceId: string; lastChangedAt: string;
deviceName: string;
deviceLastSeenAt: string;
deviceRevokedAt: string | null;
lastReportedAt: string;
plannedTabId: string | null; plannedTabId: string | null;
email: string | null; email: string | null;
} }
@@ -40,7 +37,8 @@ export interface Command {
createdAt: string; createdAt: string;
processedAt: string | null; processedAt: string | null;
result: string | null; result: string | null;
deviceId: string; /** Null for a tab the server settled itself */
deviceId: string | null;
} }
export interface Device { export interface Device {
@@ -259,8 +257,10 @@ export const tabsApi = {
// Devices API // Devices API
export const devicesApi = { export const devicesApi = {
get: (venueId: string) => list: (venueId: string) =>
api<{ device: Device | null }>(`/venues/${venueId}/devices`), 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'), 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 }) => 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', method: 'POST',
body: JSON.stringify(data), body: JSON.stringify(data),
}), }),
+36 -22
View File
@@ -29,6 +29,8 @@ const translations: Record<Lang, Record<string, string>> = {
'errors.version_mismatch': 'The tab changed since it was loaded. Check it and try again.', '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.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_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.device_revoked': 'The terminal for this tab has been unpaired',
'errors.name_exists': 'This customer already exists', 'errors.name_exists': 'This customer already exists',
'errors.email_exists': 'A user with this email already exists', 'errors.email_exists': 'A user with this email already exists',
@@ -90,14 +92,13 @@ const translations: Record<Lang, Record<string, string>> = {
'tabs.lines_empty': 'No items on this tab.', 'tabs.lines_empty': 'No items on this tab.',
'tabs.each': 'each', 'tabs.each': 'each',
'tabs.close_title': "Close {name}'s tab?", '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.payment': 'Payment method',
'tabs.select_payment': 'Please select a payment method', 'tabs.select_payment': 'Please select a payment method',
'tabs.send': 'Send close request', 'tabs.send': 'Send close request',
'tabs.sending': 'Sending...', 'tabs.sending': 'Sending...',
'tabs.request_sent': 'Close request sent', 'tabs.settled': 'Tab settled',
'tabs.request_sent_desc': 'Sent to the terminal', 'tabs.settled_desc': '{name}: {amount}',
'tabs.request_result': 'Close request for {name}: {status}',
'tabs.send_test': 'Send test email', 'tabs.send_test': 'Send test email',
'tabs.test_sent': 'Test email sent', 'tabs.test_sent': 'Test email sent',
'tabs.test_sent_desc': 'Sent to {email}', 'tabs.test_sent_desc': 'Sent to {email}',
@@ -185,7 +186,12 @@ const translations: Record<Lang, Record<string, string>> = {
'settings.since': 'Since {date}', 'settings.since': 'Since {date}',
'settings.pair_desc': 'Pair a bar terminal to start receiving tabs.', 'settings.pair_desc': 'Pair a bar terminal to start receiving tabs.',
'settings.pair': 'Pair Terminal', '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.generating': 'Generating...',
'settings.pair_title': 'Pair a Terminal', 'settings.pair_title': 'Pair a Terminal',
'settings.pair_instructions': 'Enter this code on the bar terminal to connect it to your venue.', 'settings.pair_instructions': 'Enter this code on the bar terminal to connect it to your venue.',
@@ -208,6 +214,7 @@ const translations: Record<Lang, Record<string, string>> = {
'overview.no_commands': 'No commands yet', 'overview.no_commands': 'No commands yet',
'overview.not_connected': 'Not connected', 'overview.not_connected': 'Not connected',
'overview.last_seen': 'Last seen {time}', 'overview.last_seen': 'Last seen {time}',
'overview.terminals': '{count} terminals, last seen {time}',
'auth.login': 'Sign in', 'auth.login': 'Sign in',
'auth.logging_in': 'Signing in...', 'auth.logging_in': 'Signing in...',
'auth.email': 'Email', 'auth.email': 'Email',
@@ -293,27 +300,27 @@ const translations: Record<Lang, Record<string, string>> = {
'stock.out': 'Out', 'stock.out': 'Out',
'stock.adjust': 'Adjust', 'stock.adjust': 'Adjust',
'stock.start_tracking': 'Track stock', '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.cancel_adjustment': 'Cancel {change} for {name}',
'stock.cancelled': 'Change cancelled', '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_add': 'Add (delivery)',
'stock.mode_remove': 'Take out (breakage, own use…)', 'stock.mode_remove': 'Take out (breakage, own use…)',
'stock.mode_count': 'Count (set the stock to what you counted)', 'stock.mode_count': 'Count (set the stock to what you counted)',
'stock.quantity': 'Quantity', 'stock.quantity': 'Quantity',
'stock.counted': 'Counted', 'stock.counted': 'Counted',
'stock.preview': '{from} → {to}', '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': 'Note (optional)',
'stock.note_placeholder': 'e.g. delivery from the brewery', 'stock.note_placeholder': 'e.g. delivery from the brewery',
'stock.threshold': 'Warn when stock is at or below', 'stock.threshold': 'Warn when stock is at or below',
'stock.threshold_none': 'No warning', 'stock.threshold_none': 'No warning',
'stock.threshold_save': 'Save', 'stock.threshold_save': 'Save',
'stock.threshold_saved': 'Warning level saved', 'stock.threshold_saved': 'Warning level saved',
'stock.send': 'Send to terminal', 'stock.send': 'Save',
'stock.sending': 'Sending…', 'stock.sending': 'Sending…',
'stock.sent': 'Sent to the terminal', 'stock.sent': 'Stock updated',
'stock.sent_desc': 'The stock changes on the terminal at its next sync.', 'stock.sent_desc': 'The terminals show it after their next sync.',
'stock.delivery': 'Register delivery', 'stock.delivery': 'Register delivery',
'stock.delivery_title': '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.', '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<Lang, Record<string, string>> = {
'errors.version_mismatch': 'De poef is gewijzigd sinds ze geladen werd. Controleer en probeer opnieuw.', '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.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_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.device_revoked': 'De kassa van deze poef is ontkoppeld',
'errors.name_exists': 'Deze klant bestaat al', 'errors.name_exists': 'Deze klant bestaat al',
'errors.email_exists': 'Er bestaat al een gebruiker met dit e-mailadres', 'errors.email_exists': 'Er bestaat al een gebruiker met dit e-mailadres',
@@ -416,14 +425,13 @@ const translations: Record<Lang, Record<string, string>> = {
'tabs.lines_empty': 'Geen items op deze poef.', 'tabs.lines_empty': 'Geen items op deze poef.',
'tabs.each': 'per stuk', 'tabs.each': 'per stuk',
'tabs.close_title': 'Poef van {name} sluiten?', '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.payment': 'Betaalmethode',
'tabs.select_payment': 'Kies een betaalmethode', 'tabs.select_payment': 'Kies een betaalmethode',
'tabs.send': 'Sluitverzoek versturen', 'tabs.send': 'Sluitverzoek versturen',
'tabs.sending': 'Bezig met versturen...', 'tabs.sending': 'Bezig met versturen...',
'tabs.request_sent': 'Sluitverzoek verstuurd', 'tabs.settled': 'Poef afgerekend',
'tabs.request_sent_desc': 'Verstuurd naar de terminal', 'tabs.settled_desc': '{name}: {amount}',
'tabs.request_result': 'Sluitverzoek voor {name}: {status}',
'tabs.send_test': 'Testmail versturen', 'tabs.send_test': 'Testmail versturen',
'tabs.test_sent': 'Testmail verstuurd', 'tabs.test_sent': 'Testmail verstuurd',
'tabs.test_sent_desc': 'Verstuurd naar {email}', 'tabs.test_sent_desc': 'Verstuurd naar {email}',
@@ -511,7 +519,12 @@ const translations: Record<Lang, Record<string, string>> = {
'settings.since': 'Sinds {date}', 'settings.since': 'Sinds {date}',
'settings.pair_desc': 'Koppel een barterminal om poefs te ontvangen.', 'settings.pair_desc': 'Koppel een barterminal om poefs te ontvangen.',
'settings.pair': 'Terminal koppelen', '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.generating': 'Code aanmaken...',
'settings.pair_title': 'Terminal koppelen', 'settings.pair_title': 'Terminal koppelen',
'settings.pair_instructions': 'Voer deze code in op de barterminal om hem met je zaak te verbinden.', 'settings.pair_instructions': 'Voer deze code in op de barterminal om hem met je zaak te verbinden.',
@@ -534,6 +547,7 @@ const translations: Record<Lang, Record<string, string>> = {
'overview.no_commands': 'Nog geen opdrachten', 'overview.no_commands': 'Nog geen opdrachten',
'overview.not_connected': 'Niet verbonden', 'overview.not_connected': 'Niet verbonden',
'overview.last_seen': 'Laatst gezien {time}', 'overview.last_seen': 'Laatst gezien {time}',
'overview.terminals': '{count} terminals, laatst gezien {time}',
'auth.login': 'Inloggen', 'auth.login': 'Inloggen',
'auth.logging_in': 'Bezig met inloggen...', 'auth.logging_in': 'Bezig met inloggen...',
'auth.email': 'E-mail', 'auth.email': 'E-mail',
@@ -619,27 +633,27 @@ const translations: Record<Lang, Record<string, string>> = {
'stock.out': 'Op', 'stock.out': 'Op',
'stock.adjust': 'Aanpassen', 'stock.adjust': 'Aanpassen',
'stock.start_tracking': 'Voorraad bijhouden', '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.cancel_adjustment': '{change} voor {name} annuleren',
'stock.cancelled': 'Wijziging geannuleerd', '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_add': 'Bijvullen (levering)',
'stock.mode_remove': 'Afboeken (breuk, eigen gebruik…)', 'stock.mode_remove': 'Afboeken (breuk, eigen gebruik…)',
'stock.mode_count': 'Tellen (voorraad wordt wat je telde)', 'stock.mode_count': 'Tellen (voorraad wordt wat je telde)',
'stock.quantity': 'Aantal', 'stock.quantity': 'Aantal',
'stock.counted': 'Geteld', 'stock.counted': 'Geteld',
'stock.preview': '{from} → {to}', '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': 'Notitie (optioneel)',
'stock.note_placeholder': 'bv. levering brouwerij', 'stock.note_placeholder': 'bv. levering brouwerij',
'stock.threshold': 'Waarschuw vanaf', 'stock.threshold': 'Waarschuw vanaf',
'stock.threshold_none': 'Geen waarschuwing', 'stock.threshold_none': 'Geen waarschuwing',
'stock.threshold_save': 'Opslaan', 'stock.threshold_save': 'Opslaan',
'stock.threshold_saved': 'Waarschuwingsniveau opgeslagen', 'stock.threshold_saved': 'Waarschuwingsniveau opgeslagen',
'stock.send': 'Naar kassa sturen', 'stock.send': 'Opslaan',
'stock.sending': 'Versturen…', 'stock.sending': 'Versturen…',
'stock.sent': 'Naar de kassa gestuurd', 'stock.sent': 'Voorraad bijgewerkt',
'stock.sent_desc': 'De voorraad wijzigt op de kassa bij de volgende synchronisatie.', 'stock.sent_desc': 'De kassa\'s tonen het na hun volgende synchronisatie.',
'stock.delivery': 'Levering invoeren', 'stock.delivery': 'Levering invoeren',
'stock.delivery_title': 'Levering invoeren', 'stock.delivery_title': 'Levering invoeren',
'stock.delivery_desc': 'Vul in hoeveel er van elk product geleverd is. Lege producten blijven ongewijzigd.', 'stock.delivery_desc': 'Vul in hoeveel er van elk product geleverd is. Lege producten blijven ongewijzigd.',
+39 -36
View File
@@ -28,47 +28,48 @@ Swagger UI: `http://localhost:3001/api/docs`
### Venues ### Venues
`GET /venues` - List venues `GET /venues` - List venues
`GET /venues/:venueId` - Get venue details `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 ### 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/sync` - Sync device state (device auth)
`POST /devices/:deviceId/commands/:commandId/ack` - Acknowledge command (device auth) `POST /devices/:deviceId/commands/:commandId/ack` - Acknowledge command (device auth)
`GET /devices/generation-code` - Generate registration code (admin) `GET /devices/generation-code` - Generate registration code (admin)
Sync semantics: Sync semantics. The server holds the venue's open tabs and stock; a venue can have several terminals. Each terminal
- `openTabs` must be the device's **complete** list of open tabs. Tabs it no longer reports works offline, queues what it does as **events**, and on every sync replaces its tabs and stock with the server's and
(e.g. deleted on the terminal) are removed from the dashboard. Settling a tab on the terminal replays its unsent events on top. The full rules are in the app repository: `docs/server-tabs-source-of-truth.md`
keeps the tab open under the same name with a zero balance, so the name stays listed. and `docs/server-stock-source-of-truth.md`.
- Every sync returns **all** pending commands for the device; `cursor` is still returned but no longer - Every write to a venue's tabs, stock and settlements (syncs, dashboard settles, stock adjustments) runs in one
filters. Devices dedupe by command id and re-send the ack for commands they already processed. transaction that locks the venue row, so simultaneous terminals apply their changes one after the other.
- The first ack for a command wins; later acks are accepted as no-ops. - `events` (at most 1000): terminal-generated UUID `id`, `type`, `occurredAt` (ISO-8601, UTC; capped at the server
- `plannedTabNames` only contains planned customers the device has not opened a tab for yet. Once a sync time when more than 5 minutes ahead) and per type:
reports an open tab with that name (ignoring case and extra spaces), the customer is marked delivered - `tab_opened` (`tabId`, `customerName`, `openedAt`), `tab_renamed` (new `customerName`), `tab_deleted`
and no longer sent. The customer (and their email, used for reminders) is kept. - `line_added` / `line_removed`: `lineId`, `productId`, `productName`, `unitPriceInCents`, `quantity` (≥ 1)
- `events` (optional) reports what happened on tabs since the last sync, for sales statistics. Each event - `settled`: `amountInCents`, `paymentMethod` (`cash`, `payconiq`, `payment_pending` or `other`), optional
has a terminal-generated UUID `id`, a `type`, `tabId`, `customerName` and `occurredAt` (ISO-8601, UTC): `commandId` and `items` (`[{ productId?, productName, quantity, unitPriceInCents }]`)
- `line_added` / `line_removed`: `productName`, `unitPriceInCents`, `quantity` (always positive) - `stock_set`: `productId`, `quantity` (a count ≥ 0, or `null` to stop tracking); no `tabId`
- `settled`: `amountInCents`, `paymentMethod` (`cash`, `payconiq`, `payment_pending` or `other`),
optional `commandId` (the remote close that caused it) and `items` (the settled lines)
The response lists `acknowledgedEventIds` (stored, including events already stored by an earlier sync) Each id is applied once (tab state and stock in the same transaction). `acknowledgedEventIds` lists the applied
and `rejectedEvents` (`[{ id, reason }]`, invalid and never stored). The terminal removes both from its ones, including repeats; `rejectedEvents` (`[{ id, reason }]`) were invalid and never applied. The terminal drops
outbox. An invalid event never fails the sync. At most 1000 events per sync. both from its outbox. An invalid event never fails the sync.
- `products` (optional) is the terminal's **complete** product catalog: - The response's `tabs` is the venue's **complete** open tab state after this request's events:
`[{ id, name, category?, priceInCents, stock }]`, where `stock` is an integer (may be negative) or `null` when `[{ id, customerName, openedAt, version, lines: [{ id, productId, productName, unitPriceInCents, quantity,
the terminal doesn't track stock for that product. Products left out are removed from the dashboard. When the units: [{ id: "<event id>:<index>", addedAt, removedAt }] }] }]`.
field is omitted (older app versions) the stored products are left alone. An invalid product is skipped and - `stock` is every product of the venue: `[{ productId, stock, countedAt }]` (`stock` null when not tracked, may be
logged, never failing the sync. At most 2000 products. negative). Line events change it unless they happened before `countedAt`; the latest count wins.
- `stockAdjustments` in the response lists **every** pending stock change requested from the dashboard - `settlements` lists the venue's settlements after `settlementsCursor` (or of the last 30 days without one), oldest
for the venue, oldest first: `[{ id, productId, type, quantity, note, requestedBy, createdAt }]`. first, at most 500; the response's `settlementsCursor` is sent back next time.
`type: "add"` changes the stock by `quantity` (negative takes stock out); `type: "set"` makes the stock - `openTabs` is only used once per venue, on the first sync after the server started holding the tabs: the terminal's
`quantity` (a count, ≥ 0, and starts tracking an untracked product). They are re-sent on every sync until the own tabs (and catalog stock) are taken over and the events of that request are stored without applying them again.
terminal reports an outcome, so the terminal must apply each id only once. - `products` (optional, at most 2000) upserts names, categories and prices. Its `stock` is ignored (counts arrive as
- `stockAdjustmentResults` (optional) reports outcomes: `[{ id, status: "applied" | "rejected", stockAfter?, reason? }]` `stock_set`), a new product starts untracked, and products are never removed because one terminal doesn't report
(`stockAfter` is required for `applied`). The response lists `acknowledgedStockAdjustmentResultIds` and them. An invalid product is skipped and logged.
`rejectedStockAdjustmentResults` (`[{ id, reason }]`); the terminal drops both from its outbox. The terminal's - Planned customers are opened as tabs by the server, once; `plannedTabNames` is always empty.
outcome is recorded even if the adjustment was cancelled on the dashboard in the meantime. - 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 ### Tabs
`GET /venues/:venueId/tabs` - List open 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) `PUT /venues/:venueId/stock/products/:productId` - Set `{ lowStockThreshold: number | null }` (admin, dashboard only)
### Commands ### 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 `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. All `/venues/:venueId/...` routes return `403 venue_forbidden` unless the user belongs to that venue.