diff --git a/apps/api/prisma/migrations/20261027140000_venue_cash_payment_emails/migration.sql b/apps/api/prisma/migrations/20261027140000_venue_cash_payment_emails/migration.sql new file mode 100644 index 0000000..9a0aa43 --- /dev/null +++ b/apps/api/prisma/migrations/20261027140000_venue_cash_payment_emails/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Venue" ADD COLUMN "cashPaymentEmails" TEXT[] DEFAULT ARRAY[]::TEXT[]; diff --git a/apps/api/prisma/migrations/20261028120000_free_drinks_customer/migration.sql b/apps/api/prisma/migrations/20261028120000_free_drinks_customer/migration.sql new file mode 100644 index 0000000..3172473 --- /dev/null +++ b/apps/api/prisma/migrations/20261028120000_free_drinks_customer/migration.sql @@ -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; diff --git a/apps/api/prisma/migrations/20261028130000_tab_event_order_id/migration.sql b/apps/api/prisma/migrations/20261028130000_tab_event_order_id/migration.sql new file mode 100644 index 0000000..a723c08 --- /dev/null +++ b/apps/api/prisma/migrations/20261028130000_tab_event_order_id/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "TabEvent" ADD COLUMN "orderId" TEXT; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 2b877bb..cf1e49b 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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? diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index 4bc6566..945a97b 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -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(), diff --git a/apps/api/src/admin/admin.service.ts b/apps/api/src/admin/admin.service.ts index 15e78fc..4724aef 100644 --- a/apps/api/src/admin/admin.service.ts +++ b/apps/api/src/admin/admin.service.ts @@ -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 = { - 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; } diff --git a/apps/api/src/admin/venue-reset.ts b/apps/api/src/admin/venue-reset.ts index faa4c4a..b6039ed 100644 --- a/apps/api/src/admin/venue-reset.ts +++ b/apps/api/src/admin/venue-reset.ts @@ -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; diff --git a/apps/api/src/commands/commands.service.spec.ts b/apps/api/src/commands/commands.service.spec.ts index 98d4fc8..7cd2ef5 100644 --- a/apps/api/src/commands/commands.service.spec.ts +++ b/apps/api/src/commands/commands.service.spec.ts @@ -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 () => { diff --git a/apps/api/src/commands/commands.service.ts b/apps/api/src/commands/commands.service.ts index 280f805..4759573 100644 --- a/apps/api/src/commands/commands.service.ts +++ b/apps/api/src/commands/commands.service.ts @@ -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; } diff --git a/apps/api/src/devices/devices.service.spec.ts b/apps/api/src/devices/devices.service.spec.ts index af9eb22..afa8306 100644 --- a/apps/api/src/devices/devices.service.spec.ts +++ b/apps/api/src/devices/devices.service.spec.ts @@ -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>; // 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 = {}) => 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(); diff --git a/apps/api/src/devices/devices.service.ts b/apps/api/src/devices/devices.service.ts index e4e2833..f8fc2b8 100644 --- a/apps/api/src/devices/devices.service.ts +++ b/apps/api/src/devices/devices.service.ts @@ -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) => ({ diff --git a/apps/api/src/devices/dto/tab-event.dto.ts b/apps/api/src/devices/dto/tab-event.dto.ts index 91d34d9..46dce6c 100644 --- a/apps/api/src/devices/dto/tab-event.dto.ts +++ b/apps/api/src/devices/dto/tab-event.dto.ts @@ -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() diff --git a/apps/api/src/devices/tab-events.ts b/apps/api/src/devices/tab-events.ts index 682d38e..6bb5bb7 100644 --- a/apps/api/src/devices/tab-events.ts +++ b/apps/api/src/devices/tab-events.ts @@ -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, }; } diff --git a/apps/api/src/email/cash-payment-notifier.spec.ts b/apps/api/src/email/cash-payment-notifier.spec.ts new file mode 100644 index 0000000..74a562b --- /dev/null +++ b/apps/api/src/email/cash-payment-notifier.spec.ts @@ -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: '

