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
+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 };
}
}