173 lines
5.3 KiB
TypeScript
173 lines
5.3 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
} |