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[]
products Product[]
stockAdjustments StockAdjustment[]
tabs Tab[]
settlements Settlement[]
// Set on the first sync after the server started holding the tabs: the tabs were then taken over from the
// terminal's own open tabs, once
tabsSeededAt DateTime?
}
model Device {
id String @id @default(uuid())
venueId String @unique
// A venue can have several terminals
venueId String
name String
tokenHash String @unique
tokenPrefix String
@@ -42,6 +48,7 @@ model Device {
tabOwnerships TabOwnership[]
remoteCommands RemoteCommand[]
tabEvents TabEvent[]
settlements Settlement[]
@@index([venueId, revokedAt])
}
@@ -105,10 +112,13 @@ model TabOwnership {
@@id([venueId, tabId])
}
// A request from the dashboard. close_tab used to be executed by the owning terminal; the server now settles
// the tab itself and records the command as completed right away, for the history.
model RemoteCommand {
id String @id @default(uuid())
venueId String
deviceId String
// Null for a command the server carried out itself
deviceId String?
targetTabId String
customerName String?
amountInCents Int?
@@ -123,7 +133,7 @@ model RemoteCommand {
expiresAt DateTime
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([venueId, status, createdAt])
@@ -188,16 +198,19 @@ model TabReminder {
@@index([venueId, tabId])
}
// What happened on a tab, as reported by the terminal: lines added or removed, and settlements.
// The id is generated by the terminal so a re-sent event is stored only once.
// What happened on a tab, as reported by the terminal. The id is generated by the terminal so a re-sent event is
// stored, and applied to the tab state and stock, only once.
model TabEvent {
id String @id
venueId String
deviceId String
tabId String
customerName String
// line_added | line_removed | settled
// Null for stock_set, which is not about a tab
tabId String?
customerName String?
// tab_opened | tab_renamed | tab_deleted | line_added | line_removed | settled | stock_set
type String
lineId String?
productId String?
productName String?
unitPriceInCents Int?
// Signed: positive for line_added, negative for line_removed
@@ -207,6 +220,8 @@ model TabEvent {
paymentMethod String?
commandId String?
items Json?
// stock_set: the counted stock, null to stop tracking
stockSetTo Int?
occurredAt DateTime
receivedAt DateTime @default(now())
@@ -217,8 +232,9 @@ model TabEvent {
@@index([venueId, type, occurredAt])
}
// The terminal's product catalog with its stock, as last reported by the terminal. The terminal owns the
// stock: it counts sales down offline, so the dashboard only changes it through StockAdjustments.
// The venue's products as reported by its terminals, with the stock. The server owns the stock: it follows the
// terminals' line events and stock counts and the dashboard's StockAdjustments. A product is never removed because
// one terminal doesn't report it.
model Product {
venueId String
// Generated by the terminal
@@ -226,8 +242,10 @@ model Product {
name String
category String?
priceInCents Int
// Null when the terminal doesn't track stock for this product. Can be negative: a sale is never blocked.
// Null when stock is not tracked for this product. Can be negative: a sale is never blocked.
stock Int?
// When the stock was last counted; line events that happened before it are already in the count
countedAt DateTime?
// Dashboard-only setting, never sent to or overwritten by the terminal
lowStockThreshold Int?
reportedAt DateTime
@@ -237,8 +255,8 @@ model Product {
@@id([venueId, productId])
}
// A stock change requested from the dashboard. It is sent to the terminal on every sync until the terminal
// reports it applied or rejected.
// A stock change made on the dashboard. The server applies it right away. Older ones may still have been applied
// by a terminal, which reported the outcome.
model StockAdjustment {
id String @id @default(uuid())
venueId String
@@ -252,7 +270,7 @@ model StockAdjustment {
requestedById String
// pending | applied | rejected | cancelled
status String @default("pending")
// The terminal's stock right after applying it
// The stock right after applying it
stockAfter Int?
result String?
createdAt DateTime @default(now())
@@ -263,3 +281,49 @@ model StockAdjustment {
@@index([venueId, status, createdAt])
@@index([venueId, productId, createdAt])
}
// An open tab of the venue: the source of truth the terminals replace their own tabs with on every sync. Tabs are
// never closed, only emptied by a settlement; tab_deleted removes one.
model Tab {
venueId String
// Generated by the terminal that opened it
id String
customerName String
openedAt DateTime
// Bumped by every change, so the dashboard can refuse to settle a tab that changed since it was shown
version Int @default(0)
// [{ id, productId, productName, unitPriceInCents, units: [{ id, addedAt, removedAt }] }]
lines Json
updatedAt DateTime @updatedAt
venue Venue @relation(fields: [venueId], references: [id])
@@id([venueId, id])
}
// A settlement made on a terminal (id = the settled event's id) or on the dashboard. Terminals read them as a feed
// after their cursor, which is the sequence number.
model Settlement {
id String @id
sequence Int @unique @default(autoincrement())
venueId String
tabId String
customerName String
settledAt DateTime
// cash | payconiq | payment_pending | other
paymentMethod String
amountInCents Int
// The settled lines, in the shape of Tab.lines with each line's settled and removed units
lines Json
// Who settled it: a terminal, or a dashboard user
deviceId String?
requestedBy String?
commandId String?
createdAt DateTime @default(now())
venue Venue @relation(fields: [venueId], references: [id])
device Device? @relation(fields: [deviceId], references: [id])
@@index([venueId, sequence])
@@index([venueId, settledAt])
}
+3 -20
View File
@@ -145,25 +145,9 @@ export class AuthService {
}
const { rawToken, hash, prefix, expiresAt } = await this.generateDeviceToken();
let deviceId: string;
// One device per venue — update existing or create new
const existing = await this.prisma.device.findFirst({ where: { venueId } });
if (existing) {
deviceId = existing.id;
await this.prisma.device.update({
where: { id: existing.id },
data: {
name,
tokenHash: hash,
tokenPrefix: prefix,
tokenExpiresAt: expiresAt,
revokedAt: null,
lastSeenAt: new Date(),
},
});
} else {
deviceId = uuidv4();
// A venue can have several terminals: every pairing adds one. A terminal that is replaced is unpaired on the
// dashboard
const deviceId = uuidv4();
await this.prisma.device.create({
data: {
id: deviceId,
@@ -174,7 +158,6 @@ export class AuthService {
tokenExpiresAt: expiresAt,
},
});
}
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 { Prisma } from '@prisma/client';
import { CommandsService } from './commands.service';
import { createFakePrisma } from '../testing/fake-prisma';
const user = { id: 'u1', name: 'Staff', email: 'staff@example.com' };
const dto = { tabId: 't1', type: 'close_tab', expectedVersion: 3, paymentMethod: 'cash' };
function createService({
ownership = { deviceId: 'd1' } as object | null,
device = { id: 'd1', revokedAt: null, lastSeenAt: new Date() } as object | null,
snapshot = { customerName: 'Jan', totalInCents: 2500, version: 3 } as object | null,
pending = null as object | null,
} = {}) {
const tx = {
tabOwnership: { findUnique: jest.fn().mockResolvedValue(ownership) },
device: { findUnique: jest.fn().mockResolvedValue(device) },
openTabSnapshot: { findUnique: jest.fn().mockResolvedValue(snapshot) },
remoteCommand: {
findFirst: jest.fn().mockResolvedValue(pending),
create: jest.fn(async ({ data }) => data),
},
auditLog: { create: jest.fn() },
};
const prisma = { $transaction: jest.fn((fn: (t: typeof tx) => unknown) => Promise.resolve().then(() => fn(tx))) };
const unit = (id: string, removed = false) => (
{ id, addedAt: '2026-09-24T20:00:00.000Z', removedAt: removed ? '2026-09-24T20:01:00.000Z' : null });
function createService({ lines = [] as unknown[] } = {}) {
const prisma = createFakePrisma();
prisma.venue.rows.push({ id: 'v1', tabsSeededAt: new Date('2026-10-01') });
prisma.tab.rows.push({
venueId: 'v1', id: 't1', customerName: 'Jan', openedAt: new Date('2026-09-24T19:00:00Z'), version: 3, lines,
updatedAt: new Date(),
});
const realtime = { notify: jest.fn() };
const service = new CommandsService(prisma as any, { log: jest.fn() } as any, realtime as any);
return { service, prisma, tx, realtime };
return { service, prisma, realtime };
}
const colaLine = {
id: 'line-1', productId: 'cola', productName: 'Cola', unitPriceInCents: 250,
units: [unit('u1'), unit('u2'), unit('u3', true)],
};
describe('CommandsService.create', () => {
it('queues a close command with the customer and amount it was sent for', async () => {
const { service, prisma, tx, realtime } = createService();
it('settles everything on the tab at once and records it as completed', async () => {
const { service, prisma, realtime } = createService({ lines: [colaLine] });
const result = await service.create('v1', dto, user);
expect(result.status).toBe('pending');
expect(tx.remoteCommand.create).toHaveBeenCalledWith({
data: expect.objectContaining({
venueId: 'v1',
deviceId: 'd1',
targetTabId: 't1',
customerName: 'Jan',
amountInCents: 2500,
expectedVersion: 3,
paymentMethod: 'cash',
requestedBy: 'Staff',
}),
expect(result).toMatchObject({ status: 'completed', amountInCents: 500 });
expect(prisma.tab.rows[0]).toMatchObject({ version: 4, lines: [] });
expect(prisma.settlement.rows).toHaveLength(1);
expect(prisma.settlement.rows[0]).toMatchObject({
tabId: 't1', customerName: 'Jan', paymentMethod: 'cash', amountInCents: 500, requestedBy: 'Staff', deviceId: null,
});
expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), { isolationLevel: 'Serializable' });
expect(realtime.notify).toHaveBeenCalledWith('v1', 'commands');
// The struck-through drink goes along, so the history shows it
expect(prisma.settlement.rows[0].lines).toMatchObject([{ quantity: 2, units: [{ id: 'u1' }, { id: 'u2' }, { id: 'u3' }] }]);
expect(prisma.remoteCommand.rows[0]).toMatchObject({
id: result.id, deviceId: null, status: 'completed', customerName: 'Jan', amountInCents: 500,
});
expect(realtime.notify).toHaveBeenCalledWith('v1', 'commands', 'tabs');
});
it('rejects a tab no device owns', async () => {
const { service } = createService({ ownership: null });
it('rejects a tab that is not open', async () => {
const { service } = createService({ lines: [colaLine] });
await expect(service.create('v1', dto, user)).rejects.toBeInstanceOf(NotFoundException);
await expect(service.create('v1', { ...dto, tabId: 't9' }, user)).rejects.toBeInstanceOf(NotFoundException);
});
it('rejects a tab that changed since it was loaded', async () => {
const { service, tx } = createService({ snapshot: { customerName: 'Jan', totalInCents: 3000, version: 4 } });
it('rejects a tab that changed since it was loaded, changing nothing', async () => {
const { service, prisma } = createService({ lines: [colaLine] });
await expect(service.create('v1', dto, user)).rejects.toMatchObject({ response: { code: 'version_mismatch' } });
expect(tx.remoteCommand.create).not.toHaveBeenCalled();
await expect(service.create('v1', { ...dto, expectedVersion: 2 }, user))
.rejects.toMatchObject({ response: { code: 'version_mismatch' } });
expect(prisma.settlement.rows).toEqual([]);
});
it('rejects a tab whose device was revoked', async () => {
const { service } = createService({ device: { id: 'd1', revokedAt: new Date(), lastSeenAt: new Date() } });
it('rejects a tab with nothing on it', async () => {
const { service } = createService({ lines: [] });
await expect(service.create('v1', dto, user)).rejects.toMatchObject({ response: { code: 'device_revoked' } });
await expect(service.create('v1', dto, user)).rejects.toBeInstanceOf(ConflictException);
});
it('rejects a second command while one is pending', async () => {
const { service, tx } = createService({ pending: { id: 'c0' } });
it('rejects an unknown payment method or command type', async () => {
const { service } = createService({ lines: [colaLine] });
await expect(service.create('v1', dto, user)).rejects.toMatchObject({ response: { code: 'command_already_pending' } });
expect(tx.remoteCommand.create).not.toHaveBeenCalled();
});
it('reports a lost race between two simultaneous requests as already pending', async () => {
const { service, prisma } = createService();
prisma.$transaction.mockRejectedValue(
new Prisma.PrismaClientKnownRequestError('serialization failure', { code: 'P2034', clientVersion: 'test' }),
);
const error = await service.create('v1', dto, user).catch((e) => e);
expect(error).toBeInstanceOf(ConflictException);
expect(error.response.code).toBe('command_already_pending');
});
it('warns when the terminal has not been seen recently', async () => {
const { service } = createService({ device: { id: 'd1', revokedAt: null, lastSeenAt: new Date(Date.now() - 60 * 60 * 1000) } });
const result = await service.create('v1', dto, user);
expect(result).toHaveProperty('warning');
await expect(service.create('v1', { ...dto, paymentMethod: 'bitcoin' }, user))
.rejects.toMatchObject({ response: { code: 'invalid_payment_method' } });
await expect(service.create('v1', { ...dto, type: 'open_tab' }, user))
.rejects.toMatchObject({ response: { code: 'invalid_command_type' } });
});
});
+37 -74
View File
@@ -5,12 +5,11 @@ import {
ConflictException,
} from '@nestjs/common';
import { v4 as uuidv4 } from 'uuid';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { RealtimeService } from '../realtime/realtime.service';
import { CreateCommandDto } from './dto/create-command.dto';
import { COMMAND_EXPIRY_HOURS, DEVICE_OFFLINE_THRESHOLD_MS } from '@kooltap/shared';
import { VenueLedger } from '../tabs/venue-ledger';
@Injectable()
export class CommandsService {
@@ -20,6 +19,11 @@ export class CommandsService {
private realtime: RealtimeService,
) {}
/**
* Settles a tab on the server, at once, also when every terminal is offline: everything still on the tab is
* settled, and the terminals see the lines disappear on their next sync. Recorded as a completed command for the
* history.
*/
async create(venueId: string, dto: CreateCommandDto, user: any) {
if (!dto.paymentMethod) {
throw new BadRequestException({
@@ -36,72 +40,50 @@ export class CommandsService {
});
}
// Serializable so two simultaneous requests for the same tab can't both pass the pending check
const type = dto.type || 'close_tab';
if (type !== 'close_tab') {
throw new BadRequestException({ code: 'invalid_command_type', message: `Unknown command type: ${type}` });
}
const requestedBy = user.name || user.email;
const result = await this.prisma.$transaction(async (tx) => {
// Resolve tab ownership
const ownership = await tx.tabOwnership.findUnique({
where: { venueId_tabId: { venueId, tabId: dto.tabId } },
});
const now = new Date();
const ledger = await VenueLedger.open(tx, venueId);
if (!ownership) {
throw new NotFoundException({
code: 'tab_not_owned',
message: 'Tab is not associated with any device',
});
const tab = ledger.tabs.get(dto.tabId);
if (!tab) {
throw new NotFoundException({ code: 'tab_not_found', message: 'This tab is no longer open' });
}
const device = await tx.device.findUnique({ where: { id: ownership.deviceId } });
if (!device) {
throw new NotFoundException({
code: 'device_not_found',
message: 'Owning device not found',
});
}
if (device.revokedAt) {
throw new ConflictException({
code: 'device_revoked',
message: 'The device owning this tab has been revoked',
});
}
const snapshot = await tx.openTabSnapshot.findUnique({
where: { deviceId_tabId: { deviceId: device.id, tabId: dto.tabId } },
});
if (snapshot && snapshot.version !== dto.expectedVersion) {
if (tab.version !== dto.expectedVersion) {
throw new ConflictException({
code: 'version_mismatch',
message: 'The tab has changed since it was loaded. Refresh and try again.',
});
}
const alreadyPending = await tx.remoteCommand.findFirst({
where: { venueId, targetTabId: dto.tabId, status: 'pending' },
});
if (alreadyPending) {
throw new ConflictException({
code: 'command_already_pending',
message: 'A command for this tab is already pending',
});
const id = uuidv4();
const settlement = ledger.tabs.settleAll(dto.tabId, { id, settledAt: now, paymentMethod: dto.paymentMethod });
if (!settlement) {
throw new ConflictException({ code: 'tab_empty', message: 'There is nothing on this tab to settle' });
}
const now = new Date();
const expiresAt = new Date(now.getTime() + COMMAND_EXPIRY_HOURS * 60 * 60 * 1000);
ledger.settlements.push({ ...settlement, commandId: id, requestedBy });
await ledger.save(tx, now);
const command = await tx.remoteCommand.create({
data: {
id: uuidv4(),
id,
venueId,
deviceId: device.id,
deviceId: null,
targetTabId: dto.tabId,
customerName: snapshot?.customerName ?? null,
amountInCents: snapshot?.totalInCents ?? null,
type: dto.type || 'close_tab',
customerName: settlement.customerName,
amountInCents: settlement.amountInCents,
type,
expectedVersion: dto.expectedVersion,
paymentMethod: dto.paymentMethod,
requestedBy: user.name || user.email,
status: 'pending',
expiresAt,
requestedBy,
status: 'completed',
acknowledgedAt: now,
expiresAt: now,
},
});
@@ -111,39 +93,20 @@ export class CommandsService {
venueId,
actorType: 'client_user',
actorId: user.id,
action: 'command_created',
action: 'tab_settled',
details: JSON.stringify({
commandId: command.id,
tabId: dto.tabId,
type: dto.type,
paymentMethod: dto.paymentMethod,
deviceId: device.id,
amountInCents: settlement.amountInCents,
}),
},
});
// Check if device is stale
const lastSeenAgo = now.getTime() - new Date(device.lastSeenAt).getTime();
const warning = lastSeenAgo > DEVICE_OFFLINE_THRESHOLD_MS
? { warning: 'Device has not been seen recently' }
: {};
return {
id: command.id,
status: command.status,
...warning,
};
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }).catch((error) => {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2034') {
throw new ConflictException({
code: 'command_already_pending',
message: 'A command for this tab is already pending',
});
}
throw error;
return { id: command.id, status: command.status, amountInCents: settlement.amountInCents };
});
this.realtime.notify(venueId, 'commands');
this.realtime.notify(venueId, 'commands', 'tabs');
return result;
}
+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 { 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({
previous = [] as Snapshot[], delivered = 0, expired = 0, products = [] as StoredProduct[], pendingStock = [] as unknown[],
recordedResults = 1,
} = {}) {
const tx = {
tabOwnership: { deleteMany: jest.fn(), upsert: jest.fn(), updateMany: jest.fn() },
openTabSnapshot: { deleteMany: jest.fn(), upsert: jest.fn(), updateMany: jest.fn() },
plannedTab: { updateMany: jest.fn().mockResolvedValue({ count: delivered }) },
device: { update: jest.fn() },
tabEvent: { createMany: jest.fn() },
product: { deleteMany: jest.fn(), updateMany: jest.fn(), upsert: jest.fn() },
stockAdjustment: { updateMany: jest.fn().mockResolvedValue({ count: recordedResults }) },
};
const prisma = {
openTabSnapshot: { findMany: jest.fn().mockResolvedValue(previous) },
tabOwnership: { findMany: jest.fn().mockResolvedValue([]) },
$transaction: jest.fn((fn: (t: typeof tx) => unknown) => fn(tx)),
remoteCommand: { updateMany: jest.fn().mockResolvedValue({ count: expired }), findMany: jest.fn().mockResolvedValue([]) },
plannedTab: { findMany: jest.fn().mockResolvedValue([{ customerName: 'Els' }]) },
product: { findMany: jest.fn().mockResolvedValue(products) },
stockAdjustment: { findMany: jest.fn().mockResolvedValue(pendingStock) },
};
const audit = { log: jest.fn() };
function setup({ seeded = true } = {}) {
const prisma = createFakePrisma();
prisma.venue.rows.push({ id: 'v1', tabsSeededAt: seeded ? new Date('2026-10-01') : null });
const tabletA: Device = { id: 'd1', venueId: 'v1', revokedAt: null, lastSeenAt: new Date() };
const tabletB: Device = { id: 'd2', venueId: 'v1', revokedAt: null, lastSeenAt: new Date() };
prisma.device.rows.push({ ...tabletA }, { ...tabletB });
const realtime = { notify: jest.fn() };
const service = new DevicesService(prisma as any, audit as any, realtime as any);
return { service, prisma, tx, realtime };
const service = new DevicesService(prisma as any, { log: jest.fn() } as any, realtime as any);
const sync = (device: Device, body: Partial<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() };
const tab = (id: string, customerName: string) => ({ id, customerName, version: 1, totalInCents: 0, itemCount: 0 });
/** Builds the events a terminal sends, with fresh ids. */
const events = {
opened: (tabId: string, customerName: string, minutes = 0) => (
{ id: uuidv4(), type: 'tab_opened', tabId, customerName, occurredAt: at(minutes), openedAt: at(minutes) }),
renamed: (tabId: string, customerName: string, minutes = 0) => (
{ id: uuidv4(), type: 'tab_renamed', tabId, customerName, occurredAt: at(minutes) }),
deleted: (tabId: string, customerName: string, minutes = 0) => (
{ id: uuidv4(), type: 'tab_deleted', tabId, customerName, occurredAt: at(minutes) }),
line: (type: 'line_added' | 'line_removed', tabId: string, quantity: number, {
minutes = 0, lineId = undefined as string | undefined, productId = 'cola', productName = 'Cola', price = 250,
} = {}) => ({
id: uuidv4(), type, tabId, customerName: 'Alice', occurredAt: at(minutes),
lineId, productId, productName, unitPriceInCents: price, quantity,
}),
settled: (tabId: string, quantity: number, minutes = 0) => ({
id: uuidv4(), type: 'settled', tabId, customerName: 'Alice', occurredAt: at(minutes),
amountInCents: quantity * 250, paymentMethod: 'payconiq',
items: [{ productId: 'cola', productName: 'Cola', quantity, unitPriceInCents: 250 }],
}),
stockSet: (productId: string, quantity: number | null, minutes = 0) => (
{ id: uuidv4(), type: 'stock_set', productId, quantity, occurredAt: at(minutes) }),
};
describe('DevicesService.sync planned customers', () => {
it('marks planned customers delivered once the device reports a tab with their name, ignoring case', async () => {
const { service, tx } = createService();
const catalog = (stock: number | null = 0) => [
{ id: 'cola', name: 'Cola', category: 'Frisdrank', priceInCents: 250, stock },
];
await service.sync(device, { deviceId: 'd1', openTabs: [tab('t1', 'Jan'), tab('t2', ' piet Peeters')] });
function tab(result: SyncResult, tabId: string) {
return result.tabs.find((t) => t.id === tabId);
}
expect(tx.plannedTab.updateMany).toHaveBeenCalledWith({
where: { venueId: 'v1', deliveredAt: null, customerNameKey: { in: ['jan', 'piet peeters'] } },
data: { deliveredAt: expect.any(Date) },
function colaStock(result: SyncResult) {
return result.stock.find((s) => s.productId === 'cola')?.stock;
}
describe('DevicesService.sync: the server holds the tabs', () => {
it('shows a newly paired tablet the open tabs of the venue', async () => {
const { sync, tabletA, tabletB } = setup();
await sync(tabletA, { events: [events.opened('t1', 'Alice'), events.line('line_added', 't1', 2)] });
const result = await sync(tabletB);
expect(result.tabs).toHaveLength(1);
expect(result.tabs[0]).toMatchObject({ id: 't1', customerName: 'Alice', version: 2 });
expect(result.tabs[0].lines).toMatchObject([{ productId: 'cola', productName: 'Cola', quantity: 2 }]);
});
it('gives units the ids the tablets give them when replaying', async () => {
const { sync, tabletA } = setup();
const added = events.line('line_added', 't1', 2, { lineId: 'line-1' });
const result = await sync(tabletA, { events: [events.opened('t1', 'Alice'), added] });
expect(tab(result, 't1')!.lines[0]).toMatchObject({
id: 'line-1',
units: [
{ id: `${added.id}:0`, addedAt: at(0), removedAt: null },
{ id: `${added.id}:1`, addedAt: at(0), removedAt: null },
],
});
});
it('only sends customers that have not been delivered yet', async () => {
const { service, prisma } = createService();
it('adds what two tablets put on one tab', async () => {
const { sync, tabletA, tabletB } = setup();
await sync(tabletA, { events: [events.opened('t1', 'Alice'), events.line('line_added', 't1', 2, { lineId: 'line-1' })] });
const result = await service.sync(device, { deviceId: 'd1', openTabs: [] });
// Tablet B replayed its drink onto the server's line, so it uses that line id
await sync(tabletB, { events: [events.line('line_added', 't1', 1, { lineId: 'line-1', minutes: 1 })] });
const result = await sync(tabletA);
expect(prisma.plannedTab.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { venueId: 'v1', deliveredAt: null } }),
expect(tab(result, 't1')!.lines).toMatchObject([{ id: 'line-1', quantity: 3 }]);
});
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);
});
});
describe('DevicesService.sync: taking over a tablet once', () => {
it("takes over the tablet's tabs and stock without applying that request's events again", async () => {
const { sync, tabletA, prisma } = setup({ seeded: false });
const result = await sync(tabletA, {
openTabs: [{
id: 't1', customerName: 'Alice', version: 4, totalInCents: 750, itemCount: 3,
items: [{ productName: 'Cola', quantity: 3, unitPriceInCents: 250 }],
}],
// Already in the tabs and stock above
events: [events.line('line_added', 't1', 1)],
products: catalog(45),
});
it('does not mark anything delivered when the device reports no tabs', async () => {
const { service, tx } = createService();
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();
await service.sync(device, { deviceId: 'd1', openTabs: [] });
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);
});
});
expect(tx.plannedTab.updateMany).not.toHaveBeenCalled();
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', () => {
const jan = { id: 't1', customerName: 'Jan', version: 2, totalInCents: 500, itemCount: 1 };
const janSnapshot = { tabId: 't1', customerName: 'Jan', version: 2, totalInCents: 500, itemCount: 1 };
it('sends nothing when the sync changed nothing', async () => {
const { service, realtime } = createService({ previous: [janSnapshot] });
const { sync, tabletA, realtime } = setup();
await sync(tabletA, { products: catalog(), events: [events.opened('t1', 'Alice')] });
realtime.notify.mockClear();
await service.sync(device, { deviceId: 'd1', openTabs: [jan] });
await sync(tabletA, { products: catalog() });
expect(realtime.notify).not.toHaveBeenCalled();
});
it('signals "tabs" when a tab changed', async () => {
const { service, realtime } = createService({ previous: [janSnapshot] });
it('signals "tabs" and "stock" when they changed', async () => {
const { sync, tabletA, realtime } = setup();
await service.sync(device, { deviceId: 'd1', openTabs: [{ ...jan, version: 3, totalInCents: 900, itemCount: 2 }] });
await sync(tabletA, { products: catalog(), events: [events.opened('t1', 'Alice')] });
expect(realtime.notify).toHaveBeenCalledWith('v1', 'tabs');
});
it('signals "tabs" when a tab disappeared', async () => {
const { service, realtime } = createService({ previous: [janSnapshot] });
await service.sync(device, { deviceId: 'd1', openTabs: [] });
expect(realtime.notify).toHaveBeenCalledWith('v1', 'tabs');
});
it('signals delivered customers and expired commands', async () => {
const { service, realtime } = createService({ previous: [janSnapshot], delivered: 1, expired: 2 });
await service.sync(device, { deviceId: 'd1', openTabs: [jan] });
expect(realtime.notify).toHaveBeenCalledWith('v1', 'planned-tabs', 'commands');
expect(realtime.notify).toHaveBeenCalledWith('v1', 'tabs', 'stock');
});
it('signals "device" when a terminal comes back online', async () => {
const { service, realtime } = createService({ previous: [janSnapshot] });
const { sync, realtime } = setup();
await service.sync({ ...device, lastSeenAt: new Date(Date.now() - 10 * 60 * 1000) }, { deviceId: 'd1', openTabs: [jan] });
await sync({ id: 'd1', venueId: 'v1', revokedAt: null, lastSeenAt: new Date(Date.now() - 3_600_000) });
expect(realtime.notify).toHaveBeenCalledWith('v1', 'device');
});
});
describe('DevicesService.sync tab lines', () => {
const base = { id: 't1', customerName: 'Bram', version: 4, totalInCents: 2500, itemCount: 10 };
it('stores the lines a terminal reports', async () => {
const { service, tx } = createService();
const items = [{ productName: 'Cola', quantity: 10, unitPriceInCents: 250 }];
await service.sync(device, { deviceId: 'd1', openTabs: [{ ...base, items }] });
const { create, update } = tx.openTabSnapshot.upsert.mock.calls[0][0];
expect(create.items).toEqual(items);
expect(update.items).toEqual(items);
});
it('stores no lines for app versions that do not report them', async () => {
const { service, tx } = createService();
await service.sync(device, { deviceId: 'd1', openTabs: [base] });
expect(tx.openTabSnapshot.upsert.mock.calls[0][0].update.items).toBe(Prisma.JsonNull);
});
});
describe('DevicesService.sync writes', () => {
const items = [{ productName: 'Cola', quantity: 10, unitPriceInCents: 250 }];
const jan = { id: 't1', customerName: 'Jan', version: 2, totalInCents: 2500, itemCount: 10, items };
// JSONB doesn't keep key order
const janSnapshot = { tabId: 't1', customerName: 'Jan', version: 2, totalInCents: 2500, itemCount: 10, items: [{ quantity: 10, productName: 'Cola', unitPriceInCents: 250 }] };
it('only bumps the report time of unchanged tabs', async () => {
const { service, tx } = createService({ previous: [janSnapshot] });
await service.sync(device, { deviceId: 'd1', openTabs: [jan] });
expect(tx.openTabSnapshot.upsert).not.toHaveBeenCalled();
expect(tx.tabOwnership.upsert).not.toHaveBeenCalled();
expect(tx.openTabSnapshot.updateMany).toHaveBeenCalledWith({
where: { deviceId: 'd1', tabId: { in: ['t1'] } },
data: { reportedAt: expect.any(Date) },
});
});
it('writes a tab whose lines changed', async () => {
const { service, tx } = createService({ previous: [janSnapshot] });
await service.sync(device, { deviceId: 'd1', openTabs: [{ ...jan, items: [{ ...items[0], quantity: 11 }] }] });
expect(tx.openTabSnapshot.upsert).toHaveBeenCalledTimes(1);
expect(tx.openTabSnapshot.updateMany).not.toHaveBeenCalled();
});
});
describe('DevicesService.sync tab events', () => {
const event = {
id: '6f1c2c0e-6a2b-4c1e-9a4c-1f2a3b4c5d6e',
type: 'line_added',
tabId: 't1',
customerName: 'Jan',
occurredAt: new Date().toISOString(),
productName: 'Cola',
unitPriceInCents: 250,
quantity: 2,
};
it('stores events once and acknowledges them', async () => {
const { service, tx } = createService();
const result = await service.sync(device, { deviceId: 'd1', openTabs: [], events: [event] });
expect(tx.tabEvent.createMany).toHaveBeenCalledWith({
data: [expect.objectContaining({ id: event.id, venueId: 'v1', deviceId: 'd1', quantityDelta: 2 })],
skipDuplicates: true,
});
expect(result.acknowledgedEventIds).toEqual([event.id]);
expect(result.rejectedEvents).toEqual([]);
});
it('still syncs when an event is invalid, and reports it', async () => {
const { service, tx } = createService();
const result = await service.sync(device, { deviceId: 'd1', openTabs: [], events: [{ ...event, quantity: -1 }] });
expect(tx.tabEvent.createMany).not.toHaveBeenCalled();
expect(tx.device.update).toHaveBeenCalled();
expect(result.acknowledgedEventIds).toEqual([]);
expect(result.rejectedEvents).toEqual([{ id: event.id, reason: expect.stringMatching(/quantity/) }]);
});
it('works for terminals that send no events', async () => {
const { service, tx } = createService();
const result = await service.sync(device, { deviceId: 'd1', openTabs: [] });
expect(tx.tabEvent.createMany).not.toHaveBeenCalled();
expect(result.acknowledgedEventIds).toEqual([]);
});
});
describe('DevicesService.sync stock', () => {
const cola = { id: 'p1', name: 'Cola', category: 'Frisdrank', priceInCents: 250, stock: 48 };
const storedCola = { productId: 'p1', name: 'Cola', category: 'Frisdrank', priceInCents: 250, stock: 48 };
it('leaves products alone when the terminal does not report them', async () => {
const { service, prisma, tx } = createService({ products: [storedCola] });
await service.sync(device, { deviceId: 'd1', openTabs: [] });
expect(prisma.product.findMany).not.toHaveBeenCalled();
expect(tx.product.deleteMany).not.toHaveBeenCalled();
});
it('only writes changed products, removes missing ones and signals "stock"', async () => {
const storedWater = { productId: 'p2', name: 'Water', category: null, priceInCents: 200, stock: null };
const { service, tx, realtime } = createService({ products: [storedCola, storedWater] });
await service.sync(device, {
deviceId: 'd1',
openTabs: [],
products: [{ ...cola, stock: 46 }, { id: 'p3', name: ' Bier ', priceInCents: 300, stock: null }],
});
expect(tx.product.deleteMany).toHaveBeenCalledWith({ where: { venueId: 'v1', productId: { notIn: ['p1', 'p3'] } } });
expect(tx.product.upsert).toHaveBeenCalledTimes(2);
expect(tx.product.upsert).toHaveBeenCalledWith(expect.objectContaining({
create: expect.objectContaining({ productId: 'p3', name: 'Bier', category: null, stock: null }),
}));
expect(realtime.notify).toHaveBeenCalledWith('v1', 'stock');
});
it('does not signal anything for an unchanged catalog', async () => {
const { service, tx, realtime } = createService({ products: [storedCola], recordedResults: 0 });
await service.sync(device, { deviceId: 'd1', openTabs: [], products: [cola] });
expect(tx.product.upsert).not.toHaveBeenCalled();
expect(realtime.notify).not.toHaveBeenCalled();
});
it('skips an invalid product without failing the sync', async () => {
const { service, tx } = createService();
await service.sync(device, { deviceId: 'd1', openTabs: [], products: [cola, { id: 'p9', name: 'Broken', priceInCents: -1, stock: 3 }] });
expect(tx.product.deleteMany).toHaveBeenCalledWith({ where: { venueId: 'v1', productId: { notIn: ['p1'] } } });
});
it('records stock adjustment results and acknowledges them', async () => {
const { service, tx, realtime } = createService();
const id = '5e0b8b4e-1d2c-4f3a-9b8c-7d6e5f4a3b2c';
const result = await service.sync(device, {
deviceId: 'd1',
openTabs: [],
stockAdjustmentResults: [{ id, status: 'applied', stockAfter: 72 }, { id: 'nope', status: 'applied' }],
});
expect(tx.stockAdjustment.updateMany).toHaveBeenCalledWith({
where: { id, venueId: 'v1', status: { in: ['pending', 'cancelled'] } },
data: { status: 'applied', stockAfter: 72, result: null, processedAt: expect.any(Date) },
});
expect(result.acknowledgedStockAdjustmentResultIds).toEqual([id]);
expect(result.rejectedStockAdjustmentResults.map((r) => r.id)).toEqual(['nope']);
expect(realtime.notify).toHaveBeenCalledWith('v1', 'stock');
});
it('sends the pending stock adjustments of the venue', async () => {
const pending = [{ id: 'a1', productId: 'p1', type: 'add', quantity: 24, note: null, requestedBy: 'Bram', createdAt: new Date() }];
const { service, prisma } = createService({ pendingStock: pending });
const result = await service.sync(device, { deviceId: 'd1', openTabs: [] });
expect(prisma.stockAdjustment.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: { venueId: 'v1', status: 'pending' },
orderBy: { createdAt: 'asc' },
}));
expect(result.stockAdjustments).toEqual(pending);
});
});
+120 -223
View File
@@ -11,11 +11,14 @@ import { AuditService } from '../audit/audit.service';
import { RealtimeService, RealtimeTopic } from '../realtime/realtime.service';
import { DEVICE_ONLINE_THRESHOLD_MS } from '@kooltap/shared';
import { Prisma } from '@prisma/client';
import { OpenTabDto, OpenTabItemDto, SyncDto } from './dto/sync.dto';
import { customerNameKey } from '../planned-tabs/customer-name';
import { SyncDto } from './dto/sync.dto';
import { AckCommandDto } from './dto/ack-command.dto';
import { parseTabEvents, tabEventRow } from './tab-events';
import { eventTime, parseTabEvents, tabEventRow } from './tab-events';
import { ParsedProduct, parseProducts, parseStockResults } from './stock-sync';
import { VenueLedger } from '../tabs/venue-ledger';
import { seedTabs, tabJson } from '../tabs/tab-state';
import { openPlannedTabs, settlementsAfter } from '../tabs/tab-sync';
import { applyPendingAdjustments } from '../stock/stock-adjustments';
@Injectable()
export class DevicesService {
@@ -36,11 +39,12 @@ export class DevicesService {
}
const now = new Date();
const venueId: string = device.venueId;
const events = parseTabEvents(dto.events ?? []);
for (const { id, reason } of events.rejected) {
this.logger.warn(`Device ${device.id} sent an invalid tab event ${id ?? '(no id)'}: ${reason}`);
}
// Omitted by app versions without stock: the products are then left as they are
// Omitted by app versions without stock
const reported = dto.products ? parseProducts(dto.products) : null;
for (const { id, reason } of reported?.rejected ?? []) {
this.logger.warn(`Device ${device.id} sent an invalid product ${id ?? '(no id)'}: ${reason}`);
@@ -49,158 +53,46 @@ export class DevicesService {
for (const { id, reason } of stockResults.rejected) {
this.logger.warn(`Device ${device.id} sent an invalid stock adjustment result ${id ?? '(no id)'}: ${reason}`);
}
const openTabs = dto.openTabs || [];
const openTabIds = openTabs.map((t) => t.id);
// Devices sync often, so dashboards are only told about what actually changed
const previous = await this.prisma.openTabSnapshot.findMany({
where: { deviceId: device.id },
select: { tabId: true, customerName: true, version: true, totalInCents: true, itemCount: true, items: true },
});
const previousById = new Map(previous.map((s) => [s.tabId, s]));
const synced = await this.prisma.$transaction(async (tx) => {
// Locks the venue: other terminals' syncs wait, so no change is lost
const ledger = await VenueLedger.open(tx, venueId);
// Ownership changes are detected up front so audit logging stays outside the transaction
const takenOver = await this.prisma.tabOwnership.findMany({
where: { venueId: device.venueId, tabId: { in: openTabIds }, deviceId: { not: device.id } },
});
const takenOverIds = new Set(takenOver.map((o) => o.tabId));
// 1. The catalog: names and prices only, the stock is the server's
const productsChanged = reported ? await this.saveProducts(tx, ledger, reported.products, now) : false;
// Only new or changed tabs are written; unchanged ones just get their report time bumped
const changedTabs = openTabs.filter((t) => {
const p = previousById.get(t.id);
return !p || takenOverIds.has(t.id) || p.customerName !== t.customerName || p.version !== t.version
|| p.totalInCents !== t.totalInCents || p.itemCount !== t.itemCount
|| !sameItems(p.items as StoredItem[] | null, t.items);
// 2. Events, each applied once: a terminal resends an event until its acknowledgement arrives
const unique = [...new Map(events.accepted.map((event) => [event.id, event])).values()];
const stored = unique.length === 0 ? [] : await tx.tabEvent.findMany({
where: { id: { in: unique.map((event) => event.id) } },
select: { id: true },
});
const changedIds = new Set(changedTabs.map((t) => t.id));
const unchangedIds = openTabIds.filter((id) => !changedIds.has(id));
const tabsChanged = previous.length !== openTabs.length || changedTabs.length > 0;
let plannedDelivered = 0;
const storedIds = new Set(stored.map((event) => event.id));
const fresh = unique.filter((event) => !storedIds.has(event.id));
if (fresh.length > 0) {
await tx.tabEvent.createMany({ data: fresh.map((event) => tabEventRow(event, venueId, device.id, now)) });
}
// Products belong to the venue rather than the device, so they survive pairing a new terminal
const previousProducts = reported
? await this.prisma.product.findMany({
where: { venueId: device.venueId },
select: { productId: true, name: true, category: true, priceInCents: true, stock: true },
})
: [];
const previousProductsById = new Map(previousProducts.map((p) => [p.productId, p]));
const changedProducts = (reported?.products ?? []).filter((p) => {
const stored = previousProductsById.get(p.id);
return !stored || !sameProduct(stored, p);
});
const productsChanged = reported !== null
&& (previousProducts.length !== reported.products.length || changedProducts.length > 0);
if (!ledger.seeded) {
// The first sync since the server holds the tabs and stock: the terminal's own tabs and stock already
// include the events of this request, so they are stored but not applied again
ledger.seed(seedTabs(dto.openTabs ?? [], now));
for (const product of reported?.products ?? []) ledger.stock.seed(product.id, product.stock);
} else {
for (const event of fresh) {
const occurredAt = eventTime(event, now);
const settlement = ledger.tabs.apply(event, occurredAt);
if (settlement) ledger.settlements.push({ ...settlement, deviceId: device.id });
ledger.stock.apply(event, occurredAt);
}
}
// 3. Outcomes from app versions that applied dashboard adjustments themselves. The terminal's outcome is the
// truth, even for an adjustment cancelled while it was on its way
let stockResultsRecorded = 0;
for (const 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) {
const { count } = await tx.stockAdjustment.updateMany({
where: { id: result.id, venueId: device.venueId, status: { in: ['pending', 'cancelled'] } },
where: { id: result.id, venueId, status: { in: ['pending', 'cancelled'] } },
data: {
status: result.status,
stockAfter: result.status === 'applied' ? result.stockAfter : null,
@@ -210,42 +102,39 @@ export class DevicesService {
});
stockResultsRecorded += count;
}
// Requested before the server applied adjustments itself, and never applied by a terminal
const adjustmentsApplied = await applyPendingAdjustments(tx, ledger, now);
if (events.accepted.length > 0) {
await tx.tabEvent.createMany({
data: events.accepted.map((event) => tabEventRow(event, device.venueId, device.id, now)),
skipDuplicates: true,
});
}
// 4. Planned customers become tabs here, once, instead of on every terminal
const plannedDelivered = await openPlannedTabs(tx, ledger, now);
await tx.device.update({
where: { id: device.id },
data: { lastSeenAt: now },
});
await ledger.save(tx, now);
await tx.device.update({ where: { id: device.id }, data: { lastSeenAt: now } });
const feed = await settlementsAfter(tx, venueId, dto.settlementsCursor, now);
return {
ledger,
feed,
topics: {
tabs: ledger.changedTabs,
plannedTabs: plannedDelivered > 0,
stock: productsChanged || ledger.stock.changed.size > 0 || stockResultsRecorded > 0 || adjustmentsApplied > 0,
},
};
}, { timeout: 15000 });
// Expire old commands
const expired = await this.prisma.remoteCommand.updateMany({
where: {
deviceId: device.id,
status: 'pending',
expiresAt: { lte: now },
},
where: { deviceId: device.id, status: 'pending', expiresAt: { lte: now } },
data: { status: 'expired' },
});
// The cursor is kept for client compatibility but no longer filters commands: every pending
// command is returned on each sync, so a command committed while a sync was in flight can never
// be skipped. Devices dedupe by command id and re-ack commands they already processed.
// close_tab commands are no longer created (the server settles tabs itself); ones from before that are
// still delivered. Every pending command is returned on each sync; devices dedupe by command id
const cursor = Buffer.from(`${now.toISOString()}:${uuidv4().substring(0, 8)}`).toString('base64url');
const commandWhere = {
deviceId: device.id,
status: 'pending',
};
// Find pending commands for this device
const commands = await this.prisma.remoteCommand.findMany({
where: commandWhere,
where: { deviceId: device.id, status: 'pending' },
select: {
id: true,
type: true,
@@ -254,35 +143,20 @@ export class DevicesService {
paymentMethod: true,
requestedBy: true,
createdAt: true,
status: true,
},
orderBy: { createdAt: 'asc' },
});
// Only customers the terminal has not opened a tab for yet
const plannedTabs = await this.prisma.plannedTab.findMany({
where: { venueId: device.venueId, deliveredAt: null },
orderBy: { position: 'asc' },
select: { customerName: true },
});
// Sent on every sync until the terminal reports an outcome; the terminal dedupes by id
const stockAdjustments = await this.prisma.stockAdjustment.findMany({
where: { venueId: device.venueId, status: 'pending' },
orderBy: { createdAt: 'asc' },
take: 500,
select: { id: true, productId: true, type: true, quantity: true, note: true, requestedBy: true, createdAt: true },
});
const topics: RealtimeTopic[] = [];
if (tabsChanged) topics.push('tabs');
if (plannedDelivered > 0) topics.push('planned-tabs');
if (synced.topics.tabs) topics.push('tabs');
if (synced.topics.plannedTabs) topics.push('planned-tabs');
if (expired.count > 0) topics.push('commands');
if (productsChanged || stockResultsRecorded > 0) topics.push('stock');
if (synced.topics.stock) topics.push('stock');
// Only when the device comes back online; dashboards poll the device status themselves
if (now.getTime() - new Date(device.lastSeenAt).getTime() > DEVICE_ONLINE_THRESHOLD_MS) topics.push('device');
if (topics.length > 0) this.realtime.notify(device.venueId, ...topics);
if (topics.length > 0) this.realtime.notify(venueId, ...topics);
const { ledger, feed } = synced;
return {
cursor,
commands: commands.map((c) => ({
@@ -294,19 +168,68 @@ export class DevicesService {
requestedBy: c.requestedBy,
createdAt: c.createdAt,
})),
plannedTabNames: plannedTabs.map((t) => t.customerName),
// Stored (or already stored earlier): the terminal can drop these from its outbox
// The server opens planned tabs itself; terminals must not open them as well
plannedTabNames: [],
// Applied (or already applied earlier): the terminal can drop these from its outbox
acknowledgedEventIds: events.accepted.map((e) => e.id),
// Invalid and never stored: the terminal should log and drop these too, re-sending won't help
// Invalid and never applied: the terminal should log and drop these too, re-sending won't help
rejectedEvents: events.rejected,
stockAdjustments,
// The server applies dashboard adjustments itself
stockAdjustments: [],
// Recorded (or already recorded earlier): the terminal can drop these results from its outbox
acknowledgedStockAdjustmentResultIds: stockResults.accepted.map((r) => r.id),
// Invalid and never recorded: drop these too, re-sending won't help
rejectedStockAdjustmentResults: stockResults.rejected,
// The venue's complete state after this request's events: the terminal replaces its own with it
tabs: ledger.tabs.all().map(tabJson),
stock: ledger.stock.entries().map(([productId, { stock, countedAt }]) => (
{ productId, stock, countedAt: countedAt?.toISOString() ?? null })),
settlements: feed.settlements,
settlementsCursor: feed.settlementsCursor,
};
}
/**
* Upserts the reported products without their stock, and never removes one: another terminal may still sell it.
* Returns whether anything the dashboard shows changed.
*/
private async saveProducts(tx: Prisma.TransactionClient, ledger: VenueLedger, products: ParsedProduct[], now: Date) {
const stored = await tx.product.findMany({
where: { venueId: ledger.venueId },
select: { productId: true, name: true, category: true, priceInCents: true },
});
const storedById = new Map(stored.map((p) => [p.productId, p]));
let changed = false;
const unchanged: string[] = [];
for (const product of products) {
const previous = storedById.get(product.id);
if (previous && previous.name === product.name && previous.category === product.category
&& previous.priceInCents === product.priceInCents) {
unchanged.push(product.id);
continue;
}
const data = { name: product.name, category: product.category, priceInCents: product.priceInCents, reportedAt: now };
await tx.product.upsert({
where: { venueId_productId: { venueId: ledger.venueId, productId: product.id } },
// Not tracked until counted: its starting stock arrives as a stock_set event
create: { venueId: ledger.venueId, productId: product.id, ...data, stock: null },
update: data,
});
ledger.stock.addProduct(product.id);
changed = true;
}
if (unchanged.length > 0) {
await tx.product.updateMany({
where: { venueId: ledger.venueId, productId: { in: unchanged } },
data: { reportedAt: now },
});
}
return changed;
}
async ackCommand(device: any, commandId: string, dto: AckCommandDto) {
if (!device) {
throw new NotFoundException({ code: 'device_not_found', message: 'Device not found' });
@@ -390,29 +313,3 @@ export class DevicesService {
return { ok: true };
}
}
type StoredProduct = { name: string; category: string | null; priceInCents: number; stock: number | null };
function sameProduct(stored: StoredProduct, reported: ParsedProduct): boolean {
return stored.name === reported.name && stored.category === reported.category
&& stored.priceInCents === reported.priceInCents && stored.stock === reported.stock;
}
type StoredItem = { productName: string; quantity: number; unitPriceInCents: number };
function sameItems(stored: StoredItem[] | null, reported: OpenTabItemDto[] | undefined): boolean {
if (!stored || !reported) return !stored && !reported;
return stored.length === reported.length && stored.every((s, i) =>
s.productName === reported[i].productName
&& s.quantity === reported[i].quantity
&& s.unitPriceInCents === reported[i].unitPriceInCents);
}
function storedItems(items: OpenTabItemDto[]): StoredItem[] {
return items.map(({ productName, quantity, unitPriceInCents }) => ({ productName, quantity, unitPriceInCents }));
}
/** The tab's lines in the stored shape, or JSON null when the terminal didn't report them. */
function snapshotItems(tab: OpenTabDto) {
return tab.items ? storedItems(tab.items) : Prisma.JsonNull;
}
+7
View File
@@ -64,6 +64,13 @@ export class SyncDto {
@IsString()
cursor?: string;
@ApiPropertyOptional({ description: 'Where the settlements feed left off; absent on the first sync after pairing' })
@IsOptional()
@IsString()
@MaxLength(100)
settlementsCursor?: string;
// The server holds the tabs: only used once, to take over the terminal's tabs on its first sync
@ApiProperty({ type: [OpenTabDto] })
@IsArray()
@ArrayMaxSize(1000)
+50 -13
View File
@@ -6,13 +6,25 @@ import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { OpenTabItemDto } from './sync.dto';
export const TAB_EVENT_TYPES = ['line_added', 'line_removed', 'settled'] as const;
export const TAB_EVENT_TYPES = [
'tab_opened', 'tab_renamed', 'tab_deleted', 'line_added', 'line_removed', 'settled', 'stock_set',
] as const;
export type TabEventType = (typeof TAB_EVENT_TYPES)[number];
export const SETTLEMENT_PAYMENT_METHODS = ['cash', 'payconiq', 'payment_pending', 'other'] as const;
const isLine = (event: TabEventDto) => event.type !== 'settled';
const isLine = (event: TabEventDto) => event.type === 'line_added' || event.type === 'line_removed';
const isSettlement = (event: TabEventDto) => event.type === 'settled';
const isStockSet = (event: TabEventDto) => event.type === 'stock_set';
const isAboutTab = (event: TabEventDto) => !isStockSet(event);
export class SettledItemDto extends OpenTabItemDto {
@ApiPropertyOptional({ example: '12', description: 'Missing in events from older app versions' })
@IsOptional()
@IsString()
@MaxLength(100)
productId?: string;
}
export class TabEventDto {
@ApiProperty({ description: 'Generated by the terminal; re-sending the same id stores the event once' })
@@ -23,20 +35,41 @@ export class TabEventDto {
@IsIn(TAB_EVENT_TYPES)
type: TabEventType;
@ApiProperty({ example: 'local-tab-uuid' })
@ApiPropertyOptional({ example: 'local-tab-uuid', description: 'Every type except stock_set' })
@ValidateIf(isAboutTab)
@IsString()
@IsNotEmpty()
tabId: string;
@MaxLength(100)
tabId?: string;
@ApiProperty({ example: 'Jan' })
@ApiPropertyOptional({ example: 'Jan', description: 'Every type except stock_set; tab_renamed: the new name' })
@ValidateIf(isAboutTab)
@IsString()
@MaxLength(200)
customerName: string;
customerName?: string;
@ApiProperty({ example: '2026-10-24T21:41:03.120Z', description: 'When it happened on the terminal' })
@IsISO8601({ strict: true })
occurredAt: string;
@ApiPropertyOptional({ example: '2026-10-24T20:00:00.000Z', description: 'tab_opened' })
@IsOptional()
@IsISO8601({ strict: true })
openedAt?: string;
@ApiPropertyOptional({ description: "line_added / line_removed: the terminal's line; missing from older app versions" })
@IsOptional()
@IsString()
@MaxLength(100)
lineId?: string;
@ApiPropertyOptional({ example: '12', description: 'line_added / line_removed (missing from older app versions), stock_set' })
@ValidateIf((event: TabEventDto) => isStockSet(event) || event.productId !== undefined)
@IsString()
@IsNotEmpty()
@MaxLength(100)
productId?: string;
@ApiPropertyOptional({ example: 'Cola', description: 'line_added / line_removed' })
@ValidateIf(isLine)
@IsString()
@@ -50,11 +83,15 @@ export class TabEventDto {
@Min(0)
unitPriceInCents?: number;
@ApiPropertyOptional({ example: 2, description: 'line_added / line_removed: how many were added or removed, always positive' })
@ValidateIf(isLine)
@ApiPropertyOptional({
example: 2,
description: 'line_added / line_removed: how many were added or removed, at least 1. '
+ 'stock_set: the counted stock (≥ 0), null to stop tracking',
})
@ValidateIf((event: TabEventDto) => isLine(event) || (isStockSet(event) && event.quantity !== null))
@IsInt()
@Min(1)
quantity?: number;
@Min(0)
quantity?: number | null;
@ApiPropertyOptional({ example: 2450, description: 'settled: the amount that was settled' })
@ValidateIf(isSettlement)
@@ -72,11 +109,11 @@ export class TabEventDto {
@IsUUID()
commandId?: string;
@ApiPropertyOptional({ type: [OpenTabItemDto], description: 'settled: the lines that were settled' })
@ApiPropertyOptional({ type: [SettledItemDto], description: 'settled: the lines that were settled' })
@IsOptional()
@IsArray()
@ArrayMaxSize(500)
@ValidateNested({ each: true })
@Type(() => OpenTabItemDto)
items?: OpenTabItemDto[];
@Type(() => SettledItemDto)
items?: SettledItemDto[];
}
+34 -1
View File
@@ -33,6 +33,39 @@ describe('parseTabEvents', () => {
expect(rejected).toEqual([]);
});
it('accepts tab and stock events, and a stock count of zero', () => {
const base = { occurredAt: '2026-10-24T21:00:00.000Z' };
const { accepted, rejected } = parseTabEvents([
{ ...base, id: '1b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e', type: 'tab_opened', tabId: 't1', customerName: 'Jan', openedAt: base.occurredAt },
{ ...base, id: '2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f', type: 'tab_renamed', tabId: 't1', customerName: 'Jan P.' },
{ ...base, id: '3d4e5f6a-7b8c-4d9e-8f1a-2b3c4d5e6f7a', type: 'stock_set', productId: '12', quantity: 0 },
{ ...base, id: '4e5f6a7b-8c9d-4e0f-9a2b-3c4d5e6f7a8b', type: 'stock_set', productId: '12', quantity: null },
]);
expect(rejected).toEqual([]);
expect(accepted.map((e) => e.type)).toEqual(['tab_opened', 'tab_renamed', 'stock_set', 'stock_set']);
});
it('rejects a stock count without a product or below zero, and a tab event without a tab', () => {
const base = { occurredAt: '2026-10-24T21:00:00.000Z' };
const { accepted, rejected } = parseTabEvents([
{ ...base, id: '5f6a7b8c-9d0e-4f1a-8b3c-4d5e6f7a8b9c', type: 'stock_set', quantity: 3 },
{ ...base, id: '6a7b8c9d-0e1f-4a2b-9c4d-5e6f7a8b9c0d', type: 'stock_set', productId: '12', quantity: -1 },
{ ...base, id: '7b8c9d0e-1f2a-4b3c-8d5e-6f7a8b9c0d1e', type: 'tab_opened', customerName: 'Jan' },
]);
expect(accepted).toEqual([]);
expect(rejected).toHaveLength(3);
});
it('stores a stock count without a tab', () => {
const [event] = parseTabEvents([
{ id: '8c9d0e1f-2a3b-4c4d-9e6f-7a8b9c0d1e2f', type: 'stock_set', productId: '12', quantity: 30, occurredAt: '2026-10-24T21:00:00.000Z' },
]).accepted;
expect(tabEventRow(event, 'v1', 'd1', now)).toMatchObject({ type: 'stock_set', tabId: null, productId: '12', stockSetTo: 30 });
});
it('rejects invalid events one by one, keeping their id', () => {
const { accepted, rejected } = parseTabEvents([
lineAdded,
@@ -87,7 +120,7 @@ describe('tabEventRow', () => {
const row = tabEventRow(event, 'v1', 'd1', now);
expect(row).toMatchObject({ type: 'settled', amountInCents: 500, paymentMethod: 'payconiq', commandId: null });
expect(row.items).toEqual(settled.items);
expect(row.items).toEqual([{ productId: null, productName: 'Cola', quantity: 2, unitPriceInCents: 250 }]);
expect(row.quantityDelta).toBeUndefined();
});
+25 -5
View File
@@ -25,25 +25,42 @@ export function parseTabEvents(raw: unknown[]): { accepted: TabEventDto[]; rejec
rejected.push({ id, reason: describe(errors) });
continue;
}
// Validated here: 0 is a valid stock count, but not a valid number of drinks
if ((event.type === 'line_added' || event.type === 'line_removed') && event.quantity! < 1) {
rejected.push({ id, reason: 'quantity must not be less than 1' });
continue;
}
accepted.push(event);
}
return { accepted, rejected };
}
export function tabEventRow(event: TabEventDto, venueId: string, deviceId: string, now: Date): Prisma.TabEventCreateManyInput {
/** When the event happened, as stored and applied: a terminal clock running ahead is capped at [now]. */
export function eventTime(event: TabEventDto, now: Date): Date {
const occurredAt = new Date(event.occurredAt);
return occurredAt.getTime() > now.getTime() + MAX_CLOCK_AHEAD_MS ? now : occurredAt;
}
export function tabEventRow(event: TabEventDto, venueId: string, deviceId: string, now: Date): Prisma.TabEventCreateManyInput {
const base = {
id: event.id,
venueId,
deviceId,
tabId: event.tabId,
customerName: event.customerName,
tabId: event.tabId ?? null,
customerName: event.customerName ?? null,
type: event.type,
occurredAt: occurredAt.getTime() > now.getTime() + MAX_CLOCK_AHEAD_MS ? now : occurredAt,
occurredAt: eventTime(event, now),
receivedAt: now,
};
if (event.type === 'stock_set') {
return { ...base, productId: event.productId, stockSetTo: event.quantity ?? null };
}
if (event.type === 'tab_opened' || event.type === 'tab_renamed' || event.type === 'tab_deleted') {
return base;
}
if (event.type === 'settled') {
return {
...base,
@@ -51,13 +68,16 @@ export function tabEventRow(event: TabEventDto, venueId: string, deviceId: strin
paymentMethod: event.paymentMethod,
commandId: event.commandId ?? null,
items: event.items
? event.items.map(({ productName, quantity, unitPriceInCents }) => ({ productName, quantity, unitPriceInCents }))
? event.items.map(({ productId, productName, quantity, unitPriceInCents }) => (
{ productId: productId ?? null, productName, quantity, unitPriceInCents }))
: Prisma.JsonNull,
};
}
return {
...base,
lineId: event.lineId ?? null,
productId: event.productId ?? null,
productName: event.productName,
unitPriceInCents: event.unitPriceInCents,
quantityDelta: event.type === 'line_removed' ? -event.quantity! : event.quantity,
@@ -4,12 +4,30 @@ import { RemindersService } from './reminders.service';
type SentRow = { customerName: string; _count: { _all: number }; _max: { sentAt: Date | null } };
function createService(items: unknown, { plannedName = 'Bram', sent = [] as SentRow[] } = {}) {
type Item = { productName: string; quantity: number; unitPriceInCents: number };
/** € 29,00 for 12 drinks. */
const defaultItems: Item[] = [
{ productName: 'Cola', quantity: 10, unitPriceInCents: 250 },
{ productName: 'Chips', quantity: 2, unitPriceInCents: 200 },
];
/** The tab's lines as the server holds them: one unit per drink. */
function lines(items: Item[]) {
return items.map((item, i) => ({
id: `line-${i}`,
productId: null,
productName: item.productName,
unitPriceInCents: item.unitPriceInCents,
units: Array.from({ length: item.quantity }, (_, n) => ({ id: `u${i}:${n}`, addedAt: '2026-09-24T20:00:00.000Z', removedAt: null })),
}));
}
function createService(items: Item[] | null, { plannedName = 'Bram', sent = [] as SentRow[] } = {}) {
const prisma = {
tabOwnership: { findMany: jest.fn().mockResolvedValue([{ tabId: 't1', deviceId: 'd1' }]) },
openTabSnapshot: {
tab: {
findMany: jest.fn().mockResolvedValue([
{ tabId: 't1', deviceId: 'd1', customerName: 'Bram', totalInCents: 2900, itemCount: 12, items },
{ id: 't1', customerName: 'Bram', version: 3, lines: lines(items ?? defaultItems), updatedAt: new Date() },
]),
},
plannedTab: { findMany: jest.fn().mockResolvedValue([{ customerName: plannedName, email: 'bram@example.com' }]) },
@@ -39,10 +57,7 @@ describe('RemindersService.sendReminders', () => {
});
it('includes the tab summary with Belgian amounts', async () => {
const { service } = createService([
{ productName: 'Cola', quantity: 10, unitPriceInCents: 250 },
{ productName: 'Chips', quantity: 2, unitPriceInCents: 200 },
]);
const { service } = createService(defaultItems);
const result = await service.sendReminders('v1');
@@ -55,16 +70,6 @@ describe('RemindersService.sendReminders', () => {
expect(email.subject).toContain('De Kroon');
});
it('leaves the summary out when the terminal did not report lines', async () => {
const { service } = createService(null);
await service.sendReminders('v1');
const [email] = requests[0].body;
expect(email.text).not.toContain('10x');
expect(email.text).toMatch(/€\s29,00/);
});
it('sends one batch request with an idempotency key and records the reminders', async () => {
const { service, prisma } = createService(null);
+4 -13
View File
@@ -4,6 +4,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { RealtimeService } from '../realtime/realtime.service';
import { EmailTemplateError, EmailTemplatesService, RenderedEmail } from '../email/email-templates.service';
import { customerNameKey } from '../planned-tabs/customer-name';
import { tabSummary } from '../tabs/tab-state';
// Tabs stay open per customer (closing only settles them), so a customer is eligible again
// once this many days have passed since their last reminder. Admins can still pick them by hand.
@@ -48,16 +49,7 @@ export class RemindersService {
/** Every customer with an unpaid open tab, with their reminder track record. */
async listCandidates(venueId: string): Promise<{ candidates: Candidate[]; cooldownDays: number }> {
const ownerships = await this.prisma.tabOwnership.findMany({
where: { venueId },
});
// Only the owning device's snapshot is authoritative for a tab
const snapshots = ownerships.length === 0 ? [] : await this.prisma.openTabSnapshot.findMany({
where: {
OR: ownerships.map((o) => ({ deviceId: o.deviceId, tabId: o.tabId })),
},
});
const tabs = (await this.prisma.tab.findMany({ where: { venueId }, orderBy: { openedAt: 'asc' } })).map(tabSummary);
const plannedTabs = await this.prisma.plannedTab.findMany({
where: { venueId, email: { not: null } },
@@ -85,7 +77,7 @@ export class RemindersService {
const now = Date.now();
const candidates: Candidate[] = [];
for (const snap of snapshots) {
for (const snap of tabs) {
if (snap.totalInCents <= 0) continue;
const key = customerNameKey(snap.customerName);
const email = emailByKey.get(key) ?? null;
@@ -97,8 +89,7 @@ export class RemindersService {
email,
amountInCents: snap.totalInCents,
itemCount: snap.itemCount,
// Null when the terminal's app version doesn't report lines
items: (snap.items as TabLine[] | null) ?? null,
items: snap.items,
reminderCount: sent?.count ?? 0,
lastReminderAt: sent?.last ?? null,
nextEligibleAt,
+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 { createFakePrisma } from '../testing/fake-prisma';
const user = { id: 'u1', email: 'bram@example.com' };
@@ -21,19 +22,35 @@ function createService({ products = [] as { productId: string; name: string; sto
const cola = { productId: 'p1', name: 'Cola', stock: 10 };
const water = { productId: 'p2', name: 'Water', stock: null };
function createWithVenue(products: { productId: string; name: string; stock: number | null }[]) {
const prisma = createFakePrisma();
prisma.venue.rows.push({ id: 'v1', tabsSeededAt: new Date('2026-10-01') });
for (const p of products) {
prisma.product.rows.push({ venueId: 'v1', ...p, category: null, priceInCents: 250, countedAt: null, reportedAt: new Date() });
}
const realtime = { notify: jest.fn() };
const service = new StockService(prisma as any, { log: jest.fn() } as any, realtime as any);
return { service, prisma, realtime };
}
describe('StockService.createAdjustments', () => {
it('creates one pending adjustment per entry with the product name and requester', async () => {
const { service, prisma, realtime } = createService({ products: [cola, water] });
it('applies the adjustments at once, in the order they were entered', async () => {
const { service, prisma, realtime } = createWithVenue([cola, water]);
const result = await service.createAdjustments('v1', [
{ productId: 'p1', type: 'add', quantity: 24, note: 'Delivery' },
{ productId: 'p2', type: 'set', quantity: 12 },
{ productId: 'p1', type: 'add', quantity: -4, note: 'Broken' },
], user);
expect(prisma.stockAdjustment.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ venueId: 'v1', productId: 'p1', productName: 'Cola', type: 'add', quantity: 24, note: 'Delivery', requestedBy: 'bram@example.com' }),
}));
expect(result.adjustments).toHaveLength(2);
expect(result.adjustments).toMatchObject([
{ productId: 'p1', productName: 'Cola', status: 'applied', stockAfter: 34, note: 'Delivery', requestedBy: 'bram@example.com' },
{ productId: 'p2', status: 'applied', stockAfter: 12 },
{ productId: 'p1', status: 'applied', stockAfter: 30 },
]);
expect(prisma.product.rows.map((p) => p.stock)).toEqual([30, 12]);
// A count starts tracking and becomes the latest count
expect(prisma.product.rows[1].countedAt).toBeInstanceOf(Date);
expect(realtime.notify).toHaveBeenCalledWith('v1', 'stock');
});
+21 -5
View File
@@ -3,6 +3,8 @@ import { PrismaService } from '../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { RealtimeService } from '../realtime/realtime.service';
import { StockAdjustmentEntryDto } from './dto/create-stock-adjustments.dto';
import { VenueLedger } from '../tabs/venue-ledger';
import { applyPendingAdjustments } from './stock-adjustments';
const adjustmentSelect = {
id: true,
@@ -106,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 created = await this.prisma.$transaction(
entries.map((entry) => this.prisma.stockAdjustment.create({
const created = await this.prisma.$transaction(async (tx) => {
const now = new Date();
const ledger = await VenueLedger.open(tx, venueId);
const ids: string[] = [];
for (const entry of entries) {
const { id } = await tx.stockAdjustment.create({
data: {
venueId,
productId: entry.productId,
@@ -118,10 +126,18 @@ export class StockService {
note: entry.note ?? null,
requestedBy,
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', {
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 { PrismaService } from '../prisma/prisma.service';
import { customerNameKey } from '../planned-tabs/customer-name';
import { tabSummary } from './tab-state';
@Injectable()
export class TabsService {
constructor(private prisma: PrismaService) {}
/** The venue's open tabs as the server holds them; every terminal shows the same. */
async findAll(venueId: string) {
const ownerships = await this.prisma.tabOwnership.findMany({
where: { venueId },
include: {
device: {
select: { id: true, name: true, lastSeenAt: true, revokedAt: true },
},
},
});
const snapshots = ownerships.length === 0 ? [] : await this.prisma.openTabSnapshot.findMany({
where: {
OR: ownerships.map((o) => ({ deviceId: o.deviceId, tabId: o.tabId })),
},
});
const snapshotByKey = new Map(snapshots.map((s) => [`${s.deviceId}:${s.tabId}`, s]));
const tabs = await this.prisma.tab.findMany({ where: { venueId }, orderBy: { openedAt: 'asc' } });
// Customer details (email for reminders) live on the planned customer with the same name
const planned = await this.prisma.plannedTab.findMany({
@@ -30,31 +18,16 @@ export class TabsService {
});
const plannedByKey = new Map(planned.map((p) => [p.customerNameKey, p]));
const tabs = [];
for (const ownership of ownerships) {
const snapshot = snapshotByKey.get(`${ownership.deviceId}:${ownership.tabId}`);
if (snapshot) {
const plannedTab = plannedByKey.get(customerNameKey(snapshot.customerName));
tabs.push({
tabId: snapshot.tabId,
customerName: snapshot.customerName,
version: snapshot.version,
totalInCents: snapshot.totalInCents,
itemCount: snapshot.itemCount,
// null: the terminal's app version doesn't report lines yet
items: snapshot.items ?? null,
deviceId: ownership.device.id,
deviceName: ownership.device.name,
deviceLastSeenAt: ownership.device.lastSeenAt,
deviceRevokedAt: ownership.device.revokedAt,
lastReportedAt: snapshot.reportedAt,
return {
tabs: tabs.map(tabSummary).map(({ updatedAt, ...tab }) => {
const plannedTab = plannedByKey.get(customerNameKey(tab.customerName));
return {
...tab,
lastChangedAt: updatedAt,
plannedTabId: plannedTab?.id ?? null,
email: plannedTab?.email ?? null,
});
}
}
return { tabs };
};
}),
};
}
}
+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')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, VenueAccessGuard)
@ApiOperation({ summary: 'Get venue device info' })
@ApiOperation({ summary: "The venue's paired terminals" })
async findDevices(@Param('venueId') venueId: string) {
const device = await this.prisma.device.findFirst({
const devices = await this.prisma.device.findMany({
where: { venueId, revokedAt: null },
select: { id: true, name: true, lastSeenAt: true, createdAt: true },
orderBy: { createdAt: 'asc' },
});
return { device };
return { devices };
}
}
+12 -3
View File
@@ -27,7 +27,7 @@ export default function DashboardPage() {
const deviceQuery = useQuery({
queryKey: ['device', venueId],
queryFn: () => devicesApi.get(venueId),
queryFn: () => devicesApi.list(venueId),
// A terminal going offline sends no event, so its status is always polled
refetchInterval: 30000,
});
@@ -45,7 +45,12 @@ export default function DashboardPage() {
});
const tabs = tabsQuery.data?.tabs ?? [];
const device = deviceQuery.data?.device;
const devices = deviceQuery.data?.devices ?? [];
// The terminal heard from most recently
const device = devices.reduce<(typeof devices)[number] | undefined>(
(latest, d) => (!latest || d.lastSeenAt > latest.lastSeenAt ? d : latest),
undefined,
);
const commands = recentQuery.data?.commands ?? [];
const outstanding = tabs.reduce((sum, tab) => sum + tab.totalInCents, 0);
const isLoading = tabsQuery.isLoading || deviceQuery.isLoading || pendingQuery.isLoading;
@@ -88,7 +93,11 @@ export default function DashboardPage() {
<span className="text-2xl font-bold truncate">{device.name}</span>
<DeviceStatusBadge lastSeenAt={device.lastSeenAt} />
</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>
@@ -1,13 +1,14 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { devicesApi, venuesApi } from '@/lib/api/client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { devicesApi, venuesApi, type Device } from '@/lib/api/client';
import { PageHeader } from '@/components/page-header';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { DeviceStatusBadge } from '@/components/device-status-badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { toast } from '@/components/ui/toast';
import { useLang } from '@/lib/i18n';
import { useSession } from '@/lib/session';
@@ -17,6 +18,8 @@ export default function SettingsPage() {
const { venueId, role } = useSession();
const [regDialog, setRegDialog] = useState<{ open: boolean; code: string; expiresAt: string } | null>(null);
const [generating, setGenerating] = useState(false);
const [unpairing, setUnpairing] = useState<Device | null>(null);
const queryClient = useQueryClient();
const { t, te, relativeTime, dateTime, time } = useLang();
const { data: venue } = useQuery({
@@ -26,12 +29,22 @@ export default function SettingsPage() {
const { data: deviceData } = useQuery({
queryKey: ['device', venueId],
queryFn: () => devicesApi.get(venueId),
queryFn: () => devicesApi.list(venueId),
// A terminal going offline sends no event, so its status is always polled
refetchInterval: 15000,
});
const device = deviceData?.device;
const devices = deviceData?.devices ?? [];
const unpairMutation = useMutation({
mutationFn: (device: Device) => devicesApi.unpair(venueId, device.id),
onSuccess: (_, device) => toast({ title: t('settings.unpaired', { name: device.name }), variant: 'success' }),
onError: (err) => toast({ title: t('common.failed'), description: te(err), variant: 'destructive' }),
onSettled: () => {
setUnpairing(null);
queryClient.invalidateQueries({ queryKey: ['device'] });
},
});
async function handleGenerateCode() {
setGenerating(true);
@@ -71,17 +84,29 @@ export default function SettingsPage() {
<CardHeader>
<CardTitle>{t('settings.device')}</CardTitle>
<CardDescription>
{device
? t('settings.paired', { name: device.name, time: relativeTime(device.lastSeenAt) })
: t('settings.not_paired')}
{devices.length === 0
? t('settings.not_paired')
: devices.length === 1
? t('settings.paired', { name: devices[0].name, time: relativeTime(devices[0].lastSeenAt) })
: t('settings.terminals', { count: devices.length })}
</CardDescription>
</CardHeader>
<CardContent>
{device ? (
<div className="flex items-center gap-2 text-sm">
{devices.length > 0 ? (
<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" />
<span className="font-medium">{device.name}</span>
<DeviceStatusBadge lastSeenAt={device.lastSeenAt} />
<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>
) : (
<p className="text-sm text-muted-foreground mb-3">
@@ -91,7 +116,7 @@ export default function SettingsPage() {
{role === 'admin' && (
<Button onClick={handleGenerateCode} disabled={generating} size="sm" className="mt-3">
<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>
)}
</CardContent>
@@ -99,6 +124,18 @@ export default function SettingsPage() {
</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 && (
<Dialog open={regDialog.open} onOpenChange={(open) => !open && setRegDialog(null)}>
<DialogContent>
+9 -33
View File
@@ -1,7 +1,7 @@
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Fragment, useEffect, useState } from 'react';
import { Fragment, useState } from 'react';
import { commandsApi, plannedTabsApi, remindersApi, tabsApi, type Tab, type TabLine } from '@/lib/api/client';
import { PageHeader } from '@/components/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -40,7 +40,6 @@ export default function TabsPage() {
const [closeTab, setCloseTab] = useState<Tab | null>(null);
const [paymentMethod, setPaymentMethod] = useState('');
const [expandedTabs, setExpandedTabs] = useState<Set<string>>(new Set());
const [watchedCommandIds, setWatchedCommandIds] = useState<Set<string>>(new Set());
const [search, setSearch] = useState('');
const [sort, setSort] = useState<SortKey>('name');
const [plannedName, setPlannedName] = useState('');
@@ -61,14 +60,6 @@ export default function TabsPage() {
});
const closingTabIds = new Set((pendingQuery.data?.commands ?? []).map((c) => c.tabId));
// Only needed to report the outcome of close requests sent from this page
const resultsQuery = useQuery({
queryKey: ['commands', venueId, 'results'],
queryFn: () => commandsApi.list(venueId, { limit: 50 }),
enabled: watchedCommandIds.size > 0,
refetchInterval: live ? false : 3000,
});
const plannedQuery = useQuery({
queryKey: ['planned-tabs', venueId],
queryFn: () => plannedTabsApi.list(venueId),
@@ -78,31 +69,16 @@ export default function TabsPage() {
// Tiles only show customers the terminal has not opened a tab for yet
const pendingPlanned = (plannedQuery.data?.plannedTabs ?? []).filter((pt) => !pt.deliveredAt);
useEffect(() => {
const finished = (resultsQuery.data?.commands ?? []).filter((c) => watchedCommandIds.has(c.id) && c.status !== 'pending');
if (finished.length === 0) return;
for (const cmd of finished) {
const status = t(`status.${cmd.status}`);
toast({
title: status,
description: t('tabs.request_result', { name: cmd.customerName ?? cmd.tabId.substring(0, 8), status }),
variant: cmd.status === 'completed' ? 'success' : 'destructive',
});
}
setWatchedCommandIds((current) => {
const next = new Set(current);
for (const cmd of finished) next.delete(cmd.id);
return next;
});
queryClient.invalidateQueries({ queryKey: ['tabs'] });
}, [resultsQuery.data, watchedCommandIds, queryClient, t]);
const createCommandMutation = useMutation({
mutationFn: ({ tab, paymentMethod }: { tab: Tab; paymentMethod: string }) =>
commandsApi.create(venueId, { tabId: tab.tabId, expectedVersion: tab.version, paymentMethod, type: 'close_tab' }),
onSuccess: (result) => {
setWatchedCommandIds((current) => new Set(current).add(result.id));
toast({ title: t('tabs.request_sent'), description: result.warning || t('tabs.request_sent_desc'), variant: result.warning ? 'default' : 'success' });
onSuccess: (result, { tab }) => {
toast({
title: t('tabs.settled'),
description: t('tabs.settled_desc', { name: tab.customerName, amount: formatCents(result.amountInCents) }),
variant: 'success',
});
queryClient.invalidateQueries({ queryKey: ['tabs'] });
},
onError: (err) => {
toast({ title: t('common.failed'), description: te(err), variant: 'destructive' });
@@ -325,7 +301,7 @@ export default function TabsPage() {
</TableCell>
<TableCell className="tabular-nums">{formatCents(tab.totalInCents)}</TableCell>
<TableCell>{tab.itemCount}</TableCell>
<TableCell>{relativeTime(tab.lastReportedAt)}</TableCell>
<TableCell>{relativeTime(tab.lastChangedAt)}</TableCell>
<TableCell className="text-right">
{closingTabIds.has(tab.tabId) ? (
<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 };
/** An open tab as the server holds it: every terminal of the venue shows the same. */
export interface Tab {
tabId: string;
customerName: string;
@@ -16,11 +17,7 @@ export interface Tab {
totalInCents: number;
itemCount: number;
items: TabLine[] | null;
deviceId: string;
deviceName: string;
deviceLastSeenAt: string;
deviceRevokedAt: string | null;
lastReportedAt: string;
lastChangedAt: string;
plannedTabId: string | null;
email: string | null;
}
@@ -40,7 +37,8 @@ export interface Command {
createdAt: string;
processedAt: string | null;
result: string | null;
deviceId: string;
/** Null for a tab the server settled itself */
deviceId: string | null;
}
export interface Device {
@@ -259,8 +257,10 @@ export const tabsApi = {
// Devices API
export const devicesApi = {
get: (venueId: string) =>
api<{ device: Device | null }>(`/venues/${venueId}/devices`),
list: (venueId: string) =>
api<{ devices: Device[] }>(`/venues/${venueId}/devices`),
unpair: (venueId: string, deviceId: string) =>
api(`/venues/${venueId}/commands/devices/${deviceId}/revoke`, { method: 'POST' }),
generateCode: () => api<{ code: string; expiresAt: string }>('/devices/generation-code'),
};
@@ -285,7 +285,7 @@ export const commandsApi = {
);
},
create: (venueId: string, data: { tabId: string; type: string; expectedVersion: number; paymentMethod: string }) =>
api<{ id: string; status: string; warning?: string }>(`/venues/${venueId}/commands`, {
api<{ id: string; status: string; amountInCents: number }>(`/venues/${venueId}/commands`, {
method: 'POST',
body: JSON.stringify(data),
}),
+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.command_already_pending': 'A close request for this tab is already waiting for the terminal',
'errors.tab_not_owned': 'This tab is no longer open on the terminal',
'errors.tab_not_found': 'This tab is no longer open',
'errors.tab_empty': 'There is nothing on this tab to settle',
'errors.device_revoked': 'The terminal for this tab has been unpaired',
'errors.name_exists': 'This customer already exists',
'errors.email_exists': 'A user with this email already exists',
@@ -90,14 +92,13 @@ const translations: Record<Lang, Record<string, string>> = {
'tabs.lines_empty': 'No items on this tab.',
'tabs.each': 'each',
'tabs.close_title': "Close {name}'s tab?",
'tabs.close_desc': 'Send a close request to the terminal.',
'tabs.close_desc': 'Settles everything on the tab now. The terminals show it after their next sync.',
'tabs.payment': 'Payment method',
'tabs.select_payment': 'Please select a payment method',
'tabs.send': 'Send close request',
'tabs.sending': 'Sending...',
'tabs.request_sent': 'Close request sent',
'tabs.request_sent_desc': 'Sent to the terminal',
'tabs.request_result': 'Close request for {name}: {status}',
'tabs.settled': 'Tab settled',
'tabs.settled_desc': '{name}: {amount}',
'tabs.send_test': 'Send test email',
'tabs.test_sent': 'Test email sent',
'tabs.test_sent_desc': 'Sent to {email}',
@@ -185,7 +186,12 @@ const translations: Record<Lang, Record<string, string>> = {
'settings.since': 'Since {date}',
'settings.pair_desc': 'Pair a bar terminal to start receiving tabs.',
'settings.pair': 'Pair Terminal',
'settings.repair': 'Re-pair Terminal',
'settings.repair': 'Pair Another Terminal',
'settings.unpair': 'Unpair',
'settings.unpair_title': 'Unpair {name}?',
'settings.unpair_desc': 'The terminal stops syncing and has to be paired again to reconnect. Its changes that have not been synced yet are lost.',
'settings.unpaired': '{name} is unpaired',
'settings.terminals': '{count} terminals paired',
'settings.generating': 'Generating...',
'settings.pair_title': 'Pair a Terminal',
'settings.pair_instructions': 'Enter this code on the bar terminal to connect it to your venue.',
@@ -208,6 +214,7 @@ const translations: Record<Lang, Record<string, string>> = {
'overview.no_commands': 'No commands yet',
'overview.not_connected': 'Not connected',
'overview.last_seen': 'Last seen {time}',
'overview.terminals': '{count} terminals, last seen {time}',
'auth.login': 'Sign in',
'auth.logging_in': 'Signing in...',
'auth.email': 'Email',
@@ -293,27 +300,27 @@ const translations: Record<Lang, Record<string, string>> = {
'stock.out': 'Out',
'stock.adjust': 'Adjust',
'stock.start_tracking': 'Track stock',
'stock.start_tracking_desc': "The terminal doesn't track stock for this product yet. Enter how many you have to start tracking it.",
'stock.start_tracking_desc': "Stock isn't tracked for this product yet. Enter how many you have to start tracking it.",
'stock.cancel_adjustment': 'Cancel {change} for {name}',
'stock.cancelled': 'Change cancelled',
'stock.current': 'Currently {stock} in stock on the terminal.',
'stock.current': 'Currently {stock} in stock.',
'stock.mode_add': 'Add (delivery)',
'stock.mode_remove': 'Take out (breakage, own use…)',
'stock.mode_count': 'Count (set the stock to what you counted)',
'stock.quantity': 'Quantity',
'stock.counted': 'Counted',
'stock.preview': '{from} → {to}',
'stock.count_hint': 'Drinks sold between your count and the terminal receiving it are not subtracted.',
'stock.count_hint': 'Drinks rung up before your count are in it: they are not subtracted again, also not when an offline terminal sends them later.',
'stock.note': 'Note (optional)',
'stock.note_placeholder': 'e.g. delivery from the brewery',
'stock.threshold': 'Warn when stock is at or below',
'stock.threshold_none': 'No warning',
'stock.threshold_save': 'Save',
'stock.threshold_saved': 'Warning level saved',
'stock.send': 'Send to terminal',
'stock.send': 'Save',
'stock.sending': 'Sending…',
'stock.sent': 'Sent to the terminal',
'stock.sent_desc': 'The stock changes on the terminal at its next sync.',
'stock.sent': 'Stock updated',
'stock.sent_desc': 'The terminals show it after their next sync.',
'stock.delivery': 'Register delivery',
'stock.delivery_title': 'Register delivery',
'stock.delivery_desc': 'Enter how many of each product were delivered. Products you leave empty are not changed.',
@@ -355,6 +362,8 @@ const translations: Record<Lang, Record<string, string>> = {
'errors.version_mismatch': 'De poef is gewijzigd sinds ze geladen werd. Controleer en probeer opnieuw.',
'errors.command_already_pending': 'Er wacht al een sluitverzoek voor deze poef op de kassa',
'errors.tab_not_owned': 'Deze poef staat niet meer open op de kassa',
'errors.tab_not_found': 'Deze poef staat niet meer open',
'errors.tab_empty': 'Er staat niets op deze poef om af te rekenen',
'errors.device_revoked': 'De kassa van deze poef is ontkoppeld',
'errors.name_exists': 'Deze klant bestaat al',
'errors.email_exists': 'Er bestaat al een gebruiker met dit e-mailadres',
@@ -416,14 +425,13 @@ const translations: Record<Lang, Record<string, string>> = {
'tabs.lines_empty': 'Geen items op deze poef.',
'tabs.each': 'per stuk',
'tabs.close_title': 'Poef van {name} sluiten?',
'tabs.close_desc': 'Stuur een verzoek naar de terminal om deze poef te sluiten.',
'tabs.close_desc': 'Rekent alles op de poef nu af. De terminals tonen het na hun volgende synchronisatie.',
'tabs.payment': 'Betaalmethode',
'tabs.select_payment': 'Kies een betaalmethode',
'tabs.send': 'Sluitverzoek versturen',
'tabs.sending': 'Bezig met versturen...',
'tabs.request_sent': 'Sluitverzoek verstuurd',
'tabs.request_sent_desc': 'Verstuurd naar de terminal',
'tabs.request_result': 'Sluitverzoek voor {name}: {status}',
'tabs.settled': 'Poef afgerekend',
'tabs.settled_desc': '{name}: {amount}',
'tabs.send_test': 'Testmail versturen',
'tabs.test_sent': 'Testmail verstuurd',
'tabs.test_sent_desc': 'Verstuurd naar {email}',
@@ -511,7 +519,12 @@ const translations: Record<Lang, Record<string, string>> = {
'settings.since': 'Sinds {date}',
'settings.pair_desc': 'Koppel een barterminal om poefs te ontvangen.',
'settings.pair': 'Terminal koppelen',
'settings.repair': 'Terminal opnieuw koppelen',
'settings.repair': 'Nog een terminal koppelen',
'settings.unpair': 'Ontkoppelen',
'settings.unpair_title': '{name} ontkoppelen?',
'settings.unpair_desc': 'De terminal synchroniseert niet meer en moet opnieuw gekoppeld worden. Wijzigingen die nog niet gesynchroniseerd zijn, gaan verloren.',
'settings.unpaired': '{name} is ontkoppeld',
'settings.terminals': '{count} terminals gekoppeld',
'settings.generating': 'Code aanmaken...',
'settings.pair_title': 'Terminal koppelen',
'settings.pair_instructions': 'Voer deze code in op de barterminal om hem met je zaak te verbinden.',
@@ -534,6 +547,7 @@ const translations: Record<Lang, Record<string, string>> = {
'overview.no_commands': 'Nog geen opdrachten',
'overview.not_connected': 'Niet verbonden',
'overview.last_seen': 'Laatst gezien {time}',
'overview.terminals': '{count} terminals, laatst gezien {time}',
'auth.login': 'Inloggen',
'auth.logging_in': 'Bezig met inloggen...',
'auth.email': 'E-mail',
@@ -619,27 +633,27 @@ const translations: Record<Lang, Record<string, string>> = {
'stock.out': 'Op',
'stock.adjust': 'Aanpassen',
'stock.start_tracking': 'Voorraad bijhouden',
'stock.start_tracking_desc': 'De kassa houdt voor dit product nog geen voorraad bij. Vul in hoeveel je er hebt om te beginnen.',
'stock.start_tracking_desc': 'Voor dit product wordt nog geen voorraad bijgehouden. Vul in hoeveel je er hebt om te beginnen.',
'stock.cancel_adjustment': '{change} voor {name} annuleren',
'stock.cancelled': 'Wijziging geannuleerd',
'stock.current': 'Nu {stock} in voorraad op de kassa.',
'stock.current': 'Nu {stock} in voorraad.',
'stock.mode_add': 'Bijvullen (levering)',
'stock.mode_remove': 'Afboeken (breuk, eigen gebruik…)',
'stock.mode_count': 'Tellen (voorraad wordt wat je telde)',
'stock.quantity': 'Aantal',
'stock.counted': 'Geteld',
'stock.preview': '{from} → {to}',
'stock.count_hint': 'Wat verkocht wordt tussen je telling en het moment dat de kassa ze ontvangt, wordt niet afgetrokken.',
'stock.count_hint': 'Wat voor je telling aangeslagen werd, zit erin: het wordt niet nog eens afgetrokken, ook niet als een offline kassa het later doorstuurt.',
'stock.note': 'Notitie (optioneel)',
'stock.note_placeholder': 'bv. levering brouwerij',
'stock.threshold': 'Waarschuw vanaf',
'stock.threshold_none': 'Geen waarschuwing',
'stock.threshold_save': 'Opslaan',
'stock.threshold_saved': 'Waarschuwingsniveau opgeslagen',
'stock.send': 'Naar kassa sturen',
'stock.send': 'Opslaan',
'stock.sending': 'Versturen…',
'stock.sent': 'Naar de kassa gestuurd',
'stock.sent_desc': 'De voorraad wijzigt op de kassa bij de volgende synchronisatie.',
'stock.sent': 'Voorraad bijgewerkt',
'stock.sent_desc': 'De kassa\'s tonen het na hun volgende synchronisatie.',
'stock.delivery': 'Levering invoeren',
'stock.delivery_title': 'Levering invoeren',
'stock.delivery_desc': 'Vul in hoeveel er van elk product geleverd is. Lege producten blijven ongewijzigd.',
+39 -36
View File
@@ -28,47 +28,48 @@ Swagger UI: `http://localhost:3001/api/docs`
### Venues
`GET /venues` - List venues
`GET /venues/:venueId` - Get venue details
`GET /venues/:venueId/devices` - List devices in venue
`GET /venues/:venueId/devices` - The venue's paired terminals `{ devices: [...] }`
### Devices
`POST /devices/register` - Register a new device
`POST /devices/register` - Pair a terminal; every pairing adds one
`POST /devices/:deviceId/sync` - Sync device state (device auth)
`POST /devices/:deviceId/commands/:commandId/ack` - Acknowledge command (device auth)
`GET /devices/generation-code` - Generate registration code (admin)
Sync semantics:
- `openTabs` must be the device's **complete** list of open tabs. Tabs it no longer reports
(e.g. deleted on the terminal) are removed from the dashboard. Settling a tab on the terminal
keeps the tab open under the same name with a zero balance, so the name stays listed.
- Every sync returns **all** pending commands for the device; `cursor` is still returned but no longer
filters. Devices dedupe by command id and re-send the ack for commands they already processed.
- The first ack for a command wins; later acks are accepted as no-ops.
- `plannedTabNames` only contains planned customers the device has not opened a tab for yet. Once a sync
reports an open tab with that name (ignoring case and extra spaces), the customer is marked delivered
and no longer sent. The customer (and their email, used for reminders) is kept.
- `events` (optional) reports what happened on tabs since the last sync, for sales statistics. Each event
has a terminal-generated UUID `id`, a `type`, `tabId`, `customerName` and `occurredAt` (ISO-8601, UTC):
- `line_added` / `line_removed`: `productName`, `unitPriceInCents`, `quantity` (always positive)
- `settled`: `amountInCents`, `paymentMethod` (`cash`, `payconiq`, `payment_pending` or `other`),
optional `commandId` (the remote close that caused it) and `items` (the settled lines)
Sync semantics. The server holds the venue's open tabs and stock; a venue can have several terminals. Each terminal
works offline, queues what it does as **events**, and on every sync replaces its tabs and stock with the server's and
replays its unsent events on top. The full rules are in the app repository: `docs/server-tabs-source-of-truth.md`
and `docs/server-stock-source-of-truth.md`.
- Every write to a venue's tabs, stock and settlements (syncs, dashboard settles, stock adjustments) runs in one
transaction that locks the venue row, so simultaneous terminals apply their changes one after the other.
- `events` (at most 1000): terminal-generated UUID `id`, `type`, `occurredAt` (ISO-8601, UTC; capped at the server
time when more than 5 minutes ahead) and per type:
- `tab_opened` (`tabId`, `customerName`, `openedAt`), `tab_renamed` (new `customerName`), `tab_deleted`
- `line_added` / `line_removed`: `lineId`, `productId`, `productName`, `unitPriceInCents`, `quantity` (≥ 1)
- `settled`: `amountInCents`, `paymentMethod` (`cash`, `payconiq`, `payment_pending` or `other`), optional
`commandId` and `items` (`[{ productId?, productName, quantity, unitPriceInCents }]`)
- `stock_set`: `productId`, `quantity` (a count ≥ 0, or `null` to stop tracking); no `tabId`
The response lists `acknowledgedEventIds` (stored, including events already stored by an earlier sync)
and `rejectedEvents` (`[{ id, reason }]`, invalid and never stored). The terminal removes both from its
outbox. An invalid event never fails the sync. At most 1000 events per sync.
- `products` (optional) is the terminal's **complete** product catalog:
`[{ id, name, category?, priceInCents, stock }]`, where `stock` is an integer (may be negative) or `null` when
the terminal doesn't track stock for that product. Products left out are removed from the dashboard. When the
field is omitted (older app versions) the stored products are left alone. An invalid product is skipped and
logged, never failing the sync. At most 2000 products.
- `stockAdjustments` in the response lists **every** pending stock change requested from the dashboard
for the venue, oldest first: `[{ id, productId, type, quantity, note, requestedBy, createdAt }]`.
`type: "add"` changes the stock by `quantity` (negative takes stock out); `type: "set"` makes the stock
`quantity` (a count, ≥ 0, and starts tracking an untracked product). They are re-sent on every sync until the
terminal reports an outcome, so the terminal must apply each id only once.
- `stockAdjustmentResults` (optional) reports outcomes: `[{ id, status: "applied" | "rejected", stockAfter?, reason? }]`
(`stockAfter` is required for `applied`). The response lists `acknowledgedStockAdjustmentResultIds` and
`rejectedStockAdjustmentResults` (`[{ id, reason }]`); the terminal drops both from its outbox. The terminal's
outcome is recorded even if the adjustment was cancelled on the dashboard in the meantime.
Each id is applied once (tab state and stock in the same transaction). `acknowledgedEventIds` lists the applied
ones, including repeats; `rejectedEvents` (`[{ id, reason }]`) were invalid and never applied. The terminal drops
both from its outbox. An invalid event never fails the sync.
- The response's `tabs` is the venue's **complete** open tab state after this request's events:
`[{ id, customerName, openedAt, version, lines: [{ id, productId, productName, unitPriceInCents, quantity,
units: [{ id: "<event id>:<index>", addedAt, removedAt }] }] }]`.
- `stock` is every product of the venue: `[{ productId, stock, countedAt }]` (`stock` null when not tracked, may be
negative). Line events change it unless they happened before `countedAt`; the latest count wins.
- `settlements` lists the venue's settlements after `settlementsCursor` (or of the last 30 days without one), oldest
first, at most 500; the response's `settlementsCursor` is sent back next time.
- `openTabs` is only used once per venue, on the first sync after the server started holding the tabs: the terminal's
own tabs (and catalog stock) are taken over and the events of that request are stored without applying them again.
- `products` (optional, at most 2000) upserts names, categories and prices. Its `stock` is ignored (counts arrive as
`stock_set`), a new product starts untracked, and products are never removed because one terminal doesn't report
them. An invalid product is skipped and logged.
- Planned customers are opened as tabs by the server, once; `plannedTabNames` is always empty.
- Dashboard stock adjustments are applied by the server right away; `stockAdjustments` is always empty.
`stockAdjustmentResults` from older app versions are still recorded and acknowledged.
- Every sync returns **all** pending commands for the device (only `close_tab` commands created before the server
settled tabs itself); `cursor` is still returned but no longer filters. The first ack for a command wins.
### Tabs
`GET /venues/:venueId/tabs` - List open tabs
@@ -98,9 +99,11 @@ Names match case-insensitively and keep their existing spelling; `replace` also
`PUT /venues/:venueId/stock/products/:productId` - Set `{ lowStockThreshold: number | null }` (admin, dashboard only)
### Commands
`POST /venues/:venueId/commands` - Create a command
`POST /venues/:venueId/commands` - Settle a tab: `{ tabId, type: "close_tab", expectedVersion, paymentMethod }`. The
server settles everything on the tab at once (also with every terminal offline) and records the command as
`completed`. `version_mismatch` when the tab changed since it was shown, `tab_empty` when there is nothing on it.
`GET /venues/:venueId/commands` - List command history
`POST /venues/:venueId/commands/devices/:deviceId/revoke` - Revoke device (admin)
`POST /venues/:venueId/commands/devices/:deviceId/revoke` - Unpair a terminal (admin)
All `/venues/:venueId/...` routes return `403 venue_forbidden` unless the user belongs to that venue.