feat: move to V2
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-10-29 11:21:52 +01:00
parent 6342b8451e
commit 314717ba3a
244 changed files with 11615 additions and 15292 deletions
-90
View File
@@ -1,90 +0,0 @@
import { TabEventDto } from '../devices/dto/tab-event.dto';
import { tabSummary, VenueTabs } from './tab-state';
import { tabOrders } from './tab-orders';
const at = (minutes: number) => new Date(Date.UTC(2026, 9, 28, 12, 30 + minutes));
let counter = 0;
function event(fields: Partial<TabEventDto> & { type: TabEventDto['type'] }): TabEventDto {
return { id: `e${++counter}`, tabId: 't1', customerName: 'Jan', occurredAt: at(0).toISOString(), ...fields } as TabEventDto;
}
const duvel = { lineId: 'duvel', productId: 'duvel', productName: 'Duvel', unitPriceInCents: 400 };
const cola = { lineId: 'cola', productId: 'cola', productName: 'Cola', unitPriceInCents: 250 };
const beer = { lineId: 'beer', productId: 'beer', productName: 'Beer', unitPriceInCents: 300 };
function tabWith(changes: [minutes: number, fields: Partial<TabEventDto> & { type: TabEventDto['type'] }][]) {
const tabs = new VenueTabs([]);
tabs.apply(event({ type: 'tab_opened' }), at(0));
for (const [minutes, fields] of changes) tabs.apply(event(fields), at(minutes));
return tabs;
}
describe('tabOrders', () => {
it('shows the drinks of each order under the time it started', () => {
const tabs = tabWith([
[0, { type: 'line_added', quantity: 2, orderId: 'o1', ...duvel }],
[1, { type: 'line_added', quantity: 1, orderId: 'o1', ...cola }],
[38, { type: 'line_added', quantity: 1, orderId: 'o2', ...duvel }],
[39, { type: 'line_added', quantity: 1, orderId: 'o2', ...beer }],
]);
expect(tabOrders(tabs.get('t1')!.lines)).toEqual([
{
orderId: 'o1', startedAt: at(0).toISOString(),
items: [
{ productName: 'Duvel', quantity: 2, unitPriceInCents: 400, removed: 0 },
{ productName: 'Cola', quantity: 1, unitPriceInCents: 250, removed: 0 },
],
},
{
orderId: 'o2', startedAt: at(38).toISOString(),
items: [
{ productName: 'Duvel', quantity: 1, unitPriceInCents: 400, removed: 0 },
{ productName: 'Beer', quantity: 1, unitPriceInCents: 300, removed: 0 },
],
},
]);
});
it('groups drinks from older app versions by time: a gap of over 5 minutes starts a new order', () => {
const tabs = tabWith([
[0, { type: 'line_added', quantity: 1, ...duvel }],
[4, { type: 'line_added', quantity: 1, ...cola }],
[20, { type: 'line_added', quantity: 1, ...beer }],
]);
const orders = tabOrders(tabs.get('t1')!.lines);
expect(orders.map((order) => [order.orderId, order.startedAt, order.items.map((item) => item.productName)])).toEqual([
[null, at(0).toISOString(), ['Duvel', 'Cola']],
[null, at(20).toISOString(), ['Beer']],
]);
});
it('takes a drink off the order it is removed from, and leaves the others', () => {
const tabs = tabWith([
[0, { type: 'line_added', quantity: 2, orderId: 'o1', ...duvel }],
[38, { type: 'line_added', quantity: 1, orderId: 'o2', ...duvel }],
[40, { type: 'line_removed', quantity: 1, orderId: 'o1', ...duvel }],
]);
expect(tabOrders(tabs.get('t1')!.lines).map((order) => [order.orderId, order.items[0].quantity, order.items[0].removed]))
.toEqual([['o1', 1, 1], ['o2', 1, 0]]);
// The open tab only shows what is still on it
expect(tabSummary({ ...tabs.get('t1')!, updatedAt: at(40) }).orders.map((order) => order.items))
.toEqual([[{ productName: 'Duvel', quantity: 1, unitPriceInCents: 400 }], [{ productName: 'Duvel', quantity: 1, unitPriceInCents: 400 }]]);
});
it('still takes the latest drinks off for a removal without an order (older app versions)', () => {
const tabs = tabWith([
[0, { type: 'line_added', quantity: 1, orderId: 'o1', ...duvel }],
[38, { type: 'line_added', quantity: 1, orderId: 'o2', ...duvel }],
]);
const removal = event({ type: 'line_removed', quantity: 5, ...duvel });
expect(tabs.removable(removal)).toBe(2);
expect(tabs.removable({ ...removal, orderId: 'o1' })).toBe(1);
expect(tabs.removable({ ...removal, orderId: null })).toBe(0);
});
});
-62
View File
@@ -1,62 +0,0 @@
import { isActive, Line, Unit } from './tab-state';
/** Drinks rung up without an order (older app versions) that follow each other this closely form one order. */
export const ORDER_GAP_MS = 5 * 60 * 1000;
export type OrderLine = { productName: string; quantity: number; unitPriceInCents: number; removed: number };
/** Drinks ordered together, as the terminal shows them under one "12:32 today" divider. */
export type TabOrder = {
// Null for drinks from older app versions, grouped by time instead
orderId: string | null;
// When its first drink was rung up
startedAt: string;
items: OrderLine[];
};
/**
* Splits a tab's (or settlement's) lines into its orders, oldest first. Within an order every line shows how many of
* its drinks are still on it and how many were taken off again.
*/
export function tabOrders(lines: Line[]): TabOrder[] {
const drinks = lines
.flatMap((line) => line.units.map((unit) => ({ line, unit })))
.sort((a, b) => a.unit.addedAt.localeCompare(b.unit.addedAt) || a.unit.id.localeCompare(b.unit.id));
const orders: { orderId: string | null; startedAt: string; units: Map<Line, Unit[]> }[] = [];
const byId = new Map<string, (typeof orders)[number]>();
let unordered: { order: (typeof orders)[number]; lastAt: number } | null = null;
for (const { line, unit } of drinks) {
let order: (typeof orders)[number];
if (unit.orderId) {
order = byId.get(unit.orderId) ?? { orderId: unit.orderId, startedAt: unit.addedAt, units: new Map() };
if (!byId.has(unit.orderId)) {
byId.set(unit.orderId, order);
orders.push(order);
}
} else {
const at = Date.parse(unit.addedAt);
if (!unordered || at - unordered.lastAt > ORDER_GAP_MS) {
unordered = { order: { orderId: null, startedAt: unit.addedAt, units: new Map() }, lastAt: at };
orders.push(unordered.order);
}
unordered.lastAt = at;
order = unordered.order;
}
order.units.set(line, [...(order.units.get(line) ?? []), unit]);
}
return orders
.sort((a, b) => a.startedAt.localeCompare(b.startedAt))
.map(({ orderId, startedAt, units }) => ({
orderId,
startedAt,
items: [...units].map(([line, lineUnits]) => ({
productName: line.productName,
quantity: lineUnits.filter(isActive).length,
unitPriceInCents: line.unitPriceInCents,
removed: lineUnits.filter((unit) => !isActive(unit)).length,
})),
}));
}
-153
View File
@@ -1,153 +0,0 @@
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('VenueTabs.removable', () => {
it('counts only drinks still on the line, so a removal after a settlement returns nothing to the stock', () => {
const tabs = withCola(3);
const removal = event({ type: 'line_removed', lineId: 'line-1', quantity: 5, ...cola });
expect(tabs.removable(removal)).toBe(3);
tabs.apply(event({ type: 'settled', amountInCents: 750, paymentMethod: 'cash', items: [{ ...cola, quantity: 3 }] }), time);
expect(tabs.removable(removal)).toBe(0);
expect(tabs.removable(event({ type: 'line_removed', tabId: 'gone', quantity: 1, ...cola }))).toBe(0);
});
});
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 });
});
});
-335
View File
@@ -1,335 +0,0 @@
import { TabEventDto } from '../devices/dto/tab-event.dto';
import { OpenTabDto } from '../devices/dto/sync.dto';
import { tabOrders } from './tab-orders';
/**
* 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. [orderId] is the order it was rung up in on the terminal: drinks ordered together are
* shown together. Missing for drinks from older app versions.
*/
export type Unit = { id: string; addedAt: string; removedAt: string | null; orderId?: 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;
/**
* The drinks of [line] a line_removed event may take off: those of its order (null: drinks without one), or any when
* the event has no order (older app versions).
*/
function removableUnits(line: Line, event: { orderId?: string | null }): Unit[] {
const active = line.units.filter(isActive);
if (event.orderId === undefined) return active;
return active.filter((unit) => (unit.orderId ?? null) === event.orderId);
}
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':
this.remove(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();
const orderId = event.orderId ?? null;
for (let index = 0; index < event.quantity!; index++) {
line.units.push({ id: `${event.id}:${index}`, addedAt, removedAt: null, orderId });
}
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 of the order: the drinks that were just rung up by mistake
const active = removableUnits(line, event);
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;
}
}
/**
* How many drinks a line_removed event can actually take off: none when the tab or line is gone (settled or
* deleted elsewhere while the terminal was offline).
*/
removable(event: TabEventDto): number {
const tab = event.tabId ? this.tabs.get(event.tabId) : undefined;
const line = tab && findLine(tab, event);
return line ? Math.min(event.quantity ?? 0, removableUnits(line, event).length) : 0;
}
/** 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,
};
}
/** Deletes a tab, from a terminal or the dashboard. False when it is not open. */
remove(tabId: string): boolean {
if (!this.tabs.delete(tabId)) return false;
this.changed.delete(tabId);
this.deleted.add(tabId);
return true;
}
/** 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,
// Only what is still on the tab: drinks taken off again are left out
orders: tabOrders(tab.lines as Line[])
.map((order) => ({ ...order, items: order.items.filter((item) => item.quantity > 0).map(({ removed, ...item }) => item) }))
.filter((order) => order.items.length > 0),
updatedAt: tab.updatedAt,
};
}
-75
View File
@@ -1,75 +0,0 @@
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),
};
}
+37 -44
View File
@@ -1,55 +1,48 @@
import { Controller, Delete, Get, Param, ParseIntPipe, Query, Req, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common';
import { z } from 'zod';
import { deleteTabQuerySchema, settlementsQuerySchema, SettlementPage, settleTabSchema, Tab } from '@kooltap/shared';
import { zod } from '../common/zod.pipe';
import { Authorized, AuthUser, CurrentUser, Roles } from '../common/auth';
import { TabsService } from './tabs.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { VenueAccessGuard } from '../common/guards/venue-access.guard';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
@ApiTags('Tabs')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, VenueAccessGuard, RolesGuard)
@Controller('venues/:venueId/tabs')
@Controller('venues/:venueId')
@Authorized()
export class TabsController {
constructor(private tabsService: TabsService) {}
constructor(private tabs: TabsService) {}
@Get()
@ApiOperation({ summary: 'List all open tabs for a venue' })
async findAll(@Param('venueId') venueId: string) {
return this.tabsService.findAll(venueId);
@Get('tabs')
findAll(@Param('venueId') venueId: string): Promise<Tab[]> {
return this.tabs.findAll(venueId);
}
@Get('history')
@ApiOperation({ summary: 'List paid tabs, newest first' })
@ApiQuery({ name: 'search', required: false, description: 'Part of the customer name' })
@ApiQuery({ name: 'paymentMethod', required: false })
@ApiQuery({ name: 'limit', required: false })
@ApiQuery({ name: 'offset', required: false })
async history(
@Param('venueId') venueId: string,
@Query('search') search?: string,
@Query('paymentMethod') paymentMethod?: string,
@Query('limit') limit?: string,
@Query('offset') offset?: string,
) {
return this.tabsService.history(venueId, {
search,
paymentMethod,
limit: Math.min(Math.max(parseInt(limit ?? '', 10) || 50, 1), 200),
offset: Math.max(parseInt(offset ?? '', 10) || 0, 0),
});
}
@Delete(':tabId')
@Post('tabs/:tabId/settle')
@HttpCode(HttpStatus.OK)
@Roles('admin', 'manager')
@ApiOperation({ summary: 'Delete an open tab without settling it' })
@ApiQuery({ name: 'expectedVersion', required: false, description: 'Refuse when the tab changed since this version' })
async remove(
settle(
@Param('venueId') venueId: string,
@Param('tabId') tabId: string,
@Req() req: any,
@Query('expectedVersion', new ParseIntPipe({ optional: true })) expectedVersion?: number,
@Param('tabId', ParseUUIDPipe) tabId: string,
@Body(zod(settleTabSchema)) dto: z.output<typeof settleTabSchema>,
@CurrentUser() user: AuthUser,
) {
return this.tabsService.remove(venueId, tabId, expectedVersion, req.user);
return this.tabs.settle(venueId, tabId, dto, user);
}
@Delete('tabs/:tabId')
@Roles('admin', 'manager')
remove(
@Param('venueId') venueId: string,
@Param('tabId', ParseUUIDPipe) tabId: string,
@Query(zod(deleteTabQuerySchema)) query: z.output<typeof deleteTabQuerySchema>,
@CurrentUser() user: AuthUser,
) {
return this.tabs.remove(venueId, tabId, query.expectedVersion, user);
}
@Get('settlements')
settlements(
@Param('venueId') venueId: string,
@Query(zod(settlementsQuerySchema)) query: z.output<typeof settlementsQuerySchema>,
): Promise<SettlementPage> {
return this.tabs.settlements(venueId, query);
}
}
+1 -2
View File
@@ -5,6 +5,5 @@ import { TabsService } from './tabs.service';
@Module({
controllers: [TabsController],
providers: [TabsService],
exports: [TabsService],
})
export class TabsModule {}
export class TabsModule {}
-64
View File
@@ -1,64 +0,0 @@
import { TabsService } from './tabs.service';
import { createFakePrisma } from '../testing/fake-prisma';
const at = (minutes: number) => new Date(Date.UTC(2026, 9, 27, 20, minutes));
function setup() {
const prisma = createFakePrisma();
prisma.device.rows.push({ id: 'd1', venueId: 'v1', name: 'Bar tablet' });
const service = new TabsService(prisma as any, { notify: jest.fn() } as any);
const settle = (id: string, customerName: string, minutes: number, extra: Record<string, unknown> = {}) =>
prisma.settlement.create({
data: {
id, venueId: 'v1', tabId: `tab-${id}`, customerName, settledAt: at(minutes), paymentMethod: 'cash',
amountInCents: 500, deviceId: 'd1', requestedBy: null,
lines: [{
id: 'l1', productId: 'cola', productName: 'Cola', unitPriceInCents: 250, quantity: 2,
units: [
{ id: 'u1', addedAt: at(0).toISOString(), removedAt: null },
{ id: 'u2', addedAt: at(0).toISOString(), removedAt: null },
{ id: 'u3', addedAt: at(0).toISOString(), removedAt: at(1).toISOString() },
],
}],
...extra,
},
});
return { prisma, service, settle };
}
describe('TabsService.history', () => {
it('lists paid tabs newest first, with their lines and who settled them', async () => {
const { service, settle } = setup();
await settle('s1', 'Alice', 1);
await settle('s2', 'Bob', 5, { deviceId: null, requestedBy: 'manager@bar.be', paymentMethod: 'payconiq' });
await settle('other', 'Eve', 9, { venueId: 'v2' });
const result = await service.history('v1', { limit: 50, offset: 0 });
expect(result.total).toBe(2);
expect(result.totalAmountInCents).toBe(1000);
expect(result.settlements.map((s) => s.id)).toEqual(['s2', 's1']);
expect(result.settlements[0]).toMatchObject({ settledBy: 'manager@bar.be', onDashboard: true, paymentMethod: 'payconiq' });
expect(result.settlements[1]).toMatchObject({ settledBy: 'Bar tablet', onDashboard: false, itemCount: 2 });
expect(result.settlements[1].items).toEqual([{ productName: 'Cola', quantity: 2, unitPriceInCents: 250, removed: 1 }]);
});
it('filters by part of the name and payment method, and pages', async () => {
const { service, settle } = setup();
await settle('s1', 'Alice', 1);
await settle('s2', 'alicia', 2, { paymentMethod: 'payconiq' });
await settle('s3', 'Bob', 3);
const byName = await service.history('v1', { search: ' ALI ', limit: 50, offset: 0 });
expect(byName.settlements.map((s) => s.id)).toEqual(['s2', 's1']);
const byMethod = await service.history('v1', { search: 'ali', paymentMethod: 'cash', limit: 50, offset: 0 });
expect(byMethod.settlements.map((s) => s.id)).toEqual(['s1']);
const secondPage = await service.history('v1', { limit: 2, offset: 2 });
expect(secondPage.total).toBe(3);
expect(secondPage.settlements.map((s) => s.id)).toEqual(['s1']);
});
});
+120 -85
View File
@@ -1,56 +1,130 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { v4 as uuidv4 } from 'uuid';
import { PrismaService } from '../prisma/prisma.service';
import { randomUUID } from 'crypto';
import { DASHBOARD_PAYMENT_METHODS, PaymentMethod, Settlement, SettlementPage, Tab, TabLine } from '@kooltap/shared';
import { Prisma, PrismaService } from '../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { RealtimeService } from '../realtime/realtime.service';
import { customerNameKey } from '../planned-tabs/customer-name';
import { SettledLine, tabSummary } from './tab-state';
import { tabOrders } from './tab-orders';
import { VenueLedger } from './venue-ledger';
import { CashPaymentNotifier } from '../email/cash-payment-notifier';
import { AuthUser } from '../common/auth';
import { customerNameKey } from '../customers/customer-name';
import { linesOf, VenueLedger } from '../ledger/venue-ledger';
import { isActive, Line, lineQuantity, TabState } from '../ledger/tab-state';
import { tabOrders } from '../ledger/tab-orders';
/** What is still on a tab: its lines with drinks, and the total. */
export function tabContents(lines: Line[]): { items: TabLine[]; totalInCents: number; itemCount: number } {
const items = lines
.map((line) => ({ productName: line.productName, quantity: lineQuantity(line), unitPriceInCents: line.unitPriceInCents }))
.filter((item) => item.quantity > 0);
return {
items,
totalInCents: items.reduce((sum, item) => sum + item.quantity * item.unitPriceInCents, 0),
itemCount: items.reduce((sum, item) => sum + item.quantity, 0),
};
}
@Injectable()
export class TabsService {
constructor(
private prisma: PrismaService,
private audit: AuditService,
private realtime: RealtimeService,
private cashPayments: CashPaymentNotifier,
) {}
/** The venue's open tabs as the server holds them; every terminal shows the same. */
async findAll(venueId: string) {
const tabs = await this.prisma.tab.findMany({ where: { venueId }, orderBy: { openedAt: 'asc' } });
/** The venue's open tabs, with the email of the customer with the same name. */
async findAll(venueId: string): Promise<Tab[]> {
const [tabs, units, customers] = await Promise.all([
this.prisma.tab.findMany({ where: { venueId }, orderBy: { openedAt: 'asc' } }),
this.prisma.tabUnit.findMany({ where: { venueId, tabId: { not: null } } }),
this.prisma.customer.findMany({ where: { venueId }, select: { id: true, nameKey: true, email: true } }),
]);
const customersByKey = new Map(customers.map((c) => [c.nameKey, c]));
// Customer details (email for reminders) live on the planned customer with the same name
const planned = await this.prisma.plannedTab.findMany({
where: { venueId },
select: { id: true, customerNameKey: true, email: true },
return tabs.map((tab) => {
const lines = linesOf(units.filter((unit) => unit.tabId === tab.id));
const customer = customersByKey.get(customerNameKey(tab.customerName));
return {
id: tab.id,
customerName: tab.customerName,
version: tab.version,
...tabContents(lines),
// Only what is still on the tab: drinks taken off again are left out
orders: tabOrders(lines)
.map((order) => ({ ...order, items: order.items.filter((item) => item.quantity > 0).map(({ removed, ...item }) => item) }))
.filter((order) => order.items.length > 0),
updatedAt: tab.updatedAt.toISOString(),
customerId: customer?.id ?? null,
email: customer?.email ?? null,
};
});
const plannedByKey = new Map(planned.map((p) => [p.customerNameKey, p]));
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,
};
}),
};
}
/**
* The venue's paid tabs, newest first, as the terminals' history shows them: every settlement made on a terminal
* or on the dashboard. [search] matches the customer name.
* Settles everything on a tab, at once, also when every terminal is offline; the terminals see the drinks go on
* their next sync. Refused when the tab changed since [expectedVersion].
*/
async history(
async settle(
venueId: string,
options: { search?: string; paymentMethod?: string; limit: number; offset: number },
tabId: string,
dto: { expectedVersion: number; paymentMethod: (typeof DASHBOARD_PAYMENT_METHODS)[number] },
user: AuthUser,
) {
const search = options.search?.trim();
const settlement = await this.prisma.$transaction(async (tx) => {
const now = new Date();
const ledger = await VenueLedger.open(tx, venueId);
this.checkVersion(ledger.tabs.get(tabId), dto.expectedVersion);
const draft = ledger.tabs.settleAll(tabId, { id: randomUUID(), settledAt: now, paymentMethod: dto.paymentMethod });
if (!draft) throw new ConflictException({ code: 'tab_empty', message: 'There is nothing on this tab to settle' });
ledger.settlements.push({ ...draft, settledByUserId: user.id });
await ledger.save(tx, now);
await this.audit.log({
venueId,
actor: { type: 'user', id: user.id },
action: 'tab_settled',
details: { settlementId: draft.id, tabId, paymentMethod: dto.paymentMethod, amountInCents: draft.amountInCents },
}, tx);
return draft;
});
this.realtime.notify(venueId, 'tabs');
this.cashPayments.notify(venueId, [settlement], user.name);
return { id: settlement.id, amountInCents: settlement.amountInCents };
}
/**
* Deletes a tab without settling it, as tab_deleted does on a terminal: what is still on it is not paid and stays
* out of the stock. A terminal that still has unsent drinks for it brings it back, so no sale is lost.
*/
async remove(venueId: string, tabId: string, expectedVersion: number, user: AuthUser) {
await this.prisma.$transaction(async (tx) => {
const now = new Date();
const ledger = await VenueLedger.open(tx, venueId);
const tab = this.checkVersion(ledger.tabs.get(tabId), expectedVersion);
const { items, totalInCents } = tabContents(tab.lines);
ledger.tabs.remove(tabId);
await ledger.save(tx, now);
await this.audit.log({
venueId,
actor: { type: 'user', id: user.id },
action: 'tab_deleted',
details: { tabId, customerName: tab.customerName, amountInCents: totalInCents, items },
}, tx);
});
this.realtime.notify(venueId, 'tabs');
return { ok: true };
}
/** The venue's paid tabs, newest first. [search] matches the customer name. */
async settlements(venueId: string, options: { search?: string; paymentMethod?: PaymentMethod; limit: number; offset: number }): Promise<SettlementPage> {
const where: Prisma.SettlementWhereInput = {
venueId,
...(search ? { customerName: { contains: search, mode: 'insensitive' as const } } : {}),
...(options.search ? { customerName: { contains: options.search, mode: 'insensitive' } } : {}),
...(options.paymentMethod ? { paymentMethod: options.paymentMethod } : {}),
};
@@ -60,38 +134,33 @@ export class TabsService {
orderBy: [{ settledAt: 'desc' }, { sequence: 'desc' }],
skip: options.offset,
take: options.limit,
include: { units: true, device: { select: { name: true } }, settledBy: { select: { name: true } } },
}),
this.prisma.settlement.count({ where }),
this.prisma.settlement.aggregate({ where, _sum: { amountInCents: true } }),
]);
const deviceIds = [...new Set(rows.map((row) => row.deviceId).filter((id): id is string => id !== null))];
const devices = deviceIds.length > 0
? await this.prisma.device.findMany({ where: { id: { in: deviceIds } }, select: { id: true, name: true } })
: [];
const deviceNames = new Map(devices.map((device) => [device.id, device.name]));
return {
settlements: rows.map((row) => {
const lines = row.lines as unknown as SettledLine[];
settlements: rows.map((row): Settlement => {
const lines = linesOf(row.units);
const items = lines.map((line) => ({
productName: line.productName,
quantity: line.quantity,
quantity: lineQuantity(line),
unitPriceInCents: line.unitPriceInCents,
// Rung up and taken off again before paying
removed: line.units.filter((unit) => unit.removedAt !== null).length,
removed: line.units.filter((unit) => !isActive(unit)).length,
}));
return {
id: row.id,
tabId: row.tabId,
customerName: row.customerName,
settledAt: row.settledAt,
settledAt: row.settledAt.toISOString(),
paymentMethod: row.paymentMethod,
amountInCents: row.amountInCents,
itemCount: items.reduce((count, item) => count + item.quantity, 0),
items,
orders: tabOrders(lines),
settledBy: (row.deviceId ? deviceNames.get(row.deviceId) : row.requestedBy) ?? null,
settledBy: row.device?.name ?? row.settledBy?.name ?? null,
onDashboard: row.deviceId === null,
};
}),
@@ -100,45 +169,11 @@ export class TabsService {
};
}
/**
* Deletes a tab without settling it, as tab_deleted does on a terminal: what is still on it is not paid and stays
* out of the stock. The terminals drop it on their next sync; one that still has unsent drinks for it brings it
* back, so no sale is lost. [expectedVersion] refuses a tab that changed since it was shown.
*/
async remove(venueId: string, tabId: string, expectedVersion: number | undefined, user: { id: string }) {
await this.prisma.$transaction(async (tx) => {
const now = new Date();
const ledger = await VenueLedger.open(tx, venueId);
const tab = ledger.tabs.get(tabId);
if (!tab) {
throw new NotFoundException({ code: 'tab_not_found', message: 'This tab is no longer open' });
}
if (expectedVersion !== undefined && tab.version !== expectedVersion) {
throw new ConflictException({
code: 'version_mismatch',
message: 'The tab has changed since it was loaded. Refresh and try again.',
});
}
const { customerName, totalInCents, items } = tabSummary({ ...tab, updatedAt: now });
ledger.tabs.remove(tabId);
await ledger.save(tx, now);
await tx.auditLog.create({
data: {
id: uuidv4(),
venueId,
actorType: 'client_user',
actorId: user.id,
action: 'tab_deleted',
details: JSON.stringify({ tabId, customerName, amountInCents: totalInCents, items }),
},
});
});
this.realtime.notify(venueId, 'tabs');
return { ok: true };
private checkVersion(tab: TabState | undefined, expectedVersion: number): TabState {
if (!tab) throw new NotFoundException({ code: 'tab_not_found', message: 'This tab is no longer open' });
if (tab.version !== expectedVersion) {
throw new ConflictException({ code: 'version_mismatch', message: 'The tab has changed since it was loaded. Refresh and try again.' });
}
return tab;
}
}
-118
View File
@@ -1,118 +0,0 @@
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;
}
}
}