feat(POS-008): completed feature

This commit is contained in:
chattie
2026-08-22 13:42:08 +02:00
parent 25e4e5b622
commit 63eaa3e58f
12 changed files with 356 additions and 10 deletions

View File

@@ -11,6 +11,7 @@ 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 { CreatePosSaleUseCase } from '../application/create-pos-sale.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';
@@ -30,6 +31,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
const terminalRepo = new PgTerminalRepository(pool);
const paymentMethodRepo = new PgPaymentMethodRepository(pool);
const sessionRepo = new PgCashSessionRepository(pool);
const createPosSale = new CreatePosSaleUseCase(pool);
const listStores = new ListStoresUseCase(storeRepo);
const listTerminals = new ListTerminalsUseCase(terminalRepo);
@@ -510,4 +512,81 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
return reply.send(result.rows[0]);
});
// ── POS-008: POST /pos/sales idempotent ───────────────────────────────────
app.post('/pos/sales', {
schema: {
tags: ['POS Terminal'],
summary: 'Record a POS sale (idempotent)',
body: {
type: 'object',
required: ['idempotencyKey', 'cashSessionId', 'terminalId', 'items', 'payments'],
properties: {
idempotencyKey: { type: 'string', minLength: 1, maxLength: 128 },
cashSessionId: { type: 'string', format: 'uuid' },
terminalId: { type: 'string', format: 'uuid' },
items: {
type: 'array',
items: {
type: 'object',
required: ['variantId', 'productId', 'sku', 'name', 'unitPriceCents', 'discountCents', 'taxCents', 'quantity'],
properties: {
variantId: { type: 'string', format: 'uuid' },
productId: { type: 'string', format: 'uuid' },
sku: { type: 'string' },
ean: { type: ['string', 'null'] },
name: { type: 'string' },
unitPriceCents: { type: 'integer', minimum: 0 },
discountCents: { type: 'integer', minimum: 0 },
taxCents: { type: 'integer', minimum: 0 },
quantity: { type: 'integer', minimum: 1 },
},
},
},
payments: {
type: 'array',
minItems: 1,
items: {
type: 'object',
required: ['kind', 'amountCents'],
properties: {
kind: { type: 'string', enum: ['cash', 'card', 'other'] },
amountCents: { type: 'integer', minimum: 1 },
tenderedCents: { type: 'integer', minimum: 0 },
last4: { type: 'string', maxLength: 4 },
},
},
},
customerId: { type: 'string', format: 'uuid' },
},
},
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({
idempotencyKey: z.string().min(1).max(128),
cashSessionId: z.string().uuid(),
terminalId: z.string().uuid(),
items: z.array(z.object({
variantId: z.string().uuid(), productId: z.string().uuid(), sku: z.string(),
ean: z.string().nullable(), name: z.string(),
unitPriceCents: z.number().int().min(0), discountCents: z.number().int().min(0),
taxCents: z.number().int().min(0), quantity: z.number().int().min(1),
})),
payments: z.array(z.object({
kind: z.enum(['cash', 'card', 'other']), amountCents: z.number().int().min(1),
tenderedCents: z.number().int().min(0).optional(), last4: z.string().max(4).optional(),
})),
customerId: z.string().uuid().optional(),
}),
request.body ?? {},
);
const result = await createPosSale.execute({ ...body, userId: user.id });
return reply.code(201).send(result);
});
}