This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "RemoteCommand" DROP CONSTRAINT "RemoteCommand_deviceId_fkey";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "Device_venueId_key";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Venue" ADD COLUMN "tabsSeededAt" TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "RemoteCommand" ALTER COLUMN "deviceId" DROP NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "TabEvent" ADD COLUMN "lineId" TEXT,
|
||||
ADD COLUMN "productId" TEXT,
|
||||
ADD COLUMN "stockSetTo" INTEGER,
|
||||
ALTER COLUMN "tabId" DROP NOT NULL,
|
||||
ALTER COLUMN "customerName" DROP NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Product" ADD COLUMN "countedAt" TIMESTAMP(3);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Tab" (
|
||||
"venueId" TEXT NOT NULL,
|
||||
"id" TEXT NOT NULL,
|
||||
"customerName" TEXT NOT NULL,
|
||||
"openedAt" TIMESTAMP(3) NOT NULL,
|
||||
"version" INTEGER NOT NULL DEFAULT 0,
|
||||
"lines" JSONB NOT NULL,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Tab_pkey" PRIMARY KEY ("venueId","id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Settlement" (
|
||||
"id" TEXT NOT NULL,
|
||||
"sequence" SERIAL NOT NULL,
|
||||
"venueId" TEXT NOT NULL,
|
||||
"tabId" TEXT NOT NULL,
|
||||
"customerName" TEXT NOT NULL,
|
||||
"settledAt" TIMESTAMP(3) NOT NULL,
|
||||
"paymentMethod" TEXT NOT NULL,
|
||||
"amountInCents" INTEGER NOT NULL,
|
||||
"lines" JSONB NOT NULL,
|
||||
"deviceId" TEXT,
|
||||
"requestedBy" TEXT,
|
||||
"commandId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Settlement_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Settlement_sequence_key" ON "Settlement"("sequence");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Settlement_venueId_sequence_idx" ON "Settlement"("venueId", "sequence");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Settlement_venueId_settledAt_idx" ON "Settlement"("venueId", "settledAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RemoteCommand" ADD CONSTRAINT "RemoteCommand_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Tab" ADD CONSTRAINT "Tab_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Settlement" ADD CONSTRAINT "Settlement_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Settlement" ADD CONSTRAINT "Settlement_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
@@ -24,11 +24,17 @@ model Venue {
|
||||
tabEvents TabEvent[]
|
||||
products Product[]
|
||||
stockAdjustments StockAdjustment[]
|
||||
tabs Tab[]
|
||||
settlements Settlement[]
|
||||
// Set on the first sync after the server started holding the tabs: the tabs were then taken over from the
|
||||
// terminal's own open tabs, once
|
||||
tabsSeededAt DateTime?
|
||||
}
|
||||
|
||||
model Device {
|
||||
id String @id @default(uuid())
|
||||
venueId String @unique
|
||||
// A venue can have several terminals
|
||||
venueId String
|
||||
name String
|
||||
tokenHash String @unique
|
||||
tokenPrefix String
|
||||
@@ -42,6 +48,7 @@ model Device {
|
||||
tabOwnerships TabOwnership[]
|
||||
remoteCommands RemoteCommand[]
|
||||
tabEvents TabEvent[]
|
||||
settlements Settlement[]
|
||||
|
||||
@@index([venueId, revokedAt])
|
||||
}
|
||||
@@ -105,10 +112,13 @@ model TabOwnership {
|
||||
@@id([venueId, tabId])
|
||||
}
|
||||
|
||||
// A request from the dashboard. close_tab used to be executed by the owning terminal; the server now settles
|
||||
// the tab itself and records the command as completed right away, for the history.
|
||||
model RemoteCommand {
|
||||
id String @id @default(uuid())
|
||||
venueId String
|
||||
deviceId String
|
||||
// Null for a command the server carried out itself
|
||||
deviceId String?
|
||||
targetTabId String
|
||||
customerName String?
|
||||
amountInCents Int?
|
||||
@@ -122,8 +132,8 @@ model RemoteCommand {
|
||||
createdAt DateTime @default(now())
|
||||
expiresAt DateTime
|
||||
|
||||
venue Venue @relation(fields: [venueId], references: [id])
|
||||
device Device @relation(fields: [deviceId], references: [id])
|
||||
venue Venue @relation(fields: [venueId], references: [id])
|
||||
device Device? @relation(fields: [deviceId], references: [id])
|
||||
|
||||
@@index([deviceId, status, createdAt])
|
||||
@@index([venueId, status, createdAt])
|
||||
@@ -188,16 +198,19 @@ model TabReminder {
|
||||
@@index([venueId, tabId])
|
||||
}
|
||||
|
||||
// What happened on a tab, as reported by the terminal: lines added or removed, and settlements.
|
||||
// The id is generated by the terminal so a re-sent event is stored only once.
|
||||
// What happened on a tab, as reported by the terminal. The id is generated by the terminal so a re-sent event is
|
||||
// stored, and applied to the tab state and stock, only once.
|
||||
model TabEvent {
|
||||
id String @id
|
||||
venueId String
|
||||
deviceId String
|
||||
tabId String
|
||||
customerName String
|
||||
// line_added | line_removed | settled
|
||||
// Null for stock_set, which is not about a tab
|
||||
tabId String?
|
||||
customerName String?
|
||||
// tab_opened | tab_renamed | tab_deleted | line_added | line_removed | settled | stock_set
|
||||
type String
|
||||
lineId String?
|
||||
productId String?
|
||||
productName String?
|
||||
unitPriceInCents Int?
|
||||
// Signed: positive for line_added, negative for line_removed
|
||||
@@ -207,6 +220,8 @@ model TabEvent {
|
||||
paymentMethod String?
|
||||
commandId String?
|
||||
items Json?
|
||||
// stock_set: the counted stock, null to stop tracking
|
||||
stockSetTo Int?
|
||||
occurredAt DateTime
|
||||
receivedAt DateTime @default(now())
|
||||
|
||||
@@ -217,8 +232,9 @@ model TabEvent {
|
||||
@@index([venueId, type, occurredAt])
|
||||
}
|
||||
|
||||
// The terminal's product catalog with its stock, as last reported by the terminal. The terminal owns the
|
||||
// stock: it counts sales down offline, so the dashboard only changes it through StockAdjustments.
|
||||
// The venue's products as reported by its terminals, with the stock. The server owns the stock: it follows the
|
||||
// terminals' line events and stock counts and the dashboard's StockAdjustments. A product is never removed because
|
||||
// one terminal doesn't report it.
|
||||
model Product {
|
||||
venueId String
|
||||
// Generated by the terminal
|
||||
@@ -226,8 +242,10 @@ model Product {
|
||||
name String
|
||||
category String?
|
||||
priceInCents Int
|
||||
// Null when the terminal doesn't track stock for this product. Can be negative: a sale is never blocked.
|
||||
// Null when stock is not tracked for this product. Can be negative: a sale is never blocked.
|
||||
stock Int?
|
||||
// When the stock was last counted; line events that happened before it are already in the count
|
||||
countedAt DateTime?
|
||||
// Dashboard-only setting, never sent to or overwritten by the terminal
|
||||
lowStockThreshold Int?
|
||||
reportedAt DateTime
|
||||
@@ -237,8 +255,8 @@ model Product {
|
||||
@@id([venueId, productId])
|
||||
}
|
||||
|
||||
// A stock change requested from the dashboard. It is sent to the terminal on every sync until the terminal
|
||||
// reports it applied or rejected.
|
||||
// A stock change made on the dashboard. The server applies it right away. Older ones may still have been applied
|
||||
// by a terminal, which reported the outcome.
|
||||
model StockAdjustment {
|
||||
id String @id @default(uuid())
|
||||
venueId String
|
||||
@@ -252,7 +270,7 @@ model StockAdjustment {
|
||||
requestedById String
|
||||
// pending | applied | rejected | cancelled
|
||||
status String @default("pending")
|
||||
// The terminal's stock right after applying it
|
||||
// The stock right after applying it
|
||||
stockAfter Int?
|
||||
result String?
|
||||
createdAt DateTime @default(now())
|
||||
@@ -263,3 +281,49 @@ model StockAdjustment {
|
||||
@@index([venueId, status, createdAt])
|
||||
@@index([venueId, productId, createdAt])
|
||||
}
|
||||
|
||||
// An open tab of the venue: the source of truth the terminals replace their own tabs with on every sync. Tabs are
|
||||
// never closed, only emptied by a settlement; tab_deleted removes one.
|
||||
model Tab {
|
||||
venueId String
|
||||
// Generated by the terminal that opened it
|
||||
id String
|
||||
customerName String
|
||||
openedAt DateTime
|
||||
// Bumped by every change, so the dashboard can refuse to settle a tab that changed since it was shown
|
||||
version Int @default(0)
|
||||
// [{ id, productId, productName, unitPriceInCents, units: [{ id, addedAt, removedAt }] }]
|
||||
lines Json
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
venue Venue @relation(fields: [venueId], references: [id])
|
||||
|
||||
@@id([venueId, id])
|
||||
}
|
||||
|
||||
// A settlement made on a terminal (id = the settled event's id) or on the dashboard. Terminals read them as a feed
|
||||
// after their cursor, which is the sequence number.
|
||||
model Settlement {
|
||||
id String @id
|
||||
sequence Int @unique @default(autoincrement())
|
||||
venueId String
|
||||
tabId String
|
||||
customerName String
|
||||
settledAt DateTime
|
||||
// cash | payconiq | payment_pending | other
|
||||
paymentMethod String
|
||||
amountInCents Int
|
||||
// The settled lines, in the shape of Tab.lines with each line's settled and removed units
|
||||
lines Json
|
||||
// Who settled it: a terminal, or a dashboard user
|
||||
deviceId String?
|
||||
requestedBy String?
|
||||
commandId String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
venue Venue @relation(fields: [venueId], references: [id])
|
||||
device Device? @relation(fields: [deviceId], references: [id])
|
||||
|
||||
@@index([venueId, sequence])
|
||||
@@index([venueId, settledAt])
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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' } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export default function DashboardPage() {
|
||||
|
||||
const deviceQuery = useQuery({
|
||||
queryKey: ['device', venueId],
|
||||
queryFn: () => devicesApi.get(venueId),
|
||||
queryFn: () => devicesApi.list(venueId),
|
||||
// A terminal going offline sends no event, so its status is always polled
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
@@ -45,7 +45,12 @@ export default function DashboardPage() {
|
||||
});
|
||||
|
||||
const tabs = tabsQuery.data?.tabs ?? [];
|
||||
const device = deviceQuery.data?.device;
|
||||
const devices = deviceQuery.data?.devices ?? [];
|
||||
// The terminal heard from most recently
|
||||
const device = devices.reduce<(typeof devices)[number] | undefined>(
|
||||
(latest, d) => (!latest || d.lastSeenAt > latest.lastSeenAt ? d : latest),
|
||||
undefined,
|
||||
);
|
||||
const commands = recentQuery.data?.commands ?? [];
|
||||
const outstanding = tabs.reduce((sum, tab) => sum + tab.totalInCents, 0);
|
||||
const isLoading = tabsQuery.isLoading || deviceQuery.isLoading || pendingQuery.isLoading;
|
||||
@@ -88,7 +93,11 @@ export default function DashboardPage() {
|
||||
<span className="text-2xl font-bold truncate">{device.name}</span>
|
||||
<DeviceStatusBadge lastSeenAt={device.lastSeenAt} />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t('overview.last_seen', { time: relativeTime(device.lastSeenAt) })}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{devices.length > 1
|
||||
? t('overview.terminals', { count: devices.length, time: relativeTime(device.lastSeenAt) })
|
||||
: t('overview.last_seen', { time: relativeTime(device.lastSeenAt) })}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-2xl font-bold">{t('overview.not_connected')}</div>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { devicesApi, venuesApi } from '@/lib/api/client';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { devicesApi, venuesApi, type Device } from '@/lib/api/client';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DeviceStatusBadge } from '@/components/device-status-badge';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { toast } from '@/components/ui/toast';
|
||||
import { useLang } from '@/lib/i18n';
|
||||
import { useSession } from '@/lib/session';
|
||||
@@ -17,6 +18,8 @@ export default function SettingsPage() {
|
||||
const { venueId, role } = useSession();
|
||||
const [regDialog, setRegDialog] = useState<{ open: boolean; code: string; expiresAt: string } | null>(null);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [unpairing, setUnpairing] = useState<Device | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const { t, te, relativeTime, dateTime, time } = useLang();
|
||||
|
||||
const { data: venue } = useQuery({
|
||||
@@ -26,12 +29,22 @@ export default function SettingsPage() {
|
||||
|
||||
const { data: deviceData } = useQuery({
|
||||
queryKey: ['device', venueId],
|
||||
queryFn: () => devicesApi.get(venueId),
|
||||
queryFn: () => devicesApi.list(venueId),
|
||||
// A terminal going offline sends no event, so its status is always polled
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
|
||||
const device = deviceData?.device;
|
||||
const devices = deviceData?.devices ?? [];
|
||||
|
||||
const unpairMutation = useMutation({
|
||||
mutationFn: (device: Device) => devicesApi.unpair(venueId, device.id),
|
||||
onSuccess: (_, device) => toast({ title: t('settings.unpaired', { name: device.name }), variant: 'success' }),
|
||||
onError: (err) => toast({ title: t('common.failed'), description: te(err), variant: 'destructive' }),
|
||||
onSettled: () => {
|
||||
setUnpairing(null);
|
||||
queryClient.invalidateQueries({ queryKey: ['device'] });
|
||||
},
|
||||
});
|
||||
|
||||
async function handleGenerateCode() {
|
||||
setGenerating(true);
|
||||
@@ -71,17 +84,29 @@ export default function SettingsPage() {
|
||||
<CardHeader>
|
||||
<CardTitle>{t('settings.device')}</CardTitle>
|
||||
<CardDescription>
|
||||
{device
|
||||
? t('settings.paired', { name: device.name, time: relativeTime(device.lastSeenAt) })
|
||||
: t('settings.not_paired')}
|
||||
{devices.length === 0
|
||||
? t('settings.not_paired')
|
||||
: devices.length === 1
|
||||
? t('settings.paired', { name: devices[0].name, time: relativeTime(devices[0].lastSeenAt) })
|
||||
: t('settings.terminals', { count: devices.length })}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{device ? (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Monitor className="h-4 w-4 text-muted-foreground" />
|
||||
<DeviceStatusBadge lastSeenAt={device.lastSeenAt} />
|
||||
<span className="text-muted-foreground">{t('settings.since', { date: dateTime(device.createdAt) })}</span>
|
||||
{devices.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{devices.map((device) => (
|
||||
<div key={device.id} className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<Monitor className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">{device.name}</span>
|
||||
<DeviceStatusBadge lastSeenAt={device.lastSeenAt} />
|
||||
<span className="text-muted-foreground">{t('settings.since', { date: dateTime(device.createdAt) })}</span>
|
||||
{role === 'admin' && (
|
||||
<Button variant="ghost" size="sm" className="ml-auto" onClick={() => setUnpairing(device)}>
|
||||
{t('settings.unpair')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
@@ -91,7 +116,7 @@ export default function SettingsPage() {
|
||||
{role === 'admin' && (
|
||||
<Button onClick={handleGenerateCode} disabled={generating} size="sm" className="mt-3">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{generating ? t('settings.generating') : device ? t('settings.repair') : t('settings.pair')}
|
||||
{generating ? t('settings.generating') : devices.length > 0 ? t('settings.repair') : t('settings.pair')}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -99,6 +124,18 @@ export default function SettingsPage() {
|
||||
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={unpairing !== null}
|
||||
onOpenChange={(open) => !open && setUnpairing(null)}
|
||||
title={t('settings.unpair_title', { name: unpairing?.name ?? '' })}
|
||||
description={t('settings.unpair_desc')}
|
||||
confirmLabel={t('settings.unpair')}
|
||||
cancelLabel={t('common.cancel')}
|
||||
variant="destructive"
|
||||
loading={unpairMutation.isPending}
|
||||
onConfirm={() => unpairing && unpairMutation.mutate(unpairing)}
|
||||
/>
|
||||
|
||||
{regDialog && (
|
||||
<Dialog open={regDialog.open} onOpenChange={(open) => !open && setRegDialog(null)}>
|
||||
<DialogContent>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Fragment, useEffect, useState } from 'react';
|
||||
import { Fragment, useState } from 'react';
|
||||
import { commandsApi, plannedTabsApi, remindersApi, tabsApi, type Tab, type TabLine } from '@/lib/api/client';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -40,7 +40,6 @@ export default function TabsPage() {
|
||||
const [closeTab, setCloseTab] = useState<Tab | null>(null);
|
||||
const [paymentMethod, setPaymentMethod] = useState('');
|
||||
const [expandedTabs, setExpandedTabs] = useState<Set<string>>(new Set());
|
||||
const [watchedCommandIds, setWatchedCommandIds] = useState<Set<string>>(new Set());
|
||||
const [search, setSearch] = useState('');
|
||||
const [sort, setSort] = useState<SortKey>('name');
|
||||
const [plannedName, setPlannedName] = useState('');
|
||||
@@ -61,14 +60,6 @@ export default function TabsPage() {
|
||||
});
|
||||
const closingTabIds = new Set((pendingQuery.data?.commands ?? []).map((c) => c.tabId));
|
||||
|
||||
// Only needed to report the outcome of close requests sent from this page
|
||||
const resultsQuery = useQuery({
|
||||
queryKey: ['commands', venueId, 'results'],
|
||||
queryFn: () => commandsApi.list(venueId, { limit: 50 }),
|
||||
enabled: watchedCommandIds.size > 0,
|
||||
refetchInterval: live ? false : 3000,
|
||||
});
|
||||
|
||||
const plannedQuery = useQuery({
|
||||
queryKey: ['planned-tabs', venueId],
|
||||
queryFn: () => plannedTabsApi.list(venueId),
|
||||
@@ -78,31 +69,16 @@ export default function TabsPage() {
|
||||
// Tiles only show customers the terminal has not opened a tab for yet
|
||||
const pendingPlanned = (plannedQuery.data?.plannedTabs ?? []).filter((pt) => !pt.deliveredAt);
|
||||
|
||||
useEffect(() => {
|
||||
const finished = (resultsQuery.data?.commands ?? []).filter((c) => watchedCommandIds.has(c.id) && c.status !== 'pending');
|
||||
if (finished.length === 0) return;
|
||||
for (const cmd of finished) {
|
||||
const status = t(`status.${cmd.status}`);
|
||||
toast({
|
||||
title: status,
|
||||
description: t('tabs.request_result', { name: cmd.customerName ?? cmd.tabId.substring(0, 8), status }),
|
||||
variant: cmd.status === 'completed' ? 'success' : 'destructive',
|
||||
});
|
||||
}
|
||||
setWatchedCommandIds((current) => {
|
||||
const next = new Set(current);
|
||||
for (const cmd of finished) next.delete(cmd.id);
|
||||
return next;
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['tabs'] });
|
||||
}, [resultsQuery.data, watchedCommandIds, queryClient, t]);
|
||||
|
||||
const createCommandMutation = useMutation({
|
||||
mutationFn: ({ tab, paymentMethod }: { tab: Tab; paymentMethod: string }) =>
|
||||
commandsApi.create(venueId, { tabId: tab.tabId, expectedVersion: tab.version, paymentMethod, type: 'close_tab' }),
|
||||
onSuccess: (result) => {
|
||||
setWatchedCommandIds((current) => new Set(current).add(result.id));
|
||||
toast({ title: t('tabs.request_sent'), description: result.warning || t('tabs.request_sent_desc'), variant: result.warning ? 'default' : 'success' });
|
||||
onSuccess: (result, { tab }) => {
|
||||
toast({
|
||||
title: t('tabs.settled'),
|
||||
description: t('tabs.settled_desc', { name: tab.customerName, amount: formatCents(result.amountInCents) }),
|
||||
variant: 'success',
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['tabs'] });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({ title: t('common.failed'), description: te(err), variant: 'destructive' });
|
||||
@@ -325,7 +301,7 @@ export default function TabsPage() {
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums">{formatCents(tab.totalInCents)}</TableCell>
|
||||
<TableCell>{tab.itemCount}</TableCell>
|
||||
<TableCell>{relativeTime(tab.lastReportedAt)}</TableCell>
|
||||
<TableCell>{relativeTime(tab.lastChangedAt)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{closingTabIds.has(tab.tabId) ? (
|
||||
<Badge variant="warning">{t('tabs.closing')}</Badge>
|
||||
|
||||
@@ -9,6 +9,7 @@ export type Role = 'admin' | 'viewer';
|
||||
|
||||
export type TabLine = { productName: string; quantity: number; unitPriceInCents: number };
|
||||
|
||||
/** An open tab as the server holds it: every terminal of the venue shows the same. */
|
||||
export interface Tab {
|
||||
tabId: string;
|
||||
customerName: string;
|
||||
@@ -16,11 +17,7 @@ export interface Tab {
|
||||
totalInCents: number;
|
||||
itemCount: number;
|
||||
items: TabLine[] | null;
|
||||
deviceId: string;
|
||||
deviceName: string;
|
||||
deviceLastSeenAt: string;
|
||||
deviceRevokedAt: string | null;
|
||||
lastReportedAt: string;
|
||||
lastChangedAt: string;
|
||||
plannedTabId: string | null;
|
||||
email: string | null;
|
||||
}
|
||||
@@ -40,7 +37,8 @@ export interface Command {
|
||||
createdAt: string;
|
||||
processedAt: string | null;
|
||||
result: string | null;
|
||||
deviceId: string;
|
||||
/** Null for a tab the server settled itself */
|
||||
deviceId: string | null;
|
||||
}
|
||||
|
||||
export interface Device {
|
||||
@@ -259,8 +257,10 @@ export const tabsApi = {
|
||||
|
||||
// Devices API
|
||||
export const devicesApi = {
|
||||
get: (venueId: string) =>
|
||||
api<{ device: Device | null }>(`/venues/${venueId}/devices`),
|
||||
list: (venueId: string) =>
|
||||
api<{ devices: Device[] }>(`/venues/${venueId}/devices`),
|
||||
unpair: (venueId: string, deviceId: string) =>
|
||||
api(`/venues/${venueId}/commands/devices/${deviceId}/revoke`, { method: 'POST' }),
|
||||
generateCode: () => api<{ code: string; expiresAt: string }>('/devices/generation-code'),
|
||||
};
|
||||
|
||||
@@ -285,7 +285,7 @@ export const commandsApi = {
|
||||
);
|
||||
},
|
||||
create: (venueId: string, data: { tabId: string; type: string; expectedVersion: number; paymentMethod: string }) =>
|
||||
api<{ id: string; status: string; warning?: string }>(`/venues/${venueId}/commands`, {
|
||||
api<{ id: string; status: string; amountInCents: number }>(`/venues/${venueId}/commands`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
@@ -29,6 +29,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'errors.version_mismatch': 'The tab changed since it was loaded. Check it and try again.',
|
||||
'errors.command_already_pending': 'A close request for this tab is already waiting for the terminal',
|
||||
'errors.tab_not_owned': 'This tab is no longer open on the terminal',
|
||||
'errors.tab_not_found': 'This tab is no longer open',
|
||||
'errors.tab_empty': 'There is nothing on this tab to settle',
|
||||
'errors.device_revoked': 'The terminal for this tab has been unpaired',
|
||||
'errors.name_exists': 'This customer already exists',
|
||||
'errors.email_exists': 'A user with this email already exists',
|
||||
@@ -90,14 +92,13 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'tabs.lines_empty': 'No items on this tab.',
|
||||
'tabs.each': 'each',
|
||||
'tabs.close_title': "Close {name}'s tab?",
|
||||
'tabs.close_desc': 'Send a close request to the terminal.',
|
||||
'tabs.close_desc': 'Settles everything on the tab now. The terminals show it after their next sync.',
|
||||
'tabs.payment': 'Payment method',
|
||||
'tabs.select_payment': 'Please select a payment method',
|
||||
'tabs.send': 'Send close request',
|
||||
'tabs.sending': 'Sending...',
|
||||
'tabs.request_sent': 'Close request sent',
|
||||
'tabs.request_sent_desc': 'Sent to the terminal',
|
||||
'tabs.request_result': 'Close request for {name}: {status}',
|
||||
'tabs.settled': 'Tab settled',
|
||||
'tabs.settled_desc': '{name}: {amount}',
|
||||
'tabs.send_test': 'Send test email',
|
||||
'tabs.test_sent': 'Test email sent',
|
||||
'tabs.test_sent_desc': 'Sent to {email}',
|
||||
@@ -185,7 +186,12 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'settings.since': 'Since {date}',
|
||||
'settings.pair_desc': 'Pair a bar terminal to start receiving tabs.',
|
||||
'settings.pair': 'Pair Terminal',
|
||||
'settings.repair': 'Re-pair Terminal',
|
||||
'settings.repair': 'Pair Another Terminal',
|
||||
'settings.unpair': 'Unpair',
|
||||
'settings.unpair_title': 'Unpair {name}?',
|
||||
'settings.unpair_desc': 'The terminal stops syncing and has to be paired again to reconnect. Its changes that have not been synced yet are lost.',
|
||||
'settings.unpaired': '{name} is unpaired',
|
||||
'settings.terminals': '{count} terminals paired',
|
||||
'settings.generating': 'Generating...',
|
||||
'settings.pair_title': 'Pair a Terminal',
|
||||
'settings.pair_instructions': 'Enter this code on the bar terminal to connect it to your venue.',
|
||||
@@ -208,6 +214,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'overview.no_commands': 'No commands yet',
|
||||
'overview.not_connected': 'Not connected',
|
||||
'overview.last_seen': 'Last seen {time}',
|
||||
'overview.terminals': '{count} terminals, last seen {time}',
|
||||
'auth.login': 'Sign in',
|
||||
'auth.logging_in': 'Signing in...',
|
||||
'auth.email': 'Email',
|
||||
@@ -293,27 +300,27 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'stock.out': 'Out',
|
||||
'stock.adjust': 'Adjust',
|
||||
'stock.start_tracking': 'Track stock',
|
||||
'stock.start_tracking_desc': "The terminal doesn't track stock for this product yet. Enter how many you have to start tracking it.",
|
||||
'stock.start_tracking_desc': "Stock isn't tracked for this product yet. Enter how many you have to start tracking it.",
|
||||
'stock.cancel_adjustment': 'Cancel {change} for {name}',
|
||||
'stock.cancelled': 'Change cancelled',
|
||||
'stock.current': 'Currently {stock} in stock on the terminal.',
|
||||
'stock.current': 'Currently {stock} in stock.',
|
||||
'stock.mode_add': 'Add (delivery)',
|
||||
'stock.mode_remove': 'Take out (breakage, own use…)',
|
||||
'stock.mode_count': 'Count (set the stock to what you counted)',
|
||||
'stock.quantity': 'Quantity',
|
||||
'stock.counted': 'Counted',
|
||||
'stock.preview': '{from} → {to}',
|
||||
'stock.count_hint': 'Drinks sold between your count and the terminal receiving it are not subtracted.',
|
||||
'stock.count_hint': 'Drinks rung up before your count are in it: they are not subtracted again, also not when an offline terminal sends them later.',
|
||||
'stock.note': 'Note (optional)',
|
||||
'stock.note_placeholder': 'e.g. delivery from the brewery',
|
||||
'stock.threshold': 'Warn when stock is at or below',
|
||||
'stock.threshold_none': 'No warning',
|
||||
'stock.threshold_save': 'Save',
|
||||
'stock.threshold_saved': 'Warning level saved',
|
||||
'stock.send': 'Send to terminal',
|
||||
'stock.send': 'Save',
|
||||
'stock.sending': 'Sending…',
|
||||
'stock.sent': 'Sent to the terminal',
|
||||
'stock.sent_desc': 'The stock changes on the terminal at its next sync.',
|
||||
'stock.sent': 'Stock updated',
|
||||
'stock.sent_desc': 'The terminals show it after their next sync.',
|
||||
'stock.delivery': 'Register delivery',
|
||||
'stock.delivery_title': 'Register delivery',
|
||||
'stock.delivery_desc': 'Enter how many of each product were delivered. Products you leave empty are not changed.',
|
||||
@@ -355,6 +362,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'errors.version_mismatch': 'De poef is gewijzigd sinds ze geladen werd. Controleer en probeer opnieuw.',
|
||||
'errors.command_already_pending': 'Er wacht al een sluitverzoek voor deze poef op de kassa',
|
||||
'errors.tab_not_owned': 'Deze poef staat niet meer open op de kassa',
|
||||
'errors.tab_not_found': 'Deze poef staat niet meer open',
|
||||
'errors.tab_empty': 'Er staat niets op deze poef om af te rekenen',
|
||||
'errors.device_revoked': 'De kassa van deze poef is ontkoppeld',
|
||||
'errors.name_exists': 'Deze klant bestaat al',
|
||||
'errors.email_exists': 'Er bestaat al een gebruiker met dit e-mailadres',
|
||||
@@ -416,14 +425,13 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'tabs.lines_empty': 'Geen items op deze poef.',
|
||||
'tabs.each': 'per stuk',
|
||||
'tabs.close_title': 'Poef van {name} sluiten?',
|
||||
'tabs.close_desc': 'Stuur een verzoek naar de terminal om deze poef te sluiten.',
|
||||
'tabs.close_desc': 'Rekent alles op de poef nu af. De terminals tonen het na hun volgende synchronisatie.',
|
||||
'tabs.payment': 'Betaalmethode',
|
||||
'tabs.select_payment': 'Kies een betaalmethode',
|
||||
'tabs.send': 'Sluitverzoek versturen',
|
||||
'tabs.sending': 'Bezig met versturen...',
|
||||
'tabs.request_sent': 'Sluitverzoek verstuurd',
|
||||
'tabs.request_sent_desc': 'Verstuurd naar de terminal',
|
||||
'tabs.request_result': 'Sluitverzoek voor {name}: {status}',
|
||||
'tabs.settled': 'Poef afgerekend',
|
||||
'tabs.settled_desc': '{name}: {amount}',
|
||||
'tabs.send_test': 'Testmail versturen',
|
||||
'tabs.test_sent': 'Testmail verstuurd',
|
||||
'tabs.test_sent_desc': 'Verstuurd naar {email}',
|
||||
@@ -511,7 +519,12 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'settings.since': 'Sinds {date}',
|
||||
'settings.pair_desc': 'Koppel een barterminal om poefs te ontvangen.',
|
||||
'settings.pair': 'Terminal koppelen',
|
||||
'settings.repair': 'Terminal opnieuw koppelen',
|
||||
'settings.repair': 'Nog een terminal koppelen',
|
||||
'settings.unpair': 'Ontkoppelen',
|
||||
'settings.unpair_title': '{name} ontkoppelen?',
|
||||
'settings.unpair_desc': 'De terminal synchroniseert niet meer en moet opnieuw gekoppeld worden. Wijzigingen die nog niet gesynchroniseerd zijn, gaan verloren.',
|
||||
'settings.unpaired': '{name} is ontkoppeld',
|
||||
'settings.terminals': '{count} terminals gekoppeld',
|
||||
'settings.generating': 'Code aanmaken...',
|
||||
'settings.pair_title': 'Terminal koppelen',
|
||||
'settings.pair_instructions': 'Voer deze code in op de barterminal om hem met je zaak te verbinden.',
|
||||
@@ -534,6 +547,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'overview.no_commands': 'Nog geen opdrachten',
|
||||
'overview.not_connected': 'Niet verbonden',
|
||||
'overview.last_seen': 'Laatst gezien {time}',
|
||||
'overview.terminals': '{count} terminals, laatst gezien {time}',
|
||||
'auth.login': 'Inloggen',
|
||||
'auth.logging_in': 'Bezig met inloggen...',
|
||||
'auth.email': 'E-mail',
|
||||
@@ -619,27 +633,27 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'stock.out': 'Op',
|
||||
'stock.adjust': 'Aanpassen',
|
||||
'stock.start_tracking': 'Voorraad bijhouden',
|
||||
'stock.start_tracking_desc': 'De kassa houdt voor dit product nog geen voorraad bij. Vul in hoeveel je er hebt om te beginnen.',
|
||||
'stock.start_tracking_desc': 'Voor dit product wordt nog geen voorraad bijgehouden. Vul in hoeveel je er hebt om te beginnen.',
|
||||
'stock.cancel_adjustment': '{change} voor {name} annuleren',
|
||||
'stock.cancelled': 'Wijziging geannuleerd',
|
||||
'stock.current': 'Nu {stock} in voorraad op de kassa.',
|
||||
'stock.current': 'Nu {stock} in voorraad.',
|
||||
'stock.mode_add': 'Bijvullen (levering)',
|
||||
'stock.mode_remove': 'Afboeken (breuk, eigen gebruik…)',
|
||||
'stock.mode_count': 'Tellen (voorraad wordt wat je telde)',
|
||||
'stock.quantity': 'Aantal',
|
||||
'stock.counted': 'Geteld',
|
||||
'stock.preview': '{from} → {to}',
|
||||
'stock.count_hint': 'Wat verkocht wordt tussen je telling en het moment dat de kassa ze ontvangt, wordt niet afgetrokken.',
|
||||
'stock.count_hint': 'Wat voor je telling aangeslagen werd, zit erin: het wordt niet nog eens afgetrokken, ook niet als een offline kassa het later doorstuurt.',
|
||||
'stock.note': 'Notitie (optioneel)',
|
||||
'stock.note_placeholder': 'bv. levering brouwerij',
|
||||
'stock.threshold': 'Waarschuw vanaf',
|
||||
'stock.threshold_none': 'Geen waarschuwing',
|
||||
'stock.threshold_save': 'Opslaan',
|
||||
'stock.threshold_saved': 'Waarschuwingsniveau opgeslagen',
|
||||
'stock.send': 'Naar kassa sturen',
|
||||
'stock.send': 'Opslaan',
|
||||
'stock.sending': 'Versturen…',
|
||||
'stock.sent': 'Naar de kassa gestuurd',
|
||||
'stock.sent_desc': 'De voorraad wijzigt op de kassa bij de volgende synchronisatie.',
|
||||
'stock.sent': 'Voorraad bijgewerkt',
|
||||
'stock.sent_desc': 'De kassa\'s tonen het na hun volgende synchronisatie.',
|
||||
'stock.delivery': 'Levering invoeren',
|
||||
'stock.delivery_title': 'Levering invoeren',
|
||||
'stock.delivery_desc': 'Vul in hoeveel er van elk product geleverd is. Lege producten blijven ongewijzigd.',
|
||||
|
||||
Reference in New Issue
Block a user