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;
+79 -15
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?
@@ -122,8 +132,8 @@ model RemoteCommand {
createdAt DateTime @default(now())
expiresAt DateTime
venue Venue @relation(fields: [venueId], references: [id])
device Device @relation(fields: [deviceId], references: [id])
venue Venue @relation(fields: [venueId], references: [id])
device Device? @relation(fields: [deviceId], references: [id])
@@index([deviceId, status, createdAt])
@@index([venueId, status, createdAt])
@@ -188,16 +198,19 @@ model TabReminder {
@@index([venueId, tabId])
}
// What happened on a tab, as reported by the terminal: lines added or removed, and settlements.
// The id is generated by the terminal so a re-sent event is stored only once.
// What happened on a tab, as reported by the terminal. The id is generated by the terminal so a re-sent event is
// stored, and applied to the tab state and stock, only once.
model TabEvent {
id String @id
venueId String
deviceId String
tabId String
customerName String
// line_added | line_removed | settled
// Null for stock_set, which is not about a tab
tabId String?
customerName String?
// tab_opened | tab_renamed | tab_deleted | line_added | line_removed | settled | stock_set
type String
lineId String?
productId String?
productName String?
unitPriceInCents Int?
// Signed: positive for line_added, negative for line_removed
@@ -207,6 +220,8 @@ model TabEvent {
paymentMethod String?
commandId String?
items Json?
// stock_set: the counted stock, null to stop tracking
stockSetTo Int?
occurredAt DateTime
receivedAt DateTime @default(now())
@@ -217,8 +232,9 @@ model TabEvent {
@@index([venueId, type, occurredAt])
}
// The terminal's product catalog with its stock, as last reported by the terminal. The terminal owns the
// stock: it counts sales down offline, so the dashboard only changes it through StockAdjustments.
// The venue's products as reported by its terminals, with the stock. The server owns the stock: it follows the
// terminals' line events and stock counts and the dashboard's StockAdjustments. A product is never removed because
// one terminal doesn't report it.
model Product {
venueId String
// Generated by the terminal
@@ -226,8 +242,10 @@ model Product {
name String
category String?
priceInCents Int
// Null when the terminal doesn't track stock for this product. Can be negative: a sale is never blocked.
// Null when stock is not tracked for this product. Can be negative: a sale is never blocked.
stock Int?
// When the stock was last counted; line events that happened before it are already in the count
countedAt DateTime?
// Dashboard-only setting, never sent to or overwritten by the terminal
lowStockThreshold Int?
reportedAt DateTime
@@ -237,8 +255,8 @@ model Product {
@@id([venueId, productId])
}
// A stock change requested from the dashboard. It is sent to the terminal on every sync until the terminal
// reports it applied or rejected.
// A stock change made on the dashboard. The server applies it right away. Older ones may still have been applied
// by a terminal, which reported the outcome.
model StockAdjustment {
id String @id @default(uuid())
venueId String
@@ -252,7 +270,7 @@ model StockAdjustment {
requestedById String
// pending | applied | rejected | cancelled
status String @default("pending")
// The terminal's stock right after applying it
// The stock right after applying it
stockAfter Int?
result String?
createdAt DateTime @default(now())
@@ -263,3 +281,49 @@ model StockAdjustment {
@@index([venueId, status, createdAt])
@@index([venueId, productId, createdAt])
}
// An open tab of the venue: the source of truth the terminals replace their own tabs with on every sync. Tabs are
// never closed, only emptied by a settlement; tab_deleted removes one.
model Tab {
venueId String
// Generated by the terminal that opened it
id String
customerName String
openedAt DateTime
// Bumped by every change, so the dashboard can refuse to settle a tab that changed since it was shown
version Int @default(0)
// [{ id, productId, productName, unitPriceInCents, units: [{ id, addedAt, removedAt }] }]
lines Json
updatedAt DateTime @updatedAt
venue Venue @relation(fields: [venueId], references: [id])
@@id([venueId, id])
}
// A settlement made on a terminal (id = the settled event's id) or on the dashboard. Terminals read them as a feed
// after their cursor, which is the sequence number.
model Settlement {
id String @id
sequence Int @unique @default(autoincrement())
venueId String
tabId String
customerName String
settledAt DateTime
// cash | payconiq | payment_pending | other
paymentMethod String
amountInCents Int
// The settled lines, in the shape of Tab.lines with each line's settled and removed units
lines Json
// Who settled it: a terminal, or a dashboard user
deviceId String?
requestedBy String?
commandId String?
createdAt DateTime @default(now())
venue Venue @relation(fields: [venueId], references: [id])
device Device? @relation(fields: [deviceId], references: [id])
@@index([venueId, sequence])
@@index([venueId, settledAt])
}
+13 -30
View File
@@ -145,36 +145,19 @@ export class AuthService {
}
const { rawToken, hash, prefix, expiresAt } = await this.generateDeviceToken();
let deviceId: string;
// One device per venue — update existing or create new
const existing = await this.prisma.device.findFirst({ where: { venueId } });
if (existing) {
deviceId = existing.id;
await this.prisma.device.update({
where: { id: existing.id },
data: {
name,
tokenHash: hash,
tokenPrefix: prefix,
tokenExpiresAt: expiresAt,
revokedAt: null,
lastSeenAt: new Date(),
},
});
} else {
deviceId = uuidv4();
await this.prisma.device.create({
data: {
id: deviceId,
venueId,
name,
tokenHash: hash,
tokenPrefix: prefix,
tokenExpiresAt: expiresAt,
},
});
}
// A venue can have several terminals: every pairing adds one. A terminal that is replaced is unpaired on the
// dashboard
const deviceId = uuidv4();
await this.prisma.device.create({
data: {
id: deviceId,
venueId,
name,
tokenHash: hash,
tokenPrefix: prefix,
tokenExpiresAt: expiresAt,
},
});
await this.auditService.log(venueId, 'device', deviceId, 'device_registered', { deviceName: name });
+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;
}
+340 -254
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(result.plannedTabNames).toEqual(['Els']);
expect(tab(result, 't1')!.lines).toMatchObject([{ id: 'line-1', quantity: 3 }]);
});
it('does not mark anything delivered when the device reports no tabs', async () => {
const { service, tx } = createService();
it('applies an event once, also when it arrives again in a later sync', async () => {
const { sync, tabletA, prisma } = setup();
const added = events.line('line_added', 't1', 1);
await sync(tabletA, { events: [events.opened('t1', 'Alice'), added] });
await service.sync(device, { deviceId: 'd1', openTabs: [] });
const result = await sync(tabletA, { events: [added, added] });
expect(tx.plannedTab.updateMany).not.toHaveBeenCalled();
expect(result.acknowledgedEventIds).toEqual([added.id, added.id]);
expect(tab(result, 't1')!.lines[0].quantity).toBe(1);
expect(prisma.tabEvent.rows).toHaveLength(2);
});
it('catches up with everything a tablet did offline, in order', async () => {
const { sync, tabletA, tabletB } = setup();
await sync(tabletB, { events: [events.opened('bob', 'Bob')] });
await sync(tabletA, {
events: [
events.opened('t1', 'Alice', 1),
events.line('line_added', 't1', 3, { minutes: 2 }),
events.line('line_removed', 't1', 1, { minutes: 3 }),
events.renamed('t1', 'Alice V.', 4),
events.deleted('bob', 'Bob', 5),
],
});
const result = await sync(tabletB);
expect(result.tabs.map((t) => t.customerName)).toEqual(['Alice V.']);
const [line] = tab(result, 't1')!.lines;
expect(line.quantity).toBe(2);
// The latest drink was taken back
expect(line.units.map((u) => u.removedAt)).toEqual([null, null, at(3)]);
});
it('settles only what the tablet saw, leaving a drink added elsewhere on the tab', async () => {
const { sync, tabletA, tabletB } = setup();
await sync(tabletA, { events: [events.opened('t1', 'Alice'), events.line('line_added', 't1', 2)] });
await sync(tabletB, { events: [events.line('line_added', 't1', 1, { minutes: 5 })] });
// Tablet A was offline and settled the 2 Cola it knew about
const result = await sync(tabletA, { events: [events.settled('t1', 2, 3)] });
expect(tab(result, 't1')!.lines).toMatchObject([{ quantity: 1, units: [{ addedAt: at(5) }] }]);
expect(result.settlements).toHaveLength(1);
expect(result.settlements[0]).toMatchObject({ tabId: 't1', paymentMethod: 'payconiq', amountInCents: 500 });
expect(result.settlements[0].lines).toMatchObject([{ productId: 'cola', quantity: 2 }]);
expect(result.settlements[0].lines[0].units.map((u) => u.addedAt)).toEqual([at(0), at(0)]);
});
it('brings a tab deleted elsewhere back with the drink that was put on it', async () => {
const { sync, tabletA, tabletB } = setup();
await sync(tabletA, { events: [events.opened('bob', 'Bob')] });
await sync(tabletB, { events: [events.deleted('bob', 'Bob', 1)] });
const result = await sync(tabletA, {
events: [{ ...events.line('line_added', 'bob', 1, { minutes: 2, productId: 'beer', productName: 'Beer', price: 400 }), customerName: 'Bob' }],
});
expect(tab(result, 'bob')).toMatchObject({ customerName: 'Bob', lines: [{ productName: 'Beer', quantity: 1 }] });
});
it('matches events from older app versions by product name and price', async () => {
const { sync, tabletA } = setup();
const { lineId, productId, ...old } = events.line('line_added', 't1', 2);
await sync(tabletA, { events: [events.opened('t1', 'Alice'), old] });
const { lineId: _, productId: __, ...removed } = events.line('line_removed', 't1', 1, { minutes: 1 });
const result = await sync(tabletA, { events: [removed] });
expect(tab(result, 't1')!.lines).toMatchObject([{ productId: null, quantity: 1 }]);
});
it('does not apply a rejected event, and still syncs the rest', async () => {
const { sync, tabletA } = setup();
const invalid = { ...events.line('line_added', 't1', 1), quantity: 0 };
const result = await sync(tabletA, { events: [events.opened('t1', 'Alice'), invalid] });
expect(result.rejectedEvents).toEqual([{ id: invalid.id, reason: expect.any(String) }]);
expect(tab(result, 't1')!.lines).toEqual([]);
});
it('opens planned customers as tabs once, instead of on every tablet', async () => {
const { sync, tabletA, tabletB, prisma } = setup();
prisma.plannedTab.rows.push(
{ id: 'p1', venueId: 'v1', customerName: 'Els', customerNameKey: 'els', deliveredAt: null, position: 0 },
{ id: 'p2', venueId: 'v1', customerName: 'alice', customerNameKey: 'alice', deliveredAt: null, position: 1 },
);
await sync(tabletA, { events: [events.opened('t1', 'Alice')] });
const result = await sync(tabletB);
expect(result.plannedTabNames).toEqual([]);
expect(result.tabs.map((t) => t.customerName).sort()).toEqual(['Alice', 'Els']);
expect(prisma.plannedTab.rows.every((p) => p.deliveredAt !== null)).toBe(true);
});
});
describe('DevicesService.sync: taking over a tablet once', () => {
it("takes over the tablet's tabs and stock without applying that request's events again", async () => {
const { sync, tabletA, prisma } = setup({ seeded: false });
const result = await sync(tabletA, {
openTabs: [{
id: 't1', customerName: 'Alice', version: 4, totalInCents: 750, itemCount: 3,
items: [{ productName: 'Cola', quantity: 3, unitPriceInCents: 250 }],
}],
// Already in the tabs and stock above
events: [events.line('line_added', 't1', 1)],
products: catalog(45),
});
expect(tab(result, 't1')).toMatchObject({ customerName: 'Alice', version: 4, lines: [{ productName: 'Cola', quantity: 3 }] });
expect(colaStock(result)).toBe(45);
expect(prisma.venue.rows[0].tabsSeededAt).not.toBeNull();
const next = await sync(tabletA, {
openTabs: [],
events: [events.line('line_added', 't1', 1, { productId: 'cola', minutes: 1 })],
products: catalog(44),
});
expect(tab(next, 't1')!.lines[0].quantity).toBe(4);
expect(colaStock(next)).toBe(44);
});
});
describe('DevicesService.sync: settlements feed', () => {
it("sends every tablet the venue's settlements once, after its cursor", async () => {
const { sync, tabletA, tabletB } = setup();
const settled = events.settled('t1', 1, 2);
await sync(tabletA, { events: [events.opened('t1', 'Alice'), events.line('line_added', 't1', 1), settled] });
const first = await sync(tabletB);
expect(first.settlements.map((s) => s.id)).toEqual([settled.id]);
const second = await sync(tabletB, { settlementsCursor: first.settlementsCursor });
expect(second.settlements).toEqual([]);
expect(second.settlementsCursor).toBe(first.settlementsCursor);
});
it('starts an empty feed after the latest settlement', async () => {
const { sync, tabletA } = setup();
const result = await sync(tabletA);
expect(result.settlements).toEqual([]);
expect(result.settlementsCursor).toBe('0');
});
});
describe('DevicesService.sync: the server holds the stock', () => {
async function trackedCola(sync: ReturnType<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);
});
});
+117 -220
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);
});
const changedIds = new Set(changedTabs.map((t) => t.id));
const unchangedIds = openTabIds.filter((id) => !changedIds.has(id));
const tabsChanged = previous.length !== openTabs.length || changedTabs.length > 0;
let plannedDelivered = 0;
// Products belong to the venue rather than the device, so they survive pairing a new terminal
const previousProducts = reported
? await this.prisma.product.findMany({
where: { venueId: device.venueId },
select: { productId: true, name: true, category: true, priceInCents: true, stock: true },
})
: [];
const previousProductsById = new Map(previousProducts.map((p) => [p.productId, p]));
const changedProducts = (reported?.products ?? []).filter((p) => {
const stored = previousProductsById.get(p.id);
return !stored || !sameProduct(stored, p);
});
const productsChanged = reported !== null
&& (previousProducts.length !== reported.products.length || changedProducts.length > 0);
let stockResultsRecorded = 0;
for (const ownership of takenOver) {
await this.auditService.log(
device.venueId,
'device',
device.id,
'snapshot_conflict',
{ tabId: ownership.tabId, previousDevice: ownership.deviceId, newDevice: device.id },
);
}
await this.prisma.$transaction(async (tx) => {
// The device reports its full set of open tabs: anything it no longer reports has been closed
await tx.tabOwnership.deleteMany({
where: { deviceId: device.id, tabId: { notIn: openTabIds } },
// 2. Events, each applied once: a terminal resends an event until its acknowledgement arrives
const unique = [...new Map(events.accepted.map((event) => [event.id, event])).values()];
const stored = unique.length === 0 ? [] : await tx.tabEvent.findMany({
where: { id: { in: unique.map((event) => event.id) } },
select: { id: true },
});
await tx.openTabSnapshot.deleteMany({
where: { deviceId: device.id, tabId: { notIn: openTabIds } },
});
if (unchangedIds.length > 0) {
await tx.openTabSnapshot.updateMany({
where: { deviceId: device.id, tabId: { in: unchangedIds } },
data: { reportedAt: now },
});
await tx.tabOwnership.updateMany({
where: { venueId: device.venueId, deviceId: device.id, tabId: { in: unchangedIds } },
data: { lastReportedAt: now },
});
const storedIds = new Set(stored.map((event) => event.id));
const fresh = unique.filter((event) => !storedIds.has(event.id));
if (fresh.length > 0) {
await tx.tabEvent.createMany({ data: fresh.map((event) => tabEventRow(event, venueId, device.id, now)) });
}
for (const tab of changedTabs) {
await tx.openTabSnapshot.upsert({
where: {
deviceId_tabId: { deviceId: device.id, tabId: tab.id },
},
create: {
deviceId: device.id,
tabId: tab.id,
customerName: tab.customerName,
version: tab.version,
totalInCents: tab.totalInCents,
itemCount: tab.itemCount,
items: snapshotItems(tab),
reportedAt: now,
},
update: {
customerName: tab.customerName,
version: tab.version,
totalInCents: tab.totalInCents,
itemCount: tab.itemCount,
items: snapshotItems(tab),
reportedAt: now,
},
});
await tx.tabOwnership.upsert({
where: { venueId_tabId: { venueId: device.venueId, tabId: tab.id } },
create: {
tabId: tab.id,
venueId: device.venueId,
deviceId: device.id,
lastReportedAt: now,
},
update: {
deviceId: device.id,
lastReportedAt: now,
},
});
}
if (openTabs.length > 0) {
({ count: plannedDelivered } = await tx.plannedTab.updateMany({
where: {
venueId: device.venueId,
deliveredAt: null,
customerNameKey: { in: openTabs.map((t) => customerNameKey(t.customerName)) },
},
data: { deliveredAt: now },
}));
}
if (reported) {
const reportedIds = reported.products.map((p) => p.id);
await tx.product.deleteMany({ where: { venueId: device.venueId, productId: { notIn: reportedIds } } });
await tx.product.updateMany({
where: { venueId: device.venueId, productId: { in: reportedIds } },
data: { reportedAt: now },
});
for (const product of changedProducts) {
const data = {
name: product.name,
category: product.category,
priceInCents: product.priceInCents,
stock: product.stock,
reportedAt: now,
};
await tx.product.upsert({
where: { venueId_productId: { venueId: device.venueId, productId: product.id } },
create: { venueId: device.venueId, productId: product.id, ...data },
update: data,
});
if (!ledger.seeded) {
// The first sync since the server holds the tabs and stock: the terminal's own tabs and stock already
// include the events of this request, so they are stored but not applied again
ledger.seed(seedTabs(dto.openTabs ?? [], now));
for (const product of reported?.products ?? []) ledger.stock.seed(product.id, product.stock);
} else {
for (const event of fresh) {
const occurredAt = eventTime(event, now);
const settlement = ledger.tabs.apply(event, occurredAt);
if (settlement) ledger.settlements.push({ ...settlement, deviceId: device.id });
ledger.stock.apply(event, occurredAt);
}
}
// The terminal's outcome is the truth, even for an adjustment cancelled while it was on its way
// 3. Outcomes from app versions that applied dashboard adjustments themselves. The terminal's outcome is the
// truth, even for an adjustment cancelled while it was on its way
let stockResultsRecorded = 0;
for (const result of stockResults.accepted) {
const { count } = await tx.stockAdjustment.updateMany({
where: { id: result.id, venueId: device.venueId, status: { in: ['pending', 'cancelled'] } },
where: { id: result.id, venueId, status: { in: ['pending', 'cancelled'] } },
data: {
status: result.status,
stockAfter: result.status === 'applied' ? result.stockAfter : null,
@@ -210,42 +102,39 @@ export class DevicesService {
});
stockResultsRecorded += count;
}
// Requested before the server applied adjustments itself, and never applied by a terminal
const adjustmentsApplied = await applyPendingAdjustments(tx, ledger, now);
if (events.accepted.length > 0) {
await tx.tabEvent.createMany({
data: events.accepted.map((event) => tabEventRow(event, device.venueId, device.id, now)),
skipDuplicates: true,
});
}
// 4. Planned customers become tabs here, once, instead of on every terminal
const plannedDelivered = await openPlannedTabs(tx, ledger, now);
await tx.device.update({
where: { id: device.id },
data: { lastSeenAt: now },
});
await ledger.save(tx, now);
await tx.device.update({ where: { id: device.id }, data: { lastSeenAt: now } });
const feed = await settlementsAfter(tx, venueId, dto.settlementsCursor, now);
return {
ledger,
feed,
topics: {
tabs: ledger.changedTabs,
plannedTabs: plannedDelivered > 0,
stock: productsChanged || ledger.stock.changed.size > 0 || stockResultsRecorded > 0 || adjustmentsApplied > 0,
},
};
}, { timeout: 15000 });
// Expire old commands
const expired = await this.prisma.remoteCommand.updateMany({
where: {
deviceId: device.id,
status: 'pending',
expiresAt: { lte: now },
},
where: { deviceId: device.id, status: 'pending', expiresAt: { lte: now } },
data: { status: 'expired' },
});
// The cursor is kept for client compatibility but no longer filters commands: every pending
// command is returned on each sync, so a command committed while a sync was in flight can never
// be skipped. Devices dedupe by command id and re-ack commands they already processed.
// close_tab commands are no longer created (the server settles tabs itself); ones from before that are
// still delivered. Every pending command is returned on each sync; devices dedupe by command id
const cursor = Buffer.from(`${now.toISOString()}:${uuidv4().substring(0, 8)}`).toString('base64url');
const commandWhere = {
deviceId: device.id,
status: 'pending',
};
// Find pending commands for this device
const commands = await this.prisma.remoteCommand.findMany({
where: commandWhere,
where: { deviceId: device.id, status: 'pending' },
select: {
id: true,
type: true,
@@ -254,35 +143,20 @@ export class DevicesService {
paymentMethod: true,
requestedBy: true,
createdAt: true,
status: true,
},
orderBy: { createdAt: 'asc' },
});
// Only customers the terminal has not opened a tab for yet
const plannedTabs = await this.prisma.plannedTab.findMany({
where: { venueId: device.venueId, deliveredAt: null },
orderBy: { position: 'asc' },
select: { customerName: true },
});
// Sent on every sync until the terminal reports an outcome; the terminal dedupes by id
const stockAdjustments = await this.prisma.stockAdjustment.findMany({
where: { venueId: device.venueId, status: 'pending' },
orderBy: { createdAt: 'asc' },
take: 500,
select: { id: true, productId: true, type: true, quantity: true, note: true, requestedBy: true, createdAt: true },
});
const topics: RealtimeTopic[] = [];
if (tabsChanged) topics.push('tabs');
if (plannedDelivered > 0) topics.push('planned-tabs');
if (synced.topics.tabs) topics.push('tabs');
if (synced.topics.plannedTabs) topics.push('planned-tabs');
if (expired.count > 0) topics.push('commands');
if (productsChanged || stockResultsRecorded > 0) topics.push('stock');
if (synced.topics.stock) topics.push('stock');
// Only when the device comes back online; dashboards poll the device status themselves
if (now.getTime() - new Date(device.lastSeenAt).getTime() > DEVICE_ONLINE_THRESHOLD_MS) topics.push('device');
if (topics.length > 0) this.realtime.notify(device.venueId, ...topics);
if (topics.length > 0) this.realtime.notify(venueId, ...topics);
const { ledger, feed } = synced;
return {
cursor,
commands: commands.map((c) => ({
@@ -294,19 +168,68 @@ export class DevicesService {
requestedBy: c.requestedBy,
createdAt: c.createdAt,
})),
plannedTabNames: plannedTabs.map((t) => t.customerName),
// Stored (or already stored earlier): the terminal can drop these from its outbox
// The server opens planned tabs itself; terminals must not open them as well
plannedTabNames: [],
// Applied (or already applied earlier): the terminal can drop these from its outbox
acknowledgedEventIds: events.accepted.map((e) => e.id),
// Invalid and never stored: the terminal should log and drop these too, re-sending won't help
// Invalid and never applied: the terminal should log and drop these too, re-sending won't help
rejectedEvents: events.rejected,
stockAdjustments,
// The server applies dashboard adjustments itself
stockAdjustments: [],
// Recorded (or already recorded earlier): the terminal can drop these results from its outbox
acknowledgedStockAdjustmentResultIds: stockResults.accepted.map((r) => r.id),
// Invalid and never recorded: drop these too, re-sending won't help
rejectedStockAdjustmentResults: stockResults.rejected,
// The venue's complete state after this request's events: the terminal replaces its own with it
tabs: ledger.tabs.all().map(tabJson),
stock: ledger.stock.entries().map(([productId, { stock, countedAt }]) => (
{ productId, stock, countedAt: countedAt?.toISOString() ?? null })),
settlements: feed.settlements,
settlementsCursor: feed.settlementsCursor,
};
}
/**
* Upserts the reported products without their stock, and never removes one: another terminal may still sell it.
* Returns whether anything the dashboard shows changed.
*/
private async saveProducts(tx: Prisma.TransactionClient, ledger: VenueLedger, products: ParsedProduct[], now: Date) {
const stored = await tx.product.findMany({
where: { venueId: ledger.venueId },
select: { productId: true, name: true, category: true, priceInCents: true },
});
const storedById = new Map(stored.map((p) => [p.productId, p]));
let changed = false;
const unchanged: string[] = [];
for (const product of products) {
const previous = storedById.get(product.id);
if (previous && previous.name === product.name && previous.category === product.category
&& previous.priceInCents === product.priceInCents) {
unchanged.push(product.id);
continue;
}
const data = { name: product.name, category: product.category, priceInCents: product.priceInCents, reportedAt: now };
await tx.product.upsert({
where: { venueId_productId: { venueId: ledger.venueId, productId: product.id } },
// Not tracked until counted: its starting stock arrives as a stock_set event
create: { venueId: ledger.venueId, productId: product.id, ...data, stock: null },
update: data,
});
ledger.stock.addProduct(product.id);
changed = true;
}
if (unchanged.length > 0) {
await tx.product.updateMany({
where: { venueId: ledger.venueId, productId: { in: unchanged } },
data: { reportedAt: now },
});
}
return changed;
}
async ackCommand(device: any, commandId: string, dto: AckCommandDto) {
if (!device) {
throw new NotFoundException({ code: 'device_not_found', message: 'Device not found' });
@@ -390,29 +313,3 @@ export class DevicesService {
return { ok: true };
}
}
type StoredProduct = { name: string; category: string | null; priceInCents: number; stock: number | null };
function sameProduct(stored: StoredProduct, reported: ParsedProduct): boolean {
return stored.name === reported.name && stored.category === reported.category
&& stored.priceInCents === reported.priceInCents && stored.stock === reported.stock;
}
type StoredItem = { productName: string; quantity: number; unitPriceInCents: number };
function sameItems(stored: StoredItem[] | null, reported: OpenTabItemDto[] | undefined): boolean {
if (!stored || !reported) return !stored && !reported;
return stored.length === reported.length && stored.every((s, i) =>
s.productName === reported[i].productName
&& s.quantity === reported[i].quantity
&& s.unitPriceInCents === reported[i].unitPriceInCents);
}
function storedItems(items: OpenTabItemDto[]): StoredItem[] {
return items.map(({ productName, quantity, unitPriceInCents }) => ({ productName, quantity, unitPriceInCents }));
}
/** The tab's lines in the stored shape, or JSON null when the terminal didn't report them. */
function snapshotItems(tab: OpenTabDto) {
return tab.items ? storedItems(tab.items) : Prisma.JsonNull;
}
+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');
});
+31 -15
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,22 +108,36 @@ export class StockService {
}
}
// Applied by the server right away, once, also when every terminal is offline; the terminals get the new stock
// on their next sync
const requestedBy = user.name || user.email;
const created = await this.prisma.$transaction(
entries.map((entry) => this.prisma.stockAdjustment.create({
data: {
venueId,
productId: entry.productId,
productName: productsById.get(entry.productId)!.name,
type: entry.type,
quantity: entry.quantity,
note: entry.note ?? null,
requestedBy,
requestedById: user.id,
},
select: adjustmentSelect,
})),
);
const created = await this.prisma.$transaction(async (tx) => {
const now = new Date();
const ledger = await VenueLedger.open(tx, venueId);
const ids: string[] = [];
for (const entry of entries) {
const { id } = await tx.stockAdjustment.create({
data: {
venueId,
productId: entry.productId,
productName: productsById.get(entry.productId)!.name,
type: entry.type,
quantity: entry.quantity,
note: entry.note ?? null,
requestedBy,
requestedById: user.id,
createdAt: now,
},
select: { id: true },
});
ids.push(id);
}
await applyPendingAdjustments(tx, ledger, now, ids);
await ledger.save(tx, now);
const adjustments = await tx.stockAdjustment.findMany({ where: { id: { in: ids } }, select: adjustmentSelect });
return ids.map((id) => adjustments.find((a) => a.id === id)!);
});
await this.auditService.log(venueId, 'client_user', user.id, 'stock_adjustments_created', {
adjustments: created.map((a) => ({ id: a.id, productId: a.productId, type: a.type, quantity: a.quantity })),
+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),
};
}
+13 -40
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 };
}
}