Cash

' }); + 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(); + }); +}); diff --git a/apps/api/src/email/cash-payment-notifier.ts b/apps/api/src/email/cash-payment-notifier.ts new file mode 100644 index 0000000..e8f08d2 --- /dev/null +++ b/apps/api/src/email/cash-payment-notifier.ts @@ -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 '; + + 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()}`); + } + } + } +} diff --git a/apps/api/src/email/email-templates.service.ts b/apps/api/src/email/email-templates.service.ts index fe5b62f..45c581f 100644 --- a/apps/api/src/email/email-templates.service.ts +++ b/apps/api/src/email/email-templates.service.ts @@ -17,7 +17,7 @@ const TOKEN = String.raw`\{\{\s*([#/]?)\s*([A-Za-z0-9_]+)\s*\}\}`; const TITLE = /]*>([\s\S]*?)<\/title>/i; /** Templates the API sends; each needs `.html` (and optionally `.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 {} diff --git a/apps/api/src/email/email.module.ts b/apps/api/src/email/email.module.ts index 4a8794b..48d1460 100644 --- a/apps/api/src/email/email.module.ts +++ b/apps/api/src/email/email.module.ts @@ -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 {} diff --git a/apps/api/src/planned-tabs/default-customers.spec.ts b/apps/api/src/planned-tabs/default-customers.spec.ts new file mode 100644 index 0000000..b60fe2c --- /dev/null +++ b/apps/api/src/planned-tabs/default-customers.spec.ts @@ -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]); + }); +}); diff --git a/apps/api/src/planned-tabs/default-customers.ts b/apps/api/src/planned-tabs/default-customers.ts new file mode 100644 index 0000000..36adce8 --- /dev/null +++ b/apps/api/src/planned-tabs/default-customers.ts @@ -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 }, + }); +} diff --git a/apps/api/src/tabs/tab-orders.spec.ts b/apps/api/src/tabs/tab-orders.spec.ts new file mode 100644 index 0000000..5de098f --- /dev/null +++ b/apps/api/src/tabs/tab-orders.spec.ts @@ -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 & { 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 & { 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); + }); +}); diff --git a/apps/api/src/tabs/tab-orders.ts b/apps/api/src/tabs/tab-orders.ts new file mode 100644 index 0000000..3577d81 --- /dev/null +++ b/apps/api/src/tabs/tab-orders.ts @@ -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 }[] = []; + const byId = new Map(); + 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, + })), + })); +} diff --git a/apps/api/src/tabs/tab-state.ts b/apps/api/src/tabs/tab-state.ts index fe1ed73..35c057d 100644 --- a/apps/api/src/tabs/tab-state.ts +++ b/apps/api/src/tabs/tab-state.ts @@ -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 (`:`). */ -/** 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; /** 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, }; } diff --git a/apps/api/src/tabs/tabs.service.ts b/apps/api/src/tabs/tabs.service.ts index 1810b09..e98cf33 100644 --- a/apps/api/src/tabs/tabs.service.ts +++ b/apps/api/src/tabs/tabs.service.ts @@ -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, }; diff --git a/apps/api/src/testing/fake-prisma.ts b/apps/api/src/testing/fake-prisma.ts index b649a20..bd60beb 100644 --- a/apps/api/src/testing/fake-prisma.ts +++ b/apps/api/src/testing/fake-prisma.ts @@ -11,6 +11,7 @@ type Row = Record; const COMPOUND_KEYS: Record = { venueId_id: ['venueId', 'id'], venueId_productId: ['venueId', 'productId'], + venueId_customerNameKey: ['venueId', 'customerNameKey'], }; function matches(row: Row, where: Row = {}): boolean { diff --git a/apps/api/src/venues/dto/update-cash-payment-emails.dto.ts b/apps/api/src/venues/dto/update-cash-payment-emails.dto.ts new file mode 100644 index 0000000..e60ddd0 --- /dev/null +++ b/apps/api/src/venues/dto/update-cash-payment-emails.dto.ts @@ -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[]; +} diff --git a/apps/api/src/venues/venues.controller.ts b/apps/api/src/venues/venues.controller.ts index bb7a16f..3d7e905 100644 --- a/apps/api/src/venues/venues.controller.ts +++ b/apps/api/src/venues/venues.controller.ts @@ -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) diff --git a/apps/api/src/venues/venues.service.ts b/apps/api/src/venues/venues.service.ts index 3962729..5aab31e 100644 --- a/apps/api/src/venues/venues.service.ts +++ b/apps/api/src/venues/venues.service.ts @@ -16,4 +16,15 @@ export class VenuesService { } return venue; } -} \ No newline at end of file + + /** 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 }; + } +} diff --git a/apps/api/templates/emails/README.md b/apps/api/templates/emails/README.md index fdf7019..d9d4c04 100644 --- a/apps/api/templates/emails/README.md +++ b/apps/api/templates/emails/README.md @@ -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 | diff --git a/apps/api/templates/emails/cash-payment.html b/apps/api/templates/emails/cash-payment.html new file mode 100644 index 0000000..bd7680b --- /dev/null +++ b/apps/api/templates/emails/cash-payment.html @@ -0,0 +1,31 @@ + + + + + {{customerName}} betaalde {{amount}} cash bij {{venueName}} + + +

De poef van {{customerName}} is cash betaald.

+ + + + +
Bedrag{{amount}}
Betaald op{{settledAt}}
Afgerekend door{{settledBy}}
+ {{#hasItems}} +

Overzicht:

+ + {{#items}} + + + + + {{/items}} + + + + +
{{quantity}}x {{productName}}{{lineTotal}}
Totaal{{amount}}
+ {{/hasItems}} +

{{venueName}}

+ + diff --git a/apps/api/templates/emails/cash-payment.txt b/apps/api/templates/emails/cash-payment.txt new file mode 100644 index 0000000..d3bf5c4 --- /dev/null +++ b/apps/api/templates/emails/cash-payment.txt @@ -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}} diff --git a/apps/dashboard/src/app/dashboard/history/page.tsx b/apps/dashboard/src/app/dashboard/history/page.tsx index f6f38d6..77eefb6 100644 --- a/apps/dashboard/src/app/dashboard/history/page.tsx +++ b/apps/dashboard/src/app/dashboard/history/page.tsx @@ -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() { {/* Pinned to the visible area so lines stay readable while the table scrolls sideways on phones */}
- +
@@ -184,25 +185,3 @@ export default function HistoryPage() { ); } - -/** 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

{t('tabs.lines_empty')}

; - } - return ( -
    - {items.map((item, i) => ( -
  • - - {item.quantity}× {item.productName} - · {formatCents(item.unitPriceInCents)} {t('tabs.each')} - {item.removed > 0 && · {t('history.removed', { count: item.removed })}} - - {formatCents(item.quantity * item.unitPriceInCents)} -
  • - ))} -
- ); -} diff --git a/apps/dashboard/src/app/dashboard/settings/page.tsx b/apps/dashboard/src/app/dashboard/settings/page.tsx index e832238..099aeb9 100644 --- a/apps/dashboard/src/app/dashboard/settings/page.tsx +++ b/apps/dashboard/src/app/dashboard/settings/page.tsx @@ -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() { + + {t('settings.device')} @@ -176,4 +179,84 @@ export default function SettingsPage() { )} ); -} \ No newline at end of file +} + +/** 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 ( + + + {t('settings.cash_emails')} + {t('settings.cash_emails_desc')} + + + {canEdit && ( +
+
+ { 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} + /> + +
+ {invalid &&

{t('settings.cash_emails_invalid')}

} +
+ )} +
+ {emails && current.length === 0 && ( +

{t('settings.cash_emails_empty')}

+ )} + {current.map((email) => ( +
+ {email} + {canEdit && ( + + )} +
+ ))} +
+
+
+ ); +} diff --git a/apps/dashboard/src/app/dashboard/tabs/page.tsx b/apps/dashboard/src/app/dashboard/tabs/page.tsx index 46c8b0c..bae015c 100644 --- a/apps/dashboard/src/app/dashboard/tabs/page.tsx +++ b/apps/dashboard/src/app/dashboard/tabs/page.tsx @@ -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() { {/* Pinned to the visible area so lines stay readable while the table scrolls sideways on phones */}
- +
@@ -348,7 +349,7 @@ export default function TabsPage() {
{t('tabs.amount')}{formatCents(closeTab.totalInCents)}
{t('tabs.items')}{closeTab.itemCount}
-
+
@@ -369,27 +370,3 @@ export default function TabsPage() {
); } - -/** 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

{t('tabs.lines_unknown')}

; - } - if (items.length === 0) { - return

{t('tabs.lines_empty')}

; - } - return ( -
    - {items.map((item, i) => ( -
  • - - {item.quantity}× {item.productName} - · {formatCents(item.unitPriceInCents)} {t('tabs.each')} - - {formatCents(item.quantity * item.unitPriceInCents)} -
  • - ))} -
- ); -} diff --git a/apps/dashboard/src/components/tab-orders.tsx b/apps/dashboard/src/components/tab-orders.tsx new file mode 100644 index 0000000..891beea --- /dev/null +++ b/apps/dashboard/src/components/tab-orders.tsx @@ -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

{t('tabs.lines_unknown')}

; + } + if (orders.length === 0) { + return

{t('tabs.lines_empty')}

; + } + return ( +
+ {orders.map((order) => ( +
+
+ + {orderTime(order.startedAt)} + +
+
    + {order.items.map((item, i) => ( +
  • + + {item.quantity}× {item.productName} + · {formatCents(item.unitPriceInCents)} {t('tabs.each')} + {(item.removed ?? 0) > 0 && ( + · {t('history.removed', { count: item.removed! })} + )} + + {formatCents(item.quantity * item.unitPriceInCents)} +
  • + ))} +
+
+ ))} +
+ ); +} diff --git a/apps/dashboard/src/lib/api/client.ts b/apps/dashboard/src/lib/api/client.ts index 1779c15..5e9c05a 100644 --- a/apps/dashboard/src/lib/api/client.ts +++ b/apps/dashboard/src/lib/api/client.ts @@ -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 diff --git a/apps/dashboard/src/lib/i18n.tsx b/apps/dashboard/src/lib/i18n.tsx index 14d20f4..33f9244 100644 --- a/apps/dashboard/src/lib/i18n.tsx +++ b/apps/dashboard/src/lib/i18n.tsx @@ -54,6 +54,8 @@ const translations: Record> = { '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> = { '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> = { '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> = { '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 { 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}`; + }, }; } diff --git a/docs/api.md b/docs/api.md index 4cfbf8d..9cf097b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -55,7 +55,10 @@ and `docs/server-stock-source-of-truth.md`. - `events` (at most 1000): terminal-generated UUID `id`, `type`, `occurredAt` (ISO-8601, UTC; capped at the server time when more than 5 minutes ahead) and per type: - `tab_opened` (`tabId`, `customerName`, `openedAt`), `tab_renamed` (new `customerName`), `tab_deleted` - - `line_added` / `line_removed`: `lineId`, `productId`, `productName`, `unitPriceInCents`, `quantity` (≥ 1) + - `line_added` / `line_removed`: `lineId`, `productId`, `productName`, `unitPriceInCents`, `quantity` (≥ 1) and + `orderId`: the order on the terminal the drinks belong to. Every unit keeps the `orderId` of its `line_added`, and + the dashboard and terminals show a tab per order. `line_removed` with an `orderId` (or `null`: drinks without an + order) only takes that order's drinks off; without the field (older app versions) the latest of the line - `settled`: `amountInCents`, `paymentMethod` (`cash`, `payconiq`, `payment_pending` or `other`), optional `commandId` and `items` (`[{ productId?, productName, quantity, unitPriceInCents }]`) - `stock_set`: `productId`, `quantity` (a count ≥ 0, or `null` to stop tracking); no `tabId`