feat(POS-004): completed feature

This commit is contained in:
chattie
2026-08-22 13:31:19 +02:00
parent 7ce6465054
commit 955c25d77b
18 changed files with 569 additions and 130 deletions

View File

@@ -0,0 +1,328 @@
import type { FastifyInstance, FastifySchema } from 'fastify';
import type pg from 'pg';
import type { CurrentUser, Role } from '../../../shared/auth.js';
import { AppError } from '../../../shared/errors.js';
import { parseJson } from '../../../shared/http-input.js';
import { errorSchema } from '../../../shared/swagger.js';
import { requireRole, requireAnyRole } from '../../../shared/auth.js';
import { z } from 'zod';
import { ListStoresUseCase } from '../application/list-stores.js';
import { ListTerminalsUseCase } from '../application/list-terminals.js';
import { GetPosConfigUseCase } from '../application/get-pos-config.js';
import { OpenCashSessionUseCase } from '../application/open-cash-session.js';
import { CloseCashSessionUseCase } from '../application/close-cash-session.js';
import { PgStoreRepository } from '../infrastructure/pg-store-repository.js';
import { PgTerminalRepository } from '../infrastructure/pg-terminal-repository.js';
import { PgPaymentMethodRepository } from '../infrastructure/pg-payment-method-repository.js';
import { PgCashSessionRepository } from '../infrastructure/pg-cash-session-repository.js';
export interface PosRouteDeps {
pool: pg.Pool;
authenticate: (request: import('fastify').FastifyRequest) => Promise<CurrentUser>;
}
const idParamSchema = z.object({ id: z.string().uuid() });
export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps) {
const { pool, authenticate } = deps;
const storeRepo = new PgStoreRepository(pool);
const terminalRepo = new PgTerminalRepository(pool);
const paymentMethodRepo = new PgPaymentMethodRepository(pool);
const sessionRepo = new PgCashSessionRepository(pool);
const listStores = new ListStoresUseCase(storeRepo);
const listTerminals = new ListTerminalsUseCase(terminalRepo);
const getConfig = new GetPosConfigUseCase(storeRepo, terminalRepo, paymentMethodRepo, sessionRepo);
const openSession = new OpenCashSessionUseCase(sessionRepo, terminalRepo);
const closeSession = new CloseCashSessionUseCase(sessionRepo);
// ── Admin: stores ─────────────────────────────────────────────────────────
app.get('/pos/admin/stores', {
schema: {
tags: ['POS Admin'],
summary: 'List POS stores',
querystring: { type: 'object', properties: { active: { type: 'boolean' } } },
response: { 401: errorSchema, 403: errorSchema },
} as FastifySchema,
}, async (request, reply) => {
const user = await authenticate(request);
requireRole(user, 'admin');
const { active } = request.query as { active?: boolean };
const result = await listStores.execute({ active });
return reply.send(result);
});
app.post('/pos/admin/stores', {
schema: {
tags: ['POS Admin'],
summary: 'Create POS store',
body: {
type: 'object',
required: ['name', 'slug'],
properties: {
name: { type: 'string', minLength: 1, maxLength: 200 },
slug: { type: 'string', pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$' },
address: { type: 'string' },
taxId: { type: 'string' },
contactEmail: { type: 'string' },
contactPhone: { type: 'string' },
receiptHeader: { type: 'string' },
receiptFooter: { type: 'string' },
},
},
response: { 400: errorSchema, 401: errorSchema, 403: errorSchema },
} as FastifySchema,
}, async (request, reply) => {
const user = await authenticate(request);
requireRole(user, 'admin');
const body = parseJson(
z.object({
name: z.string().min(1).max(200),
slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
address: z.string().optional(),
taxId: z.string().optional(),
contactEmail: z.string().optional(),
contactPhone: z.string().optional(),
receiptHeader: z.string().optional(),
receiptFooter: z.string().optional(),
}),
request.body ?? {},
);
const result = await pool.query<{ id: string; name: string; slug: string; active: boolean }>(
`INSERT INTO pos_stores (name, slug, address, tax_id, contact_email, contact_phone, receipt_header, receipt_footer)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, name, slug, active`,
[body.name, body.slug, body.address, body.taxId, body.contactEmail, body.contactPhone, body.receiptHeader, body.receiptFooter],
);
return reply.code(201).send(result.rows[0]);
});
// ── Admin: terminals ─────────────────────────────────────────────────────
app.get('/pos/admin/terminals', {
schema: {
tags: ['POS Admin'],
summary: 'List POS terminals',
querystring: {
type: 'object',
properties: {
storeId: { type: 'string', format: 'uuid' },
status: { type: 'string', enum: ['active', 'disabled', 'decommissioned'] },
},
},
response: { 401: errorSchema, 403: errorSchema },
} as FastifySchema,
}, async (request, reply) => {
const user = await authenticate(request);
requireRole(user, 'admin');
const { storeId, status } = request.query as { storeId?: string; status?: string };
const result = await listTerminals.execute({ storeId, status: status as 'active' | 'disabled' | 'decommissioned' | undefined });
return reply.send(result);
});
app.post('/pos/admin/terminals', {
schema: {
tags: ['POS Admin'],
summary: 'Create POS terminal',
body: {
type: 'object',
required: ['storeId', 'name'],
properties: {
storeId: { type: 'string', format: 'uuid' },
name: { type: 'string', minLength: 1, maxLength: 100 },
},
},
response: { 400: errorSchema, 401: errorSchema, 403: errorSchema },
} as FastifySchema,
}, async (request, reply) => {
const user = await authenticate(request);
requireRole(user, 'admin');
const body = parseJson(
z.object({ storeId: z.string().uuid(), name: z.string().min(1).max(100) }),
request.body ?? {},
);
// Generate a short binding code (8 hex chars)
const bindingCode = Math.random().toString(16).slice(2, 10).toUpperCase();
const result = await pool.query<{ id: string; name: string; bindingCode: string; storeId: string }>(
`INSERT INTO pos_terminals (store_id, name, binding_code)
VALUES ($1, $2, $3)
RETURNING id, name, binding_code as "bindingCode", store_id as "storeId"`,
[body.storeId, body.name, bindingCode],
);
return reply.code(201).send(result.rows[0]);
});
app.get<{ Params: { id: string } }>('/pos/admin/terminals/:id', {
schema: {
tags: ['POS Admin'],
summary: 'Get terminal',
params: idParamSchema,
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
} as FastifySchema,
}, async (request, reply) => {
const user = await authenticate(request);
requireRole(user, 'admin');
const { id } = parseJson(idParamSchema, request.params);
const terminal = await terminalRepo.findById(id);
if (!terminal) throw new AppError(404, 'TERMINAL_NOT_FOUND', 'Terminal not found');
return reply.send(terminal);
});
app.delete<{ Params: { id: string } }>('/pos/admin/terminals/:id', {
schema: {
tags: ['POS Admin'],
summary: 'Decommission terminal',
params: idParamSchema,
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
} as FastifySchema,
}, async (request, reply) => {
const user = await authenticate(request);
requireRole(user, 'admin');
const { id } = parseJson(idParamSchema, request.params);
await pool.query(`UPDATE pos_terminals SET status = 'decommissioned' WHERE id = $1`, [id]);
return reply.send({ ok: true });
});
// ── Terminal: me + bind + config ───────────────────────────────────────
app.get('/pos/terminals/me', {
schema: {
tags: ['POS Terminal'],
summary: 'Get current terminal info',
headers: { type: 'object', properties: { 'x-terminal-id': { type: 'string', format: 'uuid' } } },
response: { 401: errorSchema, 404: errorSchema },
} as FastifySchema,
}, async (request, reply) => {
const user = await authenticate(request);
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
const terminalId = request.headers['x-terminal-id'] as string | undefined;
if (!terminalId) throw new AppError(400, 'MISSING_TERMINAL_ID', 'x-terminal-id header required');
const terminal = await terminalRepo.findById(terminalId);
if (!terminal) throw new AppError(404, 'TERMINAL_NOT_FOUND', 'Terminal not found');
return reply.send(terminal);
});
app.post('/pos/terminals/bind', {
schema: {
tags: ['POS Terminal'],
summary: 'Bind terminal with code',
body: {
type: 'object',
required: ['bindingCode'],
properties: { bindingCode: { type: 'string', minLength: 8, maxLength: 8 } },
},
response: { 400: errorSchema, 401: errorSchema, 404: errorSchema, 409: errorSchema },
} as FastifySchema,
}, async (request, reply) => {
const user = await authenticate(request);
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
const body = parseJson(z.object({ bindingCode: z.string().length(8) }), request.body ?? {});
const terminal = await terminalRepo.findByBindingCode(body.bindingCode.toUpperCase());
if (!terminal) throw new AppError(404, 'TERMINAL_NOT_FOUND', 'Terminal not found');
if (terminal.status !== 'active') throw new AppError(409, 'TERMINAL_NOT_ACTIVE', 'Terminal is not active');
const bound = await terminalRepo.bind(terminal.id, body.bindingCode.toUpperCase());
return reply.send({ terminalId: bound.id, storeId: bound.storeId });
});
app.get('/pos/config', {
schema: {
tags: ['POS Terminal'],
summary: 'Get POS terminal config',
headers: { type: 'object', properties: { 'x-terminal-id': { type: 'string', format: 'uuid' } } },
response: { 401: errorSchema, 404: errorSchema },
} as FastifySchema,
}, async (request, reply) => {
const user = await authenticate(request);
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
const terminalId = request.headers['x-terminal-id'] as string | undefined;
if (!terminalId) throw new AppError(400, 'MISSING_TERMINAL_ID', 'x-terminal-id header required');
const config = await getConfig.execute(terminalId);
return reply.send(config);
});
// ── Cash sessions ───────────────────────────────────────────────────────
app.get('/pos/sessions/me', {
schema: {
tags: ['POS Terminal'],
summary: 'Get current open session',
headers: { type: 'object', properties: { 'x-terminal-id': { type: 'string', format: 'uuid' } } },
response: { 401: errorSchema, 404: errorSchema },
} as FastifySchema,
}, async (request, reply) => {
const user = await authenticate(request);
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
const terminalId = request.headers['x-terminal-id'] as string | undefined;
if (!terminalId) throw new AppError(400, 'MISSING_TERMINAL_ID', 'x-terminal-id header required');
const session = await sessionRepo.findOpenByTerminal(terminalId);
if (!session) throw new AppError(404, 'SESSION_NOT_FOUND', 'No open session');
return reply.send(session);
});
app.post('/pos/sessions', {
schema: {
tags: ['POS Terminal'],
summary: 'Open cash session',
headers: { type: 'object', properties: { 'x-terminal-id': { type: 'string', format: 'uuid' } } },
body: {
type: 'object',
required: ['openingCashCents'],
properties: { openingCashCents: { type: 'integer', minimum: 0 } },
},
response: { 400: errorSchema, 401: errorSchema, 404: errorSchema, 409: errorSchema },
} as FastifySchema,
}, async (request, reply) => {
const user = await authenticate(request);
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
const terminalId = request.headers['x-terminal-id'] as string | undefined;
if (!terminalId) throw new AppError(400, 'MISSING_TERMINAL_ID', 'x-terminal-id header required');
const body = parseJson(z.object({ openingCashCents: z.number().int().min(0) }), request.body ?? {});
try {
const session = await openSession.execute({ terminalId, userId: user.id, openingCashCents: body.openingCashCents });
return reply.code(201).send(session);
} catch (err) {
if (err instanceof AppError) throw err;
throw new AppError(409, 'SESSION_ERROR', String(err));
}
});
app.post<{ Params: { id: string } }>('/pos/sessions/:id/close', {
schema: {
tags: ['POS Terminal'],
summary: 'Close cash session',
params: idParamSchema,
body: {
type: 'object',
required: ['closingCashCents', 'actualCashCents'],
properties: {
closingCashCents: { type: 'integer', minimum: 0 },
actualCashCents: { type: 'integer', minimum: 0 },
notes: { type: 'string' },
},
},
response: { 400: errorSchema, 401: errorSchema, 404: errorSchema, 409: errorSchema },
} as FastifySchema,
}, async (request, reply) => {
const user = await authenticate(request);
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
const { id } = parseJson(idParamSchema, request.params);
const body = parseJson(
z.object({
closingCashCents: z.number().int().min(0),
actualCashCents: z.number().int().min(0),
notes: z.string().optional(),
}),
request.body ?? {},
);
try {
const session = await closeSession.execute({ sessionId: id, ...body });
return reply.send(session);
} catch (err) {
if (err instanceof AppError) throw err;
throw new AppError(409, 'CLOSE_ERROR', String(err));
}
});
}