import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { v4 as uuidv4 } from 'uuid'; import { PrismaService } from '../prisma/prisma.service'; import { 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() export class TabsService { constructor( private prisma: PrismaService, private realtime: RealtimeService, ) {} /** The venue's open tabs as the server holds them; every terminal shows the same. */ async findAll(venueId: string) { const tabs = await this.prisma.tab.findMany({ where: { venueId }, orderBy: { openedAt: 'asc' } }); // Customer details (email for reminders) live on the planned customer with the same name const planned = await this.prisma.plannedTab.findMany({ where: { venueId }, select: { id: true, customerNameKey: true, email: true }, }); const plannedByKey = new Map(planned.map((p) => [p.customerNameKey, p])); return { tabs: tabs.map(tabSummary).map(({ updatedAt, ...tab }) => { const plannedTab = plannedByKey.get(customerNameKey(tab.customerName)); return { ...tab, lastChangedAt: updatedAt, plannedTabId: plannedTab?.id ?? null, email: plannedTab?.email ?? null, }; }), }; } /** * The venue's paid tabs, newest first, as the terminals' history shows them: every settlement made on a terminal * or on the dashboard. [search] matches the customer name. */ async history( venueId: string, options: { search?: string; paymentMethod?: string; limit: number; offset: number }, ) { const search = options.search?.trim(); const where: Prisma.SettlementWhereInput = { venueId, ...(search ? { customerName: { contains: search, mode: 'insensitive' as const } } : {}), ...(options.paymentMethod ? { paymentMethod: options.paymentMethod } : {}), }; const [rows, total, sum] = await Promise.all([ this.prisma.settlement.findMany({ where, orderBy: [{ settledAt: 'desc' }, { sequence: 'desc' }], skip: options.offset, take: options.limit, }), this.prisma.settlement.count({ where }), this.prisma.settlement.aggregate({ where, _sum: { amountInCents: true } }), ]); const deviceIds = [...new Set(rows.map((row) => row.deviceId).filter((id): id is string => id !== null))]; const devices = deviceIds.length > 0 ? await this.prisma.device.findMany({ where: { id: { in: deviceIds } }, select: { id: true, name: true } }) : []; const deviceNames = new Map(devices.map((device) => [device.id, device.name])); return { settlements: rows.map((row) => { const lines = row.lines as unknown as SettledLine[]; const items = lines.map((line) => ({ productName: line.productName, quantity: line.quantity, unitPriceInCents: line.unitPriceInCents, // Rung up and taken off again before paying removed: line.units.filter((unit) => unit.removedAt !== null).length, })); return { id: row.id, tabId: row.tabId, customerName: row.customerName, settledAt: row.settledAt, paymentMethod: row.paymentMethod, amountInCents: row.amountInCents, itemCount: items.reduce((count, item) => count + item.quantity, 0), items, orders: tabOrders(lines), settledBy: (row.deviceId ? deviceNames.get(row.deviceId) : row.requestedBy) ?? null, onDashboard: row.deviceId === null, }; }), total, totalAmountInCents: sum._sum.amountInCents ?? 0, }; } /** * Deletes a tab without settling it, as tab_deleted does on a terminal: what is still on it is not paid and stays * out of the stock. The terminals drop it on their next sync; one that still has unsent drinks for it brings it * back, so no sale is lost. [expectedVersion] refuses a tab that changed since it was shown. */ async remove(venueId: string, tabId: string, expectedVersion: number | undefined, user: { id: string }) { await this.prisma.$transaction(async (tx) => { const now = new Date(); const ledger = await VenueLedger.open(tx, venueId); const tab = ledger.tabs.get(tabId); if (!tab) { throw new NotFoundException({ code: 'tab_not_found', message: 'This tab is no longer open' }); } if (expectedVersion !== undefined && tab.version !== expectedVersion) { throw new ConflictException({ code: 'version_mismatch', message: 'The tab has changed since it was loaded. Refresh and try again.', }); } const { customerName, totalInCents, items } = tabSummary({ ...tab, updatedAt: now }); ledger.tabs.remove(tabId); await ledger.save(tx, now); await tx.auditLog.create({ data: { id: uuidv4(), venueId, actorType: 'client_user', actorId: user.id, action: 'tab_deleted', details: JSON.stringify({ tabId, customerName, amountInCents: totalInCents, items }), }, }); }); this.realtime.notify(venueId, 'tabs'); return { ok: true }; } }