This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
Reference in New Issue
Block a user