import { Injectable, NotFoundException, BadRequestException, ConflictException, } from '@nestjs/common'; import { v4 as uuidv4 } from 'uuid'; import { PrismaService } from '../prisma/prisma.service'; 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 { constructor( private prisma: PrismaService, private auditService: AuditService, private realtime: RealtimeService, private cashPayments: CashPaymentNotifier, ) {} /** * Settles a tab on the server, at once, also when every terminal is offline: everything still on the tab is * settled, and the terminals see the lines disappear on their next sync. Recorded as a completed command for the * history. */ async create(venueId: string, dto: CreateCommandDto, user: any) { if (!dto.paymentMethod) { throw new BadRequestException({ code: 'payment_method_required', message: 'Payment method must be explicitly selected', }); } const validMethods = ['cash', 'payconiq', 'payment_pending']; if (!validMethods.includes(dto.paymentMethod)) { throw new BadRequestException({ code: 'invalid_payment_method', message: `Payment method must be one of: ${validMethods.join(', ')}`, }); } const type = dto.type || 'close_tab'; if (type !== 'close_tab') { throw new BadRequestException({ code: 'invalid_command_type', message: `Unknown command type: ${type}` }); } const requestedBy = user.name || user.email; const { result, settlements } = await this.prisma.$transaction(async (tx) => { const now = new Date(); const ledger = await VenueLedger.open(tx, venueId); const tab = ledger.tabs.get(dto.tabId); if (!tab) { throw new NotFoundException({ code: 'tab_not_found', message: 'This tab is no longer open' }); } if (tab.version !== dto.expectedVersion) { throw new ConflictException({ code: 'version_mismatch', message: 'The tab has changed since it was loaded. Refresh and try again.', }); } const id = uuidv4(); const settlement = ledger.tabs.settleAll(dto.tabId, { id, settledAt: now, paymentMethod: dto.paymentMethod }); if (!settlement) { throw new ConflictException({ code: 'tab_empty', message: 'There is nothing on this tab to settle' }); } ledger.settlements.push({ ...settlement, commandId: id, requestedBy }); await ledger.save(tx, now); const command = await tx.remoteCommand.create({ data: { id, venueId, deviceId: null, targetTabId: dto.tabId, customerName: settlement.customerName, amountInCents: settlement.amountInCents, type, expectedVersion: dto.expectedVersion, paymentMethod: dto.paymentMethod, requestedBy, status: 'completed', acknowledgedAt: now, expiresAt: now, }, }); await tx.auditLog.create({ data: { id: uuidv4(), venueId, actorType: 'client_user', actorId: user.id, action: 'tab_settled', details: JSON.stringify({ commandId: command.id, tabId: dto.tabId, paymentMethod: dto.paymentMethod, 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; } async findAll( venueId: string, options: { status?: string; limit: number; offset: number }, ) { const where: any = { venueId }; if (options.status) { where.status = options.status; } const [commands, total] = await Promise.all([ this.prisma.remoteCommand.findMany({ where, select: { id: true, targetTabId: true, customerName: true, amountInCents: true, type: true, status: true, paymentMethod: true, requestedBy: true, expectedVersion: true, createdAt: true, acknowledgedAt: true, result: true, deviceId: true, }, orderBy: { createdAt: 'desc' }, take: options.limit, skip: options.offset, }), this.prisma.remoteCommand.count({ where }), ]); return { commands: commands.map((c) => ({ id: c.id, tabId: c.targetTabId, customerName: c.customerName, amountInCents: c.amountInCents, type: c.type, status: c.status, paymentMethod: c.paymentMethod, requestedBy: c.requestedBy, expectedVersion: c.expectedVersion, createdAt: c.createdAt, processedAt: c.acknowledgedAt, result: c.result, deviceId: c.deviceId, })), total, }; } }