This commit is contained in:
@@ -5,12 +5,11 @@ import {
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Prisma } from '@prisma/client';
|
||||
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 { COMMAND_EXPIRY_HOURS, DEVICE_OFFLINE_THRESHOLD_MS } from '@kooltap/shared';
|
||||
import { VenueLedger } from '../tabs/venue-ledger';
|
||||
|
||||
@Injectable()
|
||||
export class CommandsService {
|
||||
@@ -20,6 +19,11 @@ export class CommandsService {
|
||||
private realtime: RealtimeService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 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({
|
||||
@@ -36,72 +40,50 @@ export class CommandsService {
|
||||
});
|
||||
}
|
||||
|
||||
// Serializable so two simultaneous requests for the same tab can't both pass the pending check
|
||||
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 = await this.prisma.$transaction(async (tx) => {
|
||||
// Resolve tab ownership
|
||||
const ownership = await tx.tabOwnership.findUnique({
|
||||
where: { venueId_tabId: { venueId, tabId: dto.tabId } },
|
||||
});
|
||||
const now = new Date();
|
||||
const ledger = await VenueLedger.open(tx, venueId);
|
||||
|
||||
if (!ownership) {
|
||||
throw new NotFoundException({
|
||||
code: 'tab_not_owned',
|
||||
message: 'Tab is not associated with any device',
|
||||
});
|
||||
const tab = ledger.tabs.get(dto.tabId);
|
||||
if (!tab) {
|
||||
throw new NotFoundException({ code: 'tab_not_found', message: 'This tab is no longer open' });
|
||||
}
|
||||
|
||||
const device = await tx.device.findUnique({ where: { id: ownership.deviceId } });
|
||||
if (!device) {
|
||||
throw new NotFoundException({
|
||||
code: 'device_not_found',
|
||||
message: 'Owning device not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (device.revokedAt) {
|
||||
throw new ConflictException({
|
||||
code: 'device_revoked',
|
||||
message: 'The device owning this tab has been revoked',
|
||||
});
|
||||
}
|
||||
|
||||
const snapshot = await tx.openTabSnapshot.findUnique({
|
||||
where: { deviceId_tabId: { deviceId: device.id, tabId: dto.tabId } },
|
||||
});
|
||||
if (snapshot && snapshot.version !== dto.expectedVersion) {
|
||||
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 alreadyPending = await tx.remoteCommand.findFirst({
|
||||
where: { venueId, targetTabId: dto.tabId, status: 'pending' },
|
||||
});
|
||||
if (alreadyPending) {
|
||||
throw new ConflictException({
|
||||
code: 'command_already_pending',
|
||||
message: 'A command for this tab is already pending',
|
||||
});
|
||||
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' });
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + COMMAND_EXPIRY_HOURS * 60 * 60 * 1000);
|
||||
ledger.settlements.push({ ...settlement, commandId: id, requestedBy });
|
||||
await ledger.save(tx, now);
|
||||
|
||||
const command = await tx.remoteCommand.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
id,
|
||||
venueId,
|
||||
deviceId: device.id,
|
||||
deviceId: null,
|
||||
targetTabId: dto.tabId,
|
||||
customerName: snapshot?.customerName ?? null,
|
||||
amountInCents: snapshot?.totalInCents ?? null,
|
||||
type: dto.type || 'close_tab',
|
||||
customerName: settlement.customerName,
|
||||
amountInCents: settlement.amountInCents,
|
||||
type,
|
||||
expectedVersion: dto.expectedVersion,
|
||||
paymentMethod: dto.paymentMethod,
|
||||
requestedBy: user.name || user.email,
|
||||
status: 'pending',
|
||||
expiresAt,
|
||||
requestedBy,
|
||||
status: 'completed',
|
||||
acknowledgedAt: now,
|
||||
expiresAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -111,39 +93,20 @@ export class CommandsService {
|
||||
venueId,
|
||||
actorType: 'client_user',
|
||||
actorId: user.id,
|
||||
action: 'command_created',
|
||||
action: 'tab_settled',
|
||||
details: JSON.stringify({
|
||||
commandId: command.id,
|
||||
tabId: dto.tabId,
|
||||
type: dto.type,
|
||||
paymentMethod: dto.paymentMethod,
|
||||
deviceId: device.id,
|
||||
amountInCents: settlement.amountInCents,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
// Check if device is stale
|
||||
const lastSeenAgo = now.getTime() - new Date(device.lastSeenAt).getTime();
|
||||
const warning = lastSeenAgo > DEVICE_OFFLINE_THRESHOLD_MS
|
||||
? { warning: 'Device has not been seen recently' }
|
||||
: {};
|
||||
|
||||
return {
|
||||
id: command.id,
|
||||
status: command.status,
|
||||
...warning,
|
||||
};
|
||||
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }).catch((error) => {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2034') {
|
||||
throw new ConflictException({
|
||||
code: 'command_already_pending',
|
||||
message: 'A command for this tab is already pending',
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
return { id: command.id, status: command.status, amountInCents: settlement.amountInCents };
|
||||
});
|
||||
|
||||
this.realtime.notify(venueId, 'commands');
|
||||
this.realtime.notify(venueId, 'commands', 'tabs');
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user