feat: add stuff
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-10-27 23:27:56 +01:00
parent 97572fe459
commit bca6e88138
27 changed files with 885 additions and 126 deletions
+21
View File
@@ -19,6 +19,27 @@ export class TabsController {
return this.tabsService.findAll(venueId);
}
@Get('history')
@ApiOperation({ summary: 'List paid tabs, newest first' })
@ApiQuery({ name: 'search', required: false, description: 'Part of the customer name' })
@ApiQuery({ name: 'paymentMethod', required: false })
@ApiQuery({ name: 'limit', required: false })
@ApiQuery({ name: 'offset', required: false })
async history(
@Param('venueId') venueId: string,
@Query('search') search?: string,
@Query('paymentMethod') paymentMethod?: string,
@Query('limit') limit?: string,
@Query('offset') offset?: string,
) {
return this.tabsService.history(venueId, {
search,
paymentMethod,
limit: Math.min(Math.max(parseInt(limit ?? '', 10) || 50, 1), 200),
offset: Math.max(parseInt(offset ?? '', 10) || 0, 0),
});
}
@Delete(':tabId')
@Roles('admin', 'manager')
@ApiOperation({ summary: 'Delete an open tab without settling it' })
+64
View File
@@ -0,0 +1,64 @@
import { TabsService } from './tabs.service';
import { createFakePrisma } from '../testing/fake-prisma';
const at = (minutes: number) => new Date(Date.UTC(2026, 9, 27, 20, minutes));
function setup() {
const prisma = createFakePrisma();
prisma.device.rows.push({ id: 'd1', venueId: 'v1', name: 'Bar tablet' });
const service = new TabsService(prisma as any, { notify: jest.fn() } as any);
const settle = (id: string, customerName: string, minutes: number, extra: Record<string, unknown> = {}) =>
prisma.settlement.create({
data: {
id, venueId: 'v1', tabId: `tab-${id}`, customerName, settledAt: at(minutes), paymentMethod: 'cash',
amountInCents: 500, deviceId: 'd1', requestedBy: null,
lines: [{
id: 'l1', productId: 'cola', productName: 'Cola', unitPriceInCents: 250, quantity: 2,
units: [
{ id: 'u1', addedAt: at(0).toISOString(), removedAt: null },
{ id: 'u2', addedAt: at(0).toISOString(), removedAt: null },
{ id: 'u3', addedAt: at(0).toISOString(), removedAt: at(1).toISOString() },
],
}],
...extra,
},
});
return { prisma, service, settle };
}
describe('TabsService.history', () => {
it('lists paid tabs newest first, with their lines and who settled them', async () => {
const { service, settle } = setup();
await settle('s1', 'Alice', 1);
await settle('s2', 'Bob', 5, { deviceId: null, requestedBy: 'manager@bar.be', paymentMethod: 'payconiq' });
await settle('other', 'Eve', 9, { venueId: 'v2' });
const result = await service.history('v1', { limit: 50, offset: 0 });
expect(result.total).toBe(2);
expect(result.totalAmountInCents).toBe(1000);
expect(result.settlements.map((s) => s.id)).toEqual(['s2', 's1']);
expect(result.settlements[0]).toMatchObject({ settledBy: 'manager@bar.be', onDashboard: true, paymentMethod: 'payconiq' });
expect(result.settlements[1]).toMatchObject({ settledBy: 'Bar tablet', onDashboard: false, itemCount: 2 });
expect(result.settlements[1].items).toEqual([{ productName: 'Cola', quantity: 2, unitPriceInCents: 250, removed: 1 }]);
});
it('filters by part of the name and payment method, and pages', async () => {
const { service, settle } = setup();
await settle('s1', 'Alice', 1);
await settle('s2', 'alicia', 2, { paymentMethod: 'payconiq' });
await settle('s3', 'Bob', 3);
const byName = await service.history('v1', { search: ' ALI ', limit: 50, offset: 0 });
expect(byName.settlements.map((s) => s.id)).toEqual(['s2', 's1']);
const byMethod = await service.history('v1', { search: 'ali', paymentMethod: 'cash', limit: 50, offset: 0 });
expect(byMethod.settlements.map((s) => s.id)).toEqual(['s1']);
const secondPage = await service.history('v1', { limit: 2, offset: 2 });
expect(secondPage.total).toBe(3);
expect(secondPage.settlements.map((s) => s.id)).toEqual(['s1']);
});
});
+61 -1
View File
@@ -1,9 +1,10 @@
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 { tabSummary } from './tab-state';
import { SettledLine, tabSummary } from './tab-state';
import { VenueLedger } from './venue-ledger';
@Injectable()
@@ -37,6 +38,65 @@ export class TabsService {
};
}
/**
* 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 items = (row.lines as unknown as SettledLine[]).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,
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