@@ -4,7 +4,7 @@ import { SyncDto } from './dto/sync.dto';
|
||||
import { createFakePrisma, FakePrisma } from '../testing/fake-prisma';
|
||||
import { StockService } from '../stock/stock.service';
|
||||
|
||||
type Device = { id: string; venueId: string; revokedAt: null; lastSeenAt: Date };
|
||||
type Device = { id: string; venueId: string; name?: string; revokedAt: null; lastSeenAt: Date };
|
||||
type SyncResult = Awaited<ReturnType<DevicesService['sync']>>;
|
||||
|
||||
// Yesterday at 20:00 UTC. In the past, as a time ahead of the server clock would be capped, and recent, as a new
|
||||
@@ -17,16 +17,17 @@ const at = (minutes: number) => new Date(Date.UTC(
|
||||
function setup({ seeded = true } = {}) {
|
||||
const prisma = createFakePrisma();
|
||||
prisma.venue.rows.push({ id: 'v1', tabsSeededAt: seeded ? new Date('2026-10-01') : null });
|
||||
const tabletA: Device = { id: 'd1', venueId: 'v1', revokedAt: null, lastSeenAt: new Date() };
|
||||
const tabletA: Device = { id: 'd1', venueId: 'v1', name: 'Bar tablet', revokedAt: null, lastSeenAt: new Date() };
|
||||
const tabletB: Device = { id: 'd2', venueId: 'v1', revokedAt: null, lastSeenAt: new Date() };
|
||||
prisma.device.rows.push({ ...tabletA }, { ...tabletB });
|
||||
const realtime = { notify: jest.fn() };
|
||||
const service = new DevicesService(prisma as any, { log: jest.fn() } as any, realtime as any);
|
||||
const cashPayments = { notify: jest.fn() };
|
||||
const service = new DevicesService(prisma as any, { log: jest.fn() } as any, realtime as any, cashPayments as any);
|
||||
|
||||
const sync = (device: Device, body: Partial<SyncDto> = {}) =>
|
||||
service.sync(device, { deviceId: device.id, openTabs: [], ...body } as SyncDto);
|
||||
|
||||
return { prisma, service, realtime, sync, tabletA, tabletB };
|
||||
return { prisma, service, realtime, cashPayments, sync, tabletA, tabletB };
|
||||
}
|
||||
|
||||
/** Builds the events a terminal sends, with fresh ids. */
|
||||
@@ -244,6 +245,19 @@ describe('DevicesService.sync: settlements feed', () => {
|
||||
expect(second.settlementsCursor).toBe(first.settlementsCursor);
|
||||
});
|
||||
|
||||
it('hands a settlement to the cash payment emails once, with the tablet that settled it', async () => {
|
||||
const { sync, tabletA, cashPayments } = setup();
|
||||
const settled = { ...events.settled('t1', 1, 2), paymentMethod: 'cash' };
|
||||
const batch = [events.opened('t1', 'Alice'), events.line('line_added', 't1', 1), settled];
|
||||
|
||||
await sync(tabletA, { events: batch });
|
||||
expect(cashPayments.notify).toHaveBeenCalledWith('v1', [expect.objectContaining({ id: settled.id, paymentMethod: 'cash' })], 'Bar tablet');
|
||||
|
||||
// Resent because the acknowledgement was lost: already applied, so nothing new to email
|
||||
await sync(tabletA, { events: batch });
|
||||
expect(cashPayments.notify).toHaveBeenLastCalledWith('v1', [], 'Bar tablet');
|
||||
});
|
||||
|
||||
it('starts an empty feed after the latest settlement', async () => {
|
||||
const { sync, tabletA } = setup();
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import { openPlannedTabs, settlementsAfter } from '../tabs/tab-sync';
|
||||
import { applyPendingAdjustments } from '../stock/stock-adjustments';
|
||||
import { applyProductEvent } from '../stock/product-events';
|
||||
import { isProductEvent } from './dto/tab-event.dto';
|
||||
import { CashPaymentNotifier } from '../email/cash-payment-notifier';
|
||||
|
||||
@Injectable()
|
||||
export class DevicesService {
|
||||
@@ -30,6 +31,7 @@ export class DevicesService {
|
||||
private prisma: PrismaService,
|
||||
private auditService: AuditService,
|
||||
private realtime: RealtimeService,
|
||||
private cashPayments: CashPaymentNotifier,
|
||||
) {}
|
||||
|
||||
async sync(device: any, dto: SyncDto) {
|
||||
@@ -177,6 +179,7 @@ export class DevicesService {
|
||||
if (topics.length > 0) this.realtime.notify(venueId, ...topics);
|
||||
|
||||
const { ledger, feed, catalog } = synced;
|
||||
this.cashPayments.notify(venueId, ledger.settlements, device.name);
|
||||
return {
|
||||
cursor,
|
||||
commands: commands.map((c) => ({
|
||||
|
||||
@@ -118,6 +118,17 @@ export class TabEventDto {
|
||||
@MaxLength(200)
|
||||
productName?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 'order-uuid',
|
||||
nullable: true,
|
||||
description: 'line_added: the order the drinks are part of. line_removed: the order they are taken off, null for '
|
||||
+ 'drinks without one. Missing from older app versions: line_removed then takes the latest drinks of the line',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
orderId?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ example: 250, description: 'line_added / line_removed' })
|
||||
@ValidateIf(isLine)
|
||||
@IsInt()
|
||||
|
||||
@@ -92,6 +92,7 @@ export function tabEventRow(event: TabEventDto, venueId: string, deviceId: strin
|
||||
productName: event.productName,
|
||||
unitPriceInCents: event.unitPriceInCents,
|
||||
quantityDelta: event.type === 'line_removed' ? -event.quantity! : event.quantity,
|
||||
orderId: event.orderId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user