@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Venue" ADD COLUMN "cashPaymentEmails" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Every venue gets a "Free Drinks" customer; the next sync opens its tab (or keeps one that is already open)
|
||||
INSERT INTO "PlannedTab" ("id", "venueId", "customerName", "customerNameKey", "position", "createdAt")
|
||||
SELECT gen_random_uuid()::text, "id", 'Free Drinks', 'free drinks', 0, CURRENT_TIMESTAMP
|
||||
FROM "Venue"
|
||||
ON CONFLICT ("venueId", "customerNameKey") DO NOTHING;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "TabEvent" ADD COLUMN "orderId" TEXT;
|
||||
@@ -28,6 +28,8 @@ model Venue {
|
||||
// Set on the first sync after the server started holding the tabs: the tabs were then taken over from the
|
||||
// terminal's own open tabs, once
|
||||
tabsSeededAt DateTime?
|
||||
// Get an email for every tab paid in cash, on a terminal or on the dashboard
|
||||
cashPaymentEmails String[] @default([])
|
||||
}
|
||||
|
||||
model Device {
|
||||
@@ -179,6 +181,8 @@ model TabEvent {
|
||||
// tab_opened | tab_renamed | tab_deleted | line_added | line_removed | settled | stock_set
|
||||
type String
|
||||
lineId String?
|
||||
// line_added / line_removed: the order on the terminal the drinks belong to
|
||||
orderId String?
|
||||
productId String?
|
||||
productName String?
|
||||
unitPriceInCents Int?
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as bcrypt from 'bcrypt';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import * as crypto from 'crypto';
|
||||
import { ensureDefaultCustomers } from '../src/planned-tabs/default-customers';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@@ -31,6 +32,7 @@ async function main() {
|
||||
await prisma.venue.create({
|
||||
data: { id: venueId, name: 'KoolTap Demo Bar' },
|
||||
});
|
||||
await ensureDefaultCustomers(prisma, venueId);
|
||||
console.log(`Created venue: KoolTap Demo Bar (${venueId})`);
|
||||
|
||||
// Create admin user
|
||||
@@ -78,6 +80,7 @@ async function main() {
|
||||
// A second venue, so the admin page has more than one
|
||||
const secondVenueId = uuidv4();
|
||||
await prisma.venue.create({ data: { id: secondVenueId, name: 'KoolTap Festival Bar' } });
|
||||
await ensureDefaultCustomers(prisma, secondVenueId);
|
||||
await prisma.clientUser.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
|
||||
@@ -11,6 +11,7 @@ import { CommandExpiryJob } from '../jobs/command-expiry.job';
|
||||
import { createdFieldTimes } from '../stock/product-fields';
|
||||
import { checkConsistency } from './consistency';
|
||||
import { countReset, deleteVenue, lockVenue, resetVenue, ResetCounts, ResetScope } from './venue-reset';
|
||||
import { ensureDefaultCustomers } from '../planned-tabs/default-customers';
|
||||
import { PurgeTarget } from './dto/admin.dto';
|
||||
|
||||
/** The superadmin doing it. */
|
||||
@@ -18,7 +19,8 @@ export type Actor = { id: string; email: string; name: string; venueId: string }
|
||||
|
||||
// What each reset scope changes on the dashboard
|
||||
const SCOPE_TOPICS: Record<ResetScope, RealtimeTopic[]> = {
|
||||
tabs: ['tabs'],
|
||||
// Plans the Free Drinks customer again
|
||||
tabs: ['tabs', 'planned-tabs'],
|
||||
settlements: ['tabs'],
|
||||
tabEvents: [],
|
||||
plannedTabs: ['planned-tabs', 'reminders'],
|
||||
@@ -92,7 +94,11 @@ export class AdminService {
|
||||
}
|
||||
|
||||
async createVenue(name: string, actor: Actor) {
|
||||
const venue = await this.prisma.venue.create({ data: { name: name.trim() } });
|
||||
const venue = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.venue.create({ data: { name: name.trim() } });
|
||||
await ensureDefaultCustomers(tx, created.id);
|
||||
return created;
|
||||
});
|
||||
await this.audit.log(venue.id, 'client_user', actor.id, 'admin_venue_created', { name: venue.name, by: actor.email });
|
||||
return venue;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { ensureDefaultCustomers } from '../planned-tabs/default-customers';
|
||||
|
||||
type Tx = Prisma.TransactionClient;
|
||||
|
||||
@@ -73,6 +74,8 @@ export async function resetVenue(
|
||||
await tx.tabReminder.deleteMany({ where });
|
||||
counts.plannedTabs = (await tx.plannedTab.deleteMany({ where })).count;
|
||||
}
|
||||
// The Free Drinks tab is always there: planned again, and opened on the next sync
|
||||
if (has('tabs') || has('plannedTabs')) await ensureDefaultCustomers(tx, venueId);
|
||||
if (has('stockHistory')) counts.stockHistory = (await tx.stockAdjustment.deleteMany({ where })).count;
|
||||
if (has('stock')) {
|
||||
const { now, actor, stockValue } = options;
|
||||
|
||||
@@ -16,8 +16,9 @@ function createService({ lines = [] as unknown[] } = {}) {
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
const realtime = { notify: jest.fn() };
|
||||
const service = new CommandsService(prisma as any, { log: jest.fn() } as any, realtime as any);
|
||||
return { service, prisma, realtime };
|
||||
const cashPayments = { notify: jest.fn() };
|
||||
const service = new CommandsService(prisma as any, { log: jest.fn() } as any, realtime as any, cashPayments as any);
|
||||
return { service, prisma, realtime, cashPayments };
|
||||
}
|
||||
|
||||
const colaLine = {
|
||||
@@ -27,7 +28,7 @@ const colaLine = {
|
||||
|
||||
describe('CommandsService.create', () => {
|
||||
it('settles everything on the tab at once and records it as completed', async () => {
|
||||
const { service, prisma, realtime } = createService({ lines: [colaLine] });
|
||||
const { service, prisma, realtime, cashPayments } = createService({ lines: [colaLine] });
|
||||
|
||||
const result = await service.create('v1', dto, user);
|
||||
|
||||
@@ -43,6 +44,7 @@ describe('CommandsService.create', () => {
|
||||
id: result.id, deviceId: null, status: 'completed', customerName: 'Jan', amountInCents: 500,
|
||||
});
|
||||
expect(realtime.notify).toHaveBeenCalledWith('v1', 'commands', 'tabs');
|
||||
expect(cashPayments.notify).toHaveBeenCalledWith('v1', [expect.objectContaining({ paymentMethod: 'cash', amountInCents: 500 })], 'Staff');
|
||||
});
|
||||
|
||||
it('rejects a tab that is not open', async () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { AuditService } from '../audit/audit.service';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { CreateCommandDto } from './dto/create-command.dto';
|
||||
import { VenueLedger } from '../tabs/venue-ledger';
|
||||
import { CashPaymentNotifier } from '../email/cash-payment-notifier';
|
||||
|
||||
@Injectable()
|
||||
export class CommandsService {
|
||||
@@ -17,6 +18,7 @@ export class CommandsService {
|
||||
private prisma: PrismaService,
|
||||
private auditService: AuditService,
|
||||
private realtime: RealtimeService,
|
||||
private cashPayments: CashPaymentNotifier,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -46,7 +48,7 @@ export class CommandsService {
|
||||
}
|
||||
|
||||
const requestedBy = user.name || user.email;
|
||||
const result = await this.prisma.$transaction(async (tx) => {
|
||||
const { result, settlements } = await this.prisma.$transaction(async (tx) => {
|
||||
const now = new Date();
|
||||
const ledger = await VenueLedger.open(tx, venueId);
|
||||
|
||||
@@ -103,10 +105,14 @@ export class CommandsService {
|
||||
},
|
||||
});
|
||||
|
||||
return { id: command.id, status: command.status, amountInCents: settlement.amountInCents };
|
||||
return {
|
||||
result: { id: command.id, status: command.status, amountInCents: settlement.amountInCents },
|
||||
settlements: ledger.settlements,
|
||||
};
|
||||
});
|
||||
|
||||
this.realtime.notify(venueId, 'commands', 'tabs');
|
||||
this.cashPayments.notify(venueId, settlements, requestedBy);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { SyncDto } from './dto/sync.dto';
|
||||
import { createFakePrisma, FakePrisma } from '../testing/fake-prisma';
|
||||
import { StockService } from '../stock/stock.service';
|
||||
|
||||
type Device = { id: string; venueId: string; revokedAt: null; lastSeenAt: Date };
|
||||
type Device = { id: string; venueId: string; name?: string; revokedAt: null; lastSeenAt: Date };
|
||||
type SyncResult = Awaited<ReturnType<DevicesService['sync']>>;
|
||||
|
||||
// Yesterday at 20:00 UTC. In the past, as a time ahead of the server clock would be capped, and recent, as a new
|
||||
@@ -17,16 +17,17 @@ const at = (minutes: number) => new Date(Date.UTC(
|
||||
function setup({ seeded = true } = {}) {
|
||||
const prisma = createFakePrisma();
|
||||
prisma.venue.rows.push({ id: 'v1', tabsSeededAt: seeded ? new Date('2026-10-01') : null });
|
||||
const tabletA: Device = { id: 'd1', venueId: 'v1', revokedAt: null, lastSeenAt: new Date() };
|
||||
const tabletA: Device = { id: 'd1', venueId: 'v1', name: 'Bar tablet', revokedAt: null, lastSeenAt: new Date() };
|
||||
const tabletB: Device = { id: 'd2', venueId: 'v1', revokedAt: null, lastSeenAt: new Date() };
|
||||
prisma.device.rows.push({ ...tabletA }, { ...tabletB });
|
||||
const realtime = { notify: jest.fn() };
|
||||
const service = new DevicesService(prisma as any, { log: jest.fn() } as any, realtime as any);
|
||||
const cashPayments = { notify: jest.fn() };
|
||||
const service = new DevicesService(prisma as any, { log: jest.fn() } as any, realtime as any, cashPayments as any);
|
||||
|
||||
const sync = (device: Device, body: Partial<SyncDto> = {}) =>
|
||||
service.sync(device, { deviceId: device.id, openTabs: [], ...body } as SyncDto);
|
||||
|
||||
return { prisma, service, realtime, sync, tabletA, tabletB };
|
||||
return { prisma, service, realtime, cashPayments, sync, tabletA, tabletB };
|
||||
}
|
||||
|
||||
/** Builds the events a terminal sends, with fresh ids. */
|
||||
@@ -244,6 +245,19 @@ describe('DevicesService.sync: settlements feed', () => {
|
||||
expect(second.settlementsCursor).toBe(first.settlementsCursor);
|
||||
});
|
||||
|
||||
it('hands a settlement to the cash payment emails once, with the tablet that settled it', async () => {
|
||||
const { sync, tabletA, cashPayments } = setup();
|
||||
const settled = { ...events.settled('t1', 1, 2), paymentMethod: 'cash' };
|
||||
const batch = [events.opened('t1', 'Alice'), events.line('line_added', 't1', 1), settled];
|
||||
|
||||
await sync(tabletA, { events: batch });
|
||||
expect(cashPayments.notify).toHaveBeenCalledWith('v1', [expect.objectContaining({ id: settled.id, paymentMethod: 'cash' })], 'Bar tablet');
|
||||
|
||||
// Resent because the acknowledgement was lost: already applied, so nothing new to email
|
||||
await sync(tabletA, { events: batch });
|
||||
expect(cashPayments.notify).toHaveBeenLastCalledWith('v1', [], 'Bar tablet');
|
||||
});
|
||||
|
||||
it('starts an empty feed after the latest settlement', async () => {
|
||||
const { sync, tabletA } = setup();
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import { openPlannedTabs, settlementsAfter } from '../tabs/tab-sync';
|
||||
import { applyPendingAdjustments } from '../stock/stock-adjustments';
|
||||
import { applyProductEvent } from '../stock/product-events';
|
||||
import { isProductEvent } from './dto/tab-event.dto';
|
||||
import { CashPaymentNotifier } from '../email/cash-payment-notifier';
|
||||
|
||||
@Injectable()
|
||||
export class DevicesService {
|
||||
@@ -30,6 +31,7 @@ export class DevicesService {
|
||||
private prisma: PrismaService,
|
||||
private auditService: AuditService,
|
||||
private realtime: RealtimeService,
|
||||
private cashPayments: CashPaymentNotifier,
|
||||
) {}
|
||||
|
||||
async sync(device: any, dto: SyncDto) {
|
||||
@@ -177,6 +179,7 @@ export class DevicesService {
|
||||
if (topics.length > 0) this.realtime.notify(venueId, ...topics);
|
||||
|
||||
const { ledger, feed, catalog } = synced;
|
||||
this.cashPayments.notify(venueId, ledger.settlements, device.name);
|
||||
return {
|
||||
cursor,
|
||||
commands: commands.map((c) => ({
|
||||
|
||||
@@ -118,6 +118,17 @@ export class TabEventDto {
|
||||
@MaxLength(200)
|
||||
productName?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 'order-uuid',
|
||||
nullable: true,
|
||||
description: 'line_added: the order the drinks are part of. line_removed: the order they are taken off, null for '
|
||||
+ 'drinks without one. Missing from older app versions: line_removed then takes the latest drinks of the line',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
orderId?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ example: 250, description: 'line_added / line_removed' })
|
||||
@ValidateIf(isLine)
|
||||
@IsInt()
|
||||
|
||||
@@ -92,6 +92,7 @@ export function tabEventRow(event: TabEventDto, venueId: string, deviceId: strin
|
||||
productName: event.productName,
|
||||
unitPriceInCents: event.unitPriceInCents,
|
||||
quantityDelta: event.type === 'line_removed' ? -event.quantity! : event.quantity,
|
||||
orderId: event.orderId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { CashPaymentNotifier } from './cash-payment-notifier';
|
||||
import { createFakePrisma } from '../testing/fake-prisma';
|
||||
|
||||
const settlement = (paymentMethod: string, id = 's1') => ({
|
||||
id, tabId: 't1', customerName: 'Jan', settledAt: new Date('2026-10-27T20:15:00Z'), paymentMethod, amountInCents: 500,
|
||||
commandId: null,
|
||||
lines: [{ id: 'l1', productId: 'cola', productName: 'Cola', unitPriceInCents: 250, quantity: 2, units: [] }],
|
||||
});
|
||||
|
||||
function setup(emails: string[]) {
|
||||
process.env.RESEND_API_KEY = 'test-key';
|
||||
const prisma = createFakePrisma();
|
||||
prisma.venue.rows.push({ id: 'v1', name: 'Café De Kroon', cashPaymentEmails: emails });
|
||||
const render = jest.fn().mockResolvedValue({ subject: 'Cash', html: '<p>Cash</p>' });
|
||||
const notifier = new CashPaymentNotifier(prisma as any, { render } as any);
|
||||
const fetchMock = jest.fn().mockResolvedValue({ ok: true, text: async () => '' });
|
||||
global.fetch = fetchMock as any;
|
||||
return { notifier, render, fetchMock };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.RESEND_API_KEY;
|
||||
});
|
||||
|
||||
describe('CashPaymentNotifier', () => {
|
||||
it('emails every address about a cash payment, once per settlement', async () => {
|
||||
const { notifier, render, fetchMock } = setup(['a@bar.be', 'b@bar.be']);
|
||||
|
||||
await notifier.send('v1', [settlement('cash')], 'Bar tablet');
|
||||
|
||||
expect(render).toHaveBeenCalledWith('cash-payment', expect.objectContaining({
|
||||
customerName: 'Jan', venueName: 'Café De Kroon', settledBy: 'Bar tablet', hasItems: true,
|
||||
items: [expect.objectContaining({ quantity: 2, productName: 'Cola' })],
|
||||
}));
|
||||
const [url, request] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('https://api.resend.com/emails/batch');
|
||||
expect(request.headers['Idempotency-Key']).toBe('cash-payment-s1');
|
||||
expect(JSON.parse(request.body).map((m: { to: string }) => m.to)).toEqual(['a@bar.be', 'b@bar.be']);
|
||||
});
|
||||
|
||||
it('sends nothing for other payment methods or without addresses', async () => {
|
||||
const withAddress = setup(['a@bar.be']);
|
||||
withAddress.notifier.notify('v1', [settlement('payconiq')], 'Bar tablet');
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(withAddress.fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
const withoutAddress = setup([]);
|
||||
await withoutAddress.notifier.send('v1', [settlement('cash')], 'Bar tablet');
|
||||
expect(withoutAddress.fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { EmailTemplatesService } from './email-templates.service';
|
||||
import { RecordedSettlement } from '../tabs/venue-ledger';
|
||||
|
||||
// Belgian notation, as on the dashboard: € 25,00
|
||||
const euro = new Intl.NumberFormat('nl-BE', { style: 'currency', currency: 'EUR' });
|
||||
const formatEuro = (cents: number) => euro.format(cents / 100);
|
||||
|
||||
const dateTime = new Intl.DateTimeFormat('nl-BE', {
|
||||
timeZone: 'Europe/Brussels', day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
|
||||
/**
|
||||
* Emails the venue's cash payment addresses (set on the dashboard) about every tab paid in cash, on a terminal or on
|
||||
* the dashboard. Runs after the settlement is stored and never fails it: a mail that can't be sent is only logged.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CashPaymentNotifier {
|
||||
private readonly logger = new Logger(CashPaymentNotifier.name);
|
||||
private readonly resendApiKey = process.env.RESEND_API_KEY || '';
|
||||
private readonly fromEmail = process.env.RESEND_FROM_EMAIL || 'KoolTap <onboarding@resend.dev>';
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private emailTemplates: EmailTemplatesService,
|
||||
) {}
|
||||
|
||||
/** Sends in the background; [settlements] may hold other payment methods, which are skipped. */
|
||||
notify(venueId: string, settlements: RecordedSettlement[], settledBy: string) {
|
||||
const cash = settlements.filter((s) => s.paymentMethod === 'cash');
|
||||
if (cash.length === 0) return;
|
||||
this.send(venueId, cash, settledBy).catch((error) => {
|
||||
this.logger.error(`Cash payment email for venue ${venueId} failed: ${error?.message ?? error}`);
|
||||
});
|
||||
}
|
||||
|
||||
async send(venueId: string, settlements: RecordedSettlement[], settledBy: string) {
|
||||
const venue = await this.prisma.venue.findUnique({
|
||||
where: { id: venueId },
|
||||
select: { name: true, cashPaymentEmails: true },
|
||||
});
|
||||
const recipients = venue?.cashPaymentEmails ?? [];
|
||||
if (recipients.length === 0) return;
|
||||
if (!this.resendApiKey) {
|
||||
this.logger.warn(`Not emailing ${settlements.length} cash payment(s) of venue ${venueId}: RESEND_API_KEY is not set`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const settlement of settlements) {
|
||||
const items = settlement.lines.filter((line) => line.quantity > 0);
|
||||
const email = await this.emailTemplates.render('cash-payment', {
|
||||
venueName: venue!.name,
|
||||
customerName: settlement.customerName,
|
||||
amount: formatEuro(settlement.amountInCents),
|
||||
settledAt: dateTime.format(settlement.settledAt),
|
||||
settledBy,
|
||||
hasItems: items.length > 0,
|
||||
items: items.map((line) => ({
|
||||
quantity: line.quantity,
|
||||
productName: line.productName,
|
||||
lineTotal: formatEuro(line.quantity * line.unitPriceInCents),
|
||||
})),
|
||||
});
|
||||
|
||||
const res = await fetch('https://api.resend.com/emails/batch', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.resendApiKey}`,
|
||||
// One settlement is emailed once, even when this request is retried
|
||||
'Idempotency-Key': `cash-payment-${settlement.id}`,
|
||||
},
|
||||
body: JSON.stringify(recipients.map((to) => ({ from: this.fromEmail, to, ...email }))),
|
||||
});
|
||||
if (!res.ok) {
|
||||
this.logger.error(`Cash payment email for settlement ${settlement.id} failed: ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ const TOKEN = String.raw`\{\{\s*([#/]?)\s*([A-Za-z0-9_]+)\s*\}\}`;
|
||||
const TITLE = /<title[^>]*>([\s\S]*?)<\/title>/i;
|
||||
|
||||
/** Templates the API sends; each needs `<name>.html` (and optionally `<name>.txt`). */
|
||||
export const EMAIL_TEMPLATES = ['payment-reminder', 'test-email'] as const;
|
||||
export const EMAIL_TEMPLATES = ['payment-reminder', 'test-email', 'cash-payment'] as const;
|
||||
export type EmailTemplateName = (typeof EMAIL_TEMPLATES)[number];
|
||||
|
||||
export class EmailTemplateError extends Error {}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { EmailTemplatesService } from './email-templates.service';
|
||||
import { CashPaymentNotifier } from './cash-payment-notifier';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [EmailTemplatesService],
|
||||
exports: [EmailTemplatesService],
|
||||
providers: [EmailTemplatesService, CashPaymentNotifier],
|
||||
exports: [EmailTemplatesService, CashPaymentNotifier],
|
||||
})
|
||||
export class EmailModule {}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { DevicesService } from '../devices/devices.service';
|
||||
import { SyncDto } from '../devices/dto/sync.dto';
|
||||
import { resetVenue } from '../admin/venue-reset';
|
||||
import { createFakePrisma } from '../testing/fake-prisma';
|
||||
import { ensureDefaultCustomers, FREE_DRINKS_CUSTOMER } from './default-customers';
|
||||
|
||||
function setup() {
|
||||
const prisma = createFakePrisma();
|
||||
prisma.venue.rows.push({ id: 'v1', tabsSeededAt: new Date('2026-10-01') });
|
||||
const tablet = { id: 'd1', venueId: 'v1', name: 'Bar tablet', revokedAt: null, lastSeenAt: new Date() };
|
||||
prisma.device.rows.push({ ...tablet });
|
||||
const service = new DevicesService(prisma as any, { log: jest.fn() } as any, { notify: jest.fn() } as any, { notify: jest.fn() } as any);
|
||||
const sync = () => service.sync(tablet, { deviceId: tablet.id, openTabs: [] } as SyncDto);
|
||||
const reset = (scopes: ('tabs' | 'plannedTabs')[]) =>
|
||||
resetVenue(prisma as any, 'v1', scopes, { stockValue: 0, actor: { id: 'u1', name: 'Admin' }, now: new Date() });
|
||||
return { prisma, sync, reset };
|
||||
}
|
||||
|
||||
const names = (result: { tabs: { customerName: string }[] }) => result.tabs.map((tab) => tab.customerName);
|
||||
|
||||
describe('ensureDefaultCustomers', () => {
|
||||
it('gives a venue a Free Drinks tab, without email, on the next sync', async () => {
|
||||
const { prisma, sync } = setup();
|
||||
|
||||
await ensureDefaultCustomers(prisma as any, 'v1');
|
||||
|
||||
expect(prisma.plannedTab.rows).toEqual([expect.objectContaining({ customerName: FREE_DRINKS_CUSTOMER, email: null })]);
|
||||
expect(names(await sync())).toEqual([FREE_DRINKS_CUSTOMER]);
|
||||
// Opened once: a second sync keeps the one tab
|
||||
expect(names(await sync())).toEqual([FREE_DRINKS_CUSTOMER]);
|
||||
});
|
||||
|
||||
it('brings the tab back after a reset of the tabs or the planned customers', async () => {
|
||||
const { prisma, sync, reset } = setup();
|
||||
await ensureDefaultCustomers(prisma as any, 'v1');
|
||||
await sync();
|
||||
|
||||
await reset(['tabs']);
|
||||
expect(names(await sync())).toEqual([FREE_DRINKS_CUSTOMER]);
|
||||
|
||||
await reset(['plannedTabs']);
|
||||
expect(prisma.plannedTab.rows).toHaveLength(1);
|
||||
// Its tab was still open: kept, not opened a second time
|
||||
expect(names(await sync())).toEqual([FREE_DRINKS_CUSTOMER]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { customerNameKey } from './customer-name';
|
||||
|
||||
type Tx = Prisma.TransactionClient;
|
||||
|
||||
/** The tab free drinks are put on, so they still count in the stock and history. A normal customer without email. */
|
||||
export const FREE_DRINKS_CUSTOMER = 'Free Drinks';
|
||||
|
||||
/**
|
||||
* Plans the customers every venue starts with; the next sync opens their tabs. A customer that was removed, or whose
|
||||
* tab was wiped, is planned again: run when a venue is created or reset.
|
||||
*/
|
||||
export async function ensureDefaultCustomers(tx: Tx, venueId: string) {
|
||||
const key = customerNameKey(FREE_DRINKS_CUSTOMER);
|
||||
await tx.plannedTab.upsert({
|
||||
where: { venueId_customerNameKey: { venueId, customerNameKey: key } },
|
||||
create: { venueId, customerName: FREE_DRINKS_CUSTOMER, customerNameKey: key },
|
||||
// Opened again when its tab is gone; an open tab with this name is kept
|
||||
update: { deliveredAt: null },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
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,5 +1,6 @@
|
||||
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
|
||||
@@ -7,8 +8,11 @@ import { OpenTabDto } from '../devices/dto/sync.dto';
|
||||
* derived the same way on both sides (`<event id>:<index>`).
|
||||
*/
|
||||
|
||||
/** One piece ever put on a line. */
|
||||
export type Unit = { id: string; addedAt: string; removedAt: string | null };
|
||||
/**
|
||||
* 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;
|
||||
@@ -46,6 +50,16 @@ 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. */
|
||||
@@ -110,8 +124,9 @@ export class VenueTabs {
|
||||
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 });
|
||||
line.units.push({ id: `${event.id}:${index}`, addedAt, removedAt: null, orderId });
|
||||
}
|
||||
this.touch(tabId);
|
||||
return null;
|
||||
@@ -121,8 +136,8 @@ export class VenueTabs {
|
||||
const tab = this.tabs.get(tabId);
|
||||
const line = tab && findLine(tab, event);
|
||||
if (!line) return null;
|
||||
// The latest ones: the drinks that were just rung up by mistake
|
||||
const active = line.units.filter(isActive);
|
||||
// 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();
|
||||
}
|
||||
@@ -178,7 +193,7 @@ export class VenueTabs {
|
||||
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, lineQuantity(line)) : 0;
|
||||
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. */
|
||||
@@ -311,6 +326,10 @@ export function tabSummary(tab: { id: string; customerName: string; version: num
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PrismaService } from '../prisma/prisma.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';
|
||||
|
||||
@Injectable()
|
||||
@@ -72,7 +73,8 @@ export class TabsService {
|
||||
|
||||
return {
|
||||
settlements: rows.map((row) => {
|
||||
const items = (row.lines as unknown as SettledLine[]).map((line) => ({
|
||||
const lines = row.lines as unknown as SettledLine[];
|
||||
const items = lines.map((line) => ({
|
||||
productName: line.productName,
|
||||
quantity: line.quantity,
|
||||
unitPriceInCents: line.unitPriceInCents,
|
||||
@@ -88,6 +90,7 @@ export class TabsService {
|
||||
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,
|
||||
onDashboard: row.deviceId === null,
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ type Row = Record<string, any>;
|
||||
const COMPOUND_KEYS: Record<string, string[]> = {
|
||||
venueId_id: ['venueId', 'id'],
|
||||
venueId_productId: ['venueId', 'productId'],
|
||||
venueId_customerNameKey: ['venueId', 'customerNameKey'],
|
||||
};
|
||||
|
||||
function matches(row: Row, where: Row = {}): boolean {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ArrayMaxSize, IsArray, IsEmail } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class UpdateCashPaymentEmailsDto {
|
||||
@ApiProperty({ example: ['owner@example.com'], description: 'Who gets an email for every tab paid in cash' })
|
||||
@IsArray()
|
||||
@ArrayMaxSize(20)
|
||||
@IsEmail({}, { each: true })
|
||||
emails: string[];
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Controller, Get, Param, UseGuards, Post, Body, Delete, Req } from '@nestjs/common';
|
||||
import { Controller, Get, Param, UseGuards, Post, Put, Body, Delete, Req } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { VenuesService } from './venues.service';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
@@ -8,6 +8,7 @@ import { VenueAccessGuard } from '../common/guards/venue-access.guard';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
import { UpdateCashPaymentEmailsDto } from './dto/update-cash-payment-emails.dto';
|
||||
|
||||
@ApiTags('Venues')
|
||||
@Controller('venues')
|
||||
@@ -32,6 +33,15 @@ export class VenuesController {
|
||||
return this.venuesService.findOne(venueId);
|
||||
}
|
||||
|
||||
@Put(':venueId/cash-payment-emails')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, VenueAccessGuard, RolesGuard)
|
||||
@Roles('admin', 'manager')
|
||||
@ApiOperation({ summary: 'Set who gets an email for every tab paid in cash' })
|
||||
async updateCashPaymentEmails(@Param('venueId') venueId: string, @Body() dto: UpdateCashPaymentEmailsDto) {
|
||||
return this.venuesService.updateCashPaymentEmails(venueId, dto.emails);
|
||||
}
|
||||
|
||||
@Get(':venueId/users')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, VenueAccessGuard, RolesGuard)
|
||||
|
||||
@@ -16,4 +16,15 @@ export class VenuesService {
|
||||
}
|
||||
return venue;
|
||||
}
|
||||
}
|
||||
|
||||
/** Who gets an email for every tab paid in cash. Each address once, whatever its case. */
|
||||
async updateCashPaymentEmails(venueId: string, emails: string[]) {
|
||||
const unique = [...new Set(emails.map((email) => email.trim().toLowerCase()).filter(Boolean))];
|
||||
const venue = await this.prisma.venue.update({
|
||||
where: { id: venueId },
|
||||
data: { cashPaymentEmails: unique },
|
||||
select: { cashPaymentEmails: true },
|
||||
});
|
||||
return { emails: venue.cashPaymentEmails };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,3 +60,21 @@ Sent by the test button next to a customer's email address.
|
||||
|---|---|
|
||||
| `{{customerName}}` | Jan Peeters |
|
||||
| `{{venueName}}` | Café De Kroon |
|
||||
|
||||
### `cash-payment`
|
||||
|
||||
Sent to the venue's cash payment addresses (dashboard settings) whenever a tab is paid in cash, on a terminal or on
|
||||
the dashboard.
|
||||
|
||||
| Placeholder | Example |
|
||||
|---|---|
|
||||
| `{{customerName}}` | Jan Peeters |
|
||||
| `{{venueName}}` | Café De Kroon |
|
||||
| `{{amount}}` | € 29,00 |
|
||||
| `{{settledAt}}` | 27 oktober 2026 om 22:15 |
|
||||
| `{{settledBy}}` | Bar tablet, or the dashboard user's name |
|
||||
| `{{#hasItems}}…{{/hasItems}}` | Shown when anything was paid for |
|
||||
| `{{#items}}…{{/items}}` | Repeated for each paid line, with: |
|
||||
| `{{quantity}}` | 10 |
|
||||
| `{{productName}}` | Cola |
|
||||
| `{{lineTotal}}` | € 25,00 |
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{customerName}} betaalde {{amount}} cash bij {{venueName}}</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>De poef van <strong>{{customerName}}</strong> is cash betaald.</p>
|
||||
<table cellpadding="4" cellspacing="0" style="border-collapse: collapse;">
|
||||
<tr><td>Bedrag</td><td style="padding-left: 24px;"><strong>{{amount}}</strong></td></tr>
|
||||
<tr><td>Betaald op</td><td style="padding-left: 24px;">{{settledAt}}</td></tr>
|
||||
<tr><td>Afgerekend door</td><td style="padding-left: 24px;">{{settledBy}}</td></tr>
|
||||
</table>
|
||||
{{#hasItems}}
|
||||
<p>Overzicht:</p>
|
||||
<table cellpadding="4" cellspacing="0" style="border-collapse: collapse;">
|
||||
{{#items}}
|
||||
<tr>
|
||||
<td>{{quantity}}x {{productName}}</td>
|
||||
<td style="text-align: right; padding-left: 24px;">{{lineTotal}}</td>
|
||||
</tr>
|
||||
{{/items}}
|
||||
<tr>
|
||||
<td style="border-top: 1px solid #ccc;"><strong>Totaal</strong></td>
|
||||
<td style="border-top: 1px solid #ccc; text-align: right; padding-left: 24px;"><strong>{{amount}}</strong></td>
|
||||
</tr>
|
||||
</table>
|
||||
{{/hasItems}}
|
||||
<p>{{venueName}}</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,11 @@
|
||||
De poef van {{customerName}} is cash betaald.
|
||||
|
||||
Bedrag: {{amount}}
|
||||
Betaald op: {{settledAt}}
|
||||
Afgerekend door: {{settledBy}}
|
||||
{{#hasItems}}
|
||||
Overzicht:
|
||||
{{#items}}{{quantity}}x {{productName}} {{lineTotal}}
|
||||
{{/items}}Totaal: {{amount}}
|
||||
{{/hasItems}}
|
||||
{{venueName}}
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Fragment, useEffect, useState } from 'react';
|
||||
import { tabsApi, type PaidTab } from '@/lib/api/client';
|
||||
import { tabsApi } from '@/lib/api/client';
|
||||
import { TabOrders } from '@/components/tab-orders';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
@@ -152,7 +153,7 @@ export default function HistoryPage() {
|
||||
<TableCell colSpan={6} className="py-3 pl-4 sm:pl-10">
|
||||
{/* Pinned to the visible area so lines stay readable while the table scrolls sideways on phones */}
|
||||
<div className="sticky left-4 max-w-[min(28rem,calc(100vw-7rem))]">
|
||||
<PaidLines items={paid.items} />
|
||||
<TabOrders orders={paid.orders} />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -184,25 +185,3 @@ export default function HistoryPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The paid lines, e.g. "10× Cola · €2.50 each · €25.00", with what was taken off before paying. */
|
||||
function PaidLines({ items }: { items: PaidTab['items'] }) {
|
||||
const { t } = useLang();
|
||||
if (items.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">{t('tabs.lines_empty')}</p>;
|
||||
}
|
||||
return (
|
||||
<ul className="space-y-1 text-sm">
|
||||
{items.map((item, i) => (
|
||||
<li key={i} className="flex justify-between gap-4">
|
||||
<span className={item.quantity === 0 ? 'text-muted-foreground line-through' : undefined}>
|
||||
<span className="font-medium">{item.quantity}×</span> {item.productName}
|
||||
<span className="text-muted-foreground"> · {formatCents(item.unitPriceInCents)} {t('tabs.each')}</span>
|
||||
{item.removed > 0 && <span className="text-destructive text-xs"> · {t('history.removed', { count: item.removed })}</span>}
|
||||
</span>
|
||||
<span className="tabular-nums">{formatCents(item.quantity * item.unitPriceInCents)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,13 +6,14 @@ import { devicesApi, venuesApi, type Device } from '@/lib/api/client';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DeviceStatusBadge } from '@/components/device-status-badge';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { toast } from '@/components/ui/toast';
|
||||
import { useLang } from '@/lib/i18n';
|
||||
import { canAdministerVenue, useSession } from '@/lib/session';
|
||||
import { Monitor, Copy, Plus } from 'lucide-react';
|
||||
import { canAdministerVenue, canManageVenue, useSession } from '@/lib/session';
|
||||
import { Monitor, Copy, Plus, Mail, X } from 'lucide-react';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { venueId, role, viewing } = useSession();
|
||||
@@ -81,6 +82,8 @@ export default function SettingsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<CashPaymentEmails emails={venue?.cashPaymentEmails} canEdit={canManageVenue(role)} />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('settings.device')}</CardTitle>
|
||||
@@ -176,4 +179,84 @@ export default function SettingsPage() {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Who gets an email for every tab paid in cash. */
|
||||
function CashPaymentEmails({ emails, canEdit }: { emails: string[] | undefined; canEdit: boolean }) {
|
||||
const { venueId } = useSession();
|
||||
const queryClient = useQueryClient();
|
||||
const { t, te } = useLang();
|
||||
const [draft, setDraft] = useState('');
|
||||
const [invalid, setInvalid] = useState(false);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (next: string[]) => venuesApi.updateCashPaymentEmails(venueId, next),
|
||||
onSuccess: () => toast({ title: t('settings.cash_emails_saved'), variant: 'success' }),
|
||||
onError: (err) => toast({ title: t('common.failed'), description: te(err), variant: 'destructive' }),
|
||||
onSettled: () => queryClient.invalidateQueries({ queryKey: ['venue', venueId] }),
|
||||
});
|
||||
|
||||
const current = emails ?? [];
|
||||
|
||||
function add() {
|
||||
const email = draft.trim().toLowerCase();
|
||||
if (!email) return;
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
setInvalid(true);
|
||||
return;
|
||||
}
|
||||
if (!current.includes(email)) saveMutation.mutate([...current, email]);
|
||||
setDraft('');
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><Mail className="h-4 w-4" />{t('settings.cash_emails')}</CardTitle>
|
||||
<CardDescription>{t('settings.cash_emails_desc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{canEdit && (
|
||||
<div className="mb-3">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Input
|
||||
type="email"
|
||||
placeholder={t('settings.cash_emails_placeholder')}
|
||||
value={draft}
|
||||
onChange={(e) => { setDraft(e.target.value); setInvalid(false); }}
|
||||
onKeyDown={(e) => e.key === 'Enter' && add()}
|
||||
className="max-w-xs"
|
||||
aria-label={t('settings.cash_emails_placeholder')}
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
<Button size="sm" onClick={add} disabled={!draft.trim() || saveMutation.isPending}>
|
||||
<Plus className="h-4 w-4 mr-2" />{t('settings.cash_emails_add')}
|
||||
</Button>
|
||||
</div>
|
||||
{invalid && <p className="text-xs text-destructive mt-1">{t('settings.cash_emails_invalid')}</p>}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{emails && current.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{t('settings.cash_emails_empty')}</p>
|
||||
)}
|
||||
{current.map((email) => (
|
||||
<div key={email} className="flex items-center gap-1 px-3 py-1.5 bg-secondary rounded-full text-sm">
|
||||
{email}
|
||||
{canEdit && (
|
||||
<button
|
||||
onClick={() => saveMutation.mutate(current.filter((e) => e !== email))}
|
||||
disabled={saveMutation.isPending}
|
||||
className="text-muted-foreground hover:text-destructive ml-0.5"
|
||||
aria-label={t('settings.cash_emails_remove', { email })}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Fragment, useState } from 'react';
|
||||
import { commandsApi, plannedTabsApi, tabsApi, type Tab, type TabLine } from '@/lib/api/client';
|
||||
import { commandsApi, plannedTabsApi, tabsApi, type Tab } from '@/lib/api/client';
|
||||
import { TabOrders } from '@/components/tab-orders';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -303,7 +304,7 @@ export default function TabsPage() {
|
||||
<TableCell colSpan={6} className="py-3 pl-4 sm:pl-10">
|
||||
{/* Pinned to the visible area so lines stay readable while the table scrolls sideways on phones */}
|
||||
<div className="sticky left-4 max-w-[min(28rem,calc(100vw-7rem))]">
|
||||
<TabLines items={tab.items} />
|
||||
<TabOrders orders={tab.orders} />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -348,7 +349,7 @@ export default function TabsPage() {
|
||||
<div className="space-y-3 py-4">
|
||||
<div className="flex justify-between text-sm"><span className="text-muted-foreground">{t('tabs.amount')}</span><span className="font-semibold">{formatCents(closeTab.totalInCents)}</span></div>
|
||||
<div className="flex justify-between text-sm"><span className="text-muted-foreground">{t('tabs.items')}</span><span>{closeTab.itemCount}</span></div>
|
||||
<div className="rounded-md border p-3"><TabLines items={closeTab.items} /></div>
|
||||
<div className="rounded-md border p-3"><TabOrders orders={closeTab.orders} /></div>
|
||||
<div className="space-y-3 pt-2">
|
||||
<Label className="text-base font-semibold">{t('tabs.payment')}</Label>
|
||||
<RadioGroup value={paymentMethod} onValueChange={setPaymentMethod}>
|
||||
@@ -369,27 +370,3 @@ export default function TabsPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The lines on a tab, e.g. "10× Cola · €2.50 each · €25.00". */
|
||||
function TabLines({ items }: { items: TabLine[] | null | undefined }) {
|
||||
const { t } = useLang();
|
||||
if (!items) {
|
||||
return <p className="text-sm text-muted-foreground">{t('tabs.lines_unknown')}</p>;
|
||||
}
|
||||
if (items.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">{t('tabs.lines_empty')}</p>;
|
||||
}
|
||||
return (
|
||||
<ul className="space-y-1 text-sm">
|
||||
{items.map((item, i) => (
|
||||
<li key={i} className="flex justify-between gap-4">
|
||||
<span>
|
||||
<span className="font-medium">{item.quantity}×</span> {item.productName}
|
||||
<span className="text-muted-foreground"> · {formatCents(item.unitPriceInCents)} {t('tabs.each')}</span>
|
||||
</span>
|
||||
<span className="tabular-nums">{formatCents(item.quantity * item.unitPriceInCents)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import type { TabOrder } from '@/lib/api/client';
|
||||
import { formatCents } from '@/lib/utils';
|
||||
import { useLang } from '@/lib/i18n';
|
||||
|
||||
/**
|
||||
* A tab's drinks per order, as the terminal shows them: "—— 12:32 today ——" above each, e.g. "2× Duvel · €4.00 each
|
||||
* · €8.00". Drinks taken off again show when the order lists them.
|
||||
*/
|
||||
export function TabOrders({ orders }: { orders: TabOrder[] | null | undefined }) {
|
||||
const { t, orderTime } = useLang();
|
||||
if (!orders) {
|
||||
return <p className="text-sm text-muted-foreground">{t('tabs.lines_unknown')}</p>;
|
||||
}
|
||||
if (orders.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">{t('tabs.lines_empty')}</p>;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{orders.map((order) => (
|
||||
<div key={order.orderId ?? order.startedAt}>
|
||||
<div className="flex items-center gap-2 text-xs font-semibold text-muted-foreground mb-1">
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
<span>{orderTime(order.startedAt)}</span>
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{order.items.map((item, i) => (
|
||||
<li key={i} className="flex justify-between gap-4">
|
||||
<span className={item.quantity === 0 ? 'text-muted-foreground line-through' : undefined}>
|
||||
<span className="font-medium">{item.quantity}×</span> {item.productName}
|
||||
<span className="text-muted-foreground"> · {formatCents(item.unitPriceInCents)} {t('tabs.each')}</span>
|
||||
{(item.removed ?? 0) > 0 && (
|
||||
<span className="text-destructive text-xs"> · {t('history.removed', { count: item.removed! })}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="tabular-nums">{formatCents(item.quantity * item.unitPriceInCents)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,15 @@ export type Role = 'admin' | 'manager' | 'superadmin';
|
||||
|
||||
export type TabLine = { productName: string; quantity: number; unitPriceInCents: number };
|
||||
|
||||
/** Drinks ordered together on a terminal, shown under the time the order started. */
|
||||
export interface TabOrder {
|
||||
// Null for drinks from older app versions, grouped by time instead
|
||||
orderId: string | null;
|
||||
startedAt: string;
|
||||
// `removed`: drinks taken off again, only in the history
|
||||
items: (TabLine & { removed?: number })[];
|
||||
}
|
||||
|
||||
/** An open tab as the server holds it: every terminal of the venue shows the same. */
|
||||
export interface Tab {
|
||||
tabId: string;
|
||||
@@ -21,6 +30,7 @@ export interface Tab {
|
||||
totalInCents: number;
|
||||
itemCount: number;
|
||||
items: TabLine[] | null;
|
||||
orders: TabOrder[];
|
||||
lastChangedAt: string;
|
||||
plannedTabId: string | null;
|
||||
email: string | null;
|
||||
@@ -36,6 +46,7 @@ export interface PaidTab {
|
||||
amountInCents: number;
|
||||
itemCount: number;
|
||||
items: (TabLine & { removed: number })[];
|
||||
orders: TabOrder[];
|
||||
settledBy: string | null;
|
||||
onDashboard: boolean;
|
||||
}
|
||||
@@ -360,7 +371,10 @@ export const commandsApi = {
|
||||
|
||||
// Venues API
|
||||
export const venuesApi = {
|
||||
get: (id: string) => api<{ id: string; name: string }>(`/venues/${id}`),
|
||||
get: (id: string) => api<{ id: string; name: string; cashPaymentEmails: string[] }>(`/venues/${id}`),
|
||||
/** Who gets an email for every tab paid in cash, on a terminal or here */
|
||||
updateCashPaymentEmails: (id: string, emails: string[]) =>
|
||||
api<{ emails: string[] }>(`/venues/${id}/cash-payment-emails`, { method: 'PUT', body: JSON.stringify({ emails }) }),
|
||||
};
|
||||
|
||||
// Planned Tabs API
|
||||
|
||||
@@ -54,6 +54,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'error_page.desc': 'This page could not be shown. Try again, or go back to the start.',
|
||||
'error_page.retry': 'Try again',
|
||||
'time.just_now': 'just now',
|
||||
'orders.today': '{time} today',
|
||||
'orders.yesterday': '{time} yesterday',
|
||||
'time.minutes_ago': '{count}m ago',
|
||||
'time.hours_ago': '{count}h ago',
|
||||
'time.days_ago': '{count}d ago',
|
||||
@@ -212,6 +214,14 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'commands.page': 'Page {page} of {total}',
|
||||
'settings.title': 'Settings',
|
||||
'settings.desc': 'Venue and terminal',
|
||||
'settings.cash_emails': 'Cash payment emails',
|
||||
'settings.cash_emails_desc': 'These addresses get an email every time a tab is paid in cash, on a terminal or here.',
|
||||
'settings.cash_emails_empty': 'Nobody gets an email yet.',
|
||||
'settings.cash_emails_placeholder': 'name@example.com',
|
||||
'settings.cash_emails_add': 'Add',
|
||||
'settings.cash_emails_remove': 'Remove {email}',
|
||||
'settings.cash_emails_invalid': 'Enter a valid email address',
|
||||
'settings.cash_emails_saved': 'Cash payment emails saved',
|
||||
'settings.venue': 'Venue',
|
||||
'settings.device': 'Terminal',
|
||||
'settings.paired': 'Paired as "{name}" — last seen {time}',
|
||||
@@ -636,6 +646,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'error_page.desc': 'Deze pagina kon niet getoond worden. Probeer het opnieuw of ga terug naar het begin.',
|
||||
'error_page.retry': 'Opnieuw proberen',
|
||||
'time.just_now': 'zonet',
|
||||
'orders.today': '{time} vandaag',
|
||||
'orders.yesterday': '{time} gisteren',
|
||||
'time.minutes_ago': '{count} min geleden',
|
||||
'time.hours_ago': '{count} u geleden',
|
||||
'time.days_ago': '{count} d geleden',
|
||||
@@ -794,6 +806,14 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'commands.page': 'Pagina {page} van {total}',
|
||||
'settings.title': 'Instellingen',
|
||||
'settings.desc': 'Zaak en terminal',
|
||||
'settings.cash_emails': 'E-mails bij cashbetaling',
|
||||
'settings.cash_emails_desc': 'Deze adressen krijgen een e-mail telkens een poef cash betaald wordt, op een kassa of hier.',
|
||||
'settings.cash_emails_empty': 'Nog niemand krijgt een e-mail.',
|
||||
'settings.cash_emails_placeholder': 'naam@voorbeeld.be',
|
||||
'settings.cash_emails_add': 'Toevoegen',
|
||||
'settings.cash_emails_remove': '{email} verwijderen',
|
||||
'settings.cash_emails_invalid': 'Geef een geldig e-mailadres in',
|
||||
'settings.cash_emails_saved': 'E-mails bij cashbetaling opgeslagen',
|
||||
'settings.venue': 'Zaak',
|
||||
'settings.device': 'Terminal',
|
||||
'settings.paired': 'Gekoppeld als "{name}" — laatst gezien {time}',
|
||||
@@ -1186,6 +1206,8 @@ interface LangContextType {
|
||||
relativeTime: (date: string | Date) => string;
|
||||
dateTime: (date: string | Date) => string;
|
||||
time: (date: string | Date) => string;
|
||||
/** When an order started: "12:32 today", "12:32 yesterday", or the date and time. */
|
||||
orderTime: (date: string | Date) => string;
|
||||
}
|
||||
|
||||
function translate(lang: Lang, key: string, params?: Params): string {
|
||||
@@ -1226,6 +1248,15 @@ function helpers(lang: Lang): Omit<LangContextType, 'setLang'> {
|
||||
dateTime: (value) =>
|
||||
new Date(value).toLocaleString(locale, { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' }),
|
||||
time: (value) => new Date(value).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }),
|
||||
orderTime: (value) => {
|
||||
const date = new Date(value);
|
||||
const time = date.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' });
|
||||
const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||
const days = Math.round((startOfDay(new Date()) - startOfDay(date)) / (24 * 60 * 60 * 1000));
|
||||
if (days === 0) return t('orders.today', { time });
|
||||
if (days === 1) return t('orders.yesterday', { time });
|
||||
return `${date.toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' })} ${time}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user