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);
});
}

View File

@@ -0,0 +1,121 @@
import type pg from 'pg';
import type { PosSaleInput, PosSaleResult, PosPaymentResult } from '../domain/pos-sale.js';
import { AppError } from '../../../shared/errors.js';
interface PaymentRow { id: string; amount_cents: number; kind: string; }
interface OrderRow { id: string; idempotency_key: string; total_cents: number; created_at: Date; }
export class CreatePosSaleUseCase {
constructor(private readonly pool: pg.Pool) {}
async execute(input: PosSaleInput): Promise<PosSaleResult> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
// 1. Idempotency: return existing sale if key already used
const existing = await client.query<{ id: string }>(
'SELECT id FROM orders_orders WHERE idempotency_key = $1',
[input.idempotencyKey],
);
if (existing.rows[0]) {
const existId = existing.rows[0].id;
await client.query('ROLLBACK');
const order = await this.pool.query<OrderRow>(
'SELECT id, idempotency_key, total_cents, created_at FROM orders_orders WHERE idempotency_key = $1',
[input.idempotencyKey],
);
const items = await this.pool.query('SELECT * FROM orders_items WHERE order_id = $1', [existId]);
const payments = await this.pool.query<PaymentRow>(
'SELECT id, amount_cents, kind FROM payments_transactions WHERE order_id = $1',
[existId],
);
const totalCents = (order.rows[0]?.total_cents ?? 0) as number;
const mappedItems: PosSaleResult['items'] = items.rows as PosSaleResult['items'];
return {
orderId: existId,
idempotencyKey: input.idempotencyKey,
totalCents,
items: mappedItems,
payments: payments.rows.map(r => ({
id: r.id, kind: r.kind as PosPaymentResult['kind'], amountCents: r.amount_cents,
})),
createdAt: order.rows[0]?.created_at ?? new Date(),
};
}
// 2. Verify cash session is open (FOR UPDATE lock)
const session = await client.query<{ id: string; status: string }>(
`SELECT id, status FROM pos_cash_sessions WHERE id = $1 FOR UPDATE`,
[input.cashSessionId],
);
if (!session.rows[0]) { await client.query('ROLLBACK'); throw new AppError(404, 'SESSION_NOT_FOUND', 'Cash session not found'); }
if (session.rows[0].status !== 'OPEN') { await client.query('ROLLBACK'); throw new AppError(409, 'SESSION_CLOSED', 'Cash session is not open'); }
// 3. Compute totals
const subtotalCents = input.items.reduce((s, i) => s + (i.unitPriceCents - i.discountCents) * i.quantity, 0);
const taxCents = input.items.reduce((s, i) => s + i.taxCents * i.quantity, 0);
const discountCents = input.items.reduce((s, i) => s + i.discountCents * i.quantity, 0);
const totalCents = subtotalCents + taxCents;
const paymentTotal = input.payments.reduce((s, p) => s + p.amountCents, 0);
if (paymentTotal < totalCents) { await client.query('ROLLBACK'); throw new AppError(400, 'INSUFFICIENT_PAYMENT', 'Payments less than total'); }
// 4. Create order
const orderResult = await client.query<OrderRow>(
`INSERT INTO orders_orders (user_id, idempotency_key, subtotal_cents, discount_cents, tax_cents, total_cents)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, idempotency_key, total_cents, created_at`,
[input.userId, input.idempotencyKey, subtotalCents, discountCents, taxCents, totalCents],
);
const orderRow = orderResult.rows[0];
if (!orderRow) { await client.query('ROLLBACK'); throw new AppError(500, 'ORDER_CREATE_FAILED', 'Order insert returned no row'); }
const orderId = orderRow.id;
// 5. Insert line items
for (const item of input.items) {
await client.query(
`INSERT INTO orders_items (order_id, product_id, variant_id, sku, ean, name, unit_price_cents, discount_cents, tax_cents, quantity)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
[orderId, item.productId, item.variantId, item.sku, item.ean, item.name, item.unitPriceCents, item.discountCents, item.taxCents, item.quantity],
);
}
// 6. Process payments + update cash session
const paymentResults: PosPaymentResult[] = [];
let cashPaymentCents = 0;
for (const p of input.payments) {
const kind = p.kind === 'cash' ? 'pos_cash' : p.kind === 'card' ? 'pos_card' : 'pos_other';
const payResult = await client.query<{ id: string }>(
`INSERT INTO payments_transactions (provider, provider_event_id, provider_payment_id, order_id, amount_cents, currency, status, raw)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`,
[kind, `pos-${orderId}-${p.kind}`, `pos-${Date.now()}`, orderId, p.amountCents, 'EUR', 'COMPLETED', JSON.stringify(p)],
);
const payRow = payResult.rows[0];
if (!payRow) { await client.query('ROLLBACK'); throw new AppError(500, 'PAYMENT_INSERT_FAILED', 'Payment insert returned no row'); }
paymentResults.push({ id: payRow.id, kind: p.kind, amountCents: p.amountCents });
if (p.kind === 'cash') cashPaymentCents += p.tenderedCents ?? p.amountCents;
}
if (cashPaymentCents > 0) {
await client.query(
`UPDATE pos_cash_sessions SET expected_cash_cents = COALESCE(expected_cash_cents, 0) + $1, updated_at = now() WHERE id = $2`,
[cashPaymentCents, input.cashSessionId],
);
}
await client.query('COMMIT');
return {
orderId,
idempotencyKey: input.idempotencyKey,
totalCents,
items: input.items,
payments: paymentResults,
createdAt: orderRow.created_at,
};
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
}

View File

@@ -0,0 +1,48 @@
export interface PosSaleLineItem {
variantId: string;
productId: string;
sku: string;
ean: string | null;
name: string;
unitPriceCents: number;
discountCents: number;
taxCents: number;
quantity: number;
}
export type PosPaymentKind = 'cash' | 'card' | 'other';
export interface PosPaymentInput {
kind: PosPaymentKind;
amountCents: number;
/** For cash payments: actual amount given by customer. */
tenderedCents?: number;
/** For card: last 4 digits if known. */
last4?: string;
}
export interface PosSaleInput {
idempotencyKey: string;
cashSessionId: string;
terminalId: string;
userId: string;
items: PosSaleLineItem[];
payments: PosPaymentInput[];
/** Optional customer ID for loyalty. */
customerId?: string;
}
export interface PosSaleResult {
orderId: string;
idempotencyKey: string;
totalCents: number;
items: PosSaleLineItem[];
payments: PosPaymentResult[];
createdAt: Date;
}
export interface PosPaymentResult {
id: string;
kind: PosPaymentKind;
amountCents: number;
}