@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,5 @@ import { TabsService } from './tabs.service';
|
||||
@Module({
|
||||
controllers: [TabsController],
|
||||
providers: [TabsService],
|
||||
exports: [TabsService],
|
||||
})
|
||||
export class TabsModule {}
|
||||
export class TabsModule {}
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user