feat(F-179): completed feature

This commit is contained in:
chattie
2026-08-22 19:05:11 +02:00
parent abdb03b6b5
commit 8c8c71d645
11 changed files with 137 additions and 40 deletions

View File

@@ -2,7 +2,18 @@ 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 PaymentRow { id: string; amount_cents: number; provider: string; }
interface OrderItemRow {
variant_id: string;
product_id: string;
sku: string;
ean: string | null;
name: string;
unit_price_cents: number;
discount_cents: number;
tax_cents: number;
quantity: number;
}
interface OrderRow { id: string; idempotency_key: string; total_cents: number; created_at: Date; }
export class CreatePosSaleUseCase {
@@ -25,32 +36,54 @@ export class CreatePosSaleUseCase {
'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 items = await this.pool.query<OrderItemRow>(
`SELECT variant_id, product_id, sku, ean, name, unit_price_cents,
discount_cents, tax_cents, quantity
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',
'SELECT id, amount_cents, provider 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'];
const mappedItems: PosSaleResult['items'] = items.rows.map((row) => ({
variantId: row.variant_id,
productId: row.product_id,
sku: row.sku,
ean: row.ean,
name: row.name,
unitPriceCents: row.unit_price_cents,
discountCents: row.discount_cents,
taxCents: row.tax_cents,
quantity: row.quantity,
}));
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,
payments: payments.rows.map((row) => ({
id: row.id,
kind: providerToPaymentKind(row.provider),
amountCents: row.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`,
const session = await client.query<{ id: string; terminal_id: string; store_id: string; status: string }>(
`SELECT id, terminal_id, store_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'); }
const sessionRow = session.rows[0];
if (!sessionRow) { await client.query('ROLLBACK'); throw new AppError(404, 'SESSION_NOT_FOUND', 'Cash session not found'); }
if (sessionRow.status !== 'OPEN') { await client.query('ROLLBACK'); throw new AppError(409, 'SESSION_CLOSED', 'Cash session is not open'); }
if (sessionRow.terminal_id !== input.terminalId) {
await client.query('ROLLBACK');
throw new AppError(409, 'TERMINAL_SESSION_MISMATCH', 'Cash session belongs to another terminal');
}
// 3. Compute totals
const subtotalCents = input.items.reduce((s, i) => s + (i.unitPriceCents - i.discountCents) * i.quantity, 0);
@@ -62,9 +95,22 @@ export class CreatePosSaleUseCase {
// 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],
`INSERT INTO orders_orders (
user_id, idempotency_key, subtotal_cents, discount_cents, tax_cents, total_cents,
source, terminal_id, cash_session_id, store_id
) VALUES ($1, $2, $3, $4, $5, $6, 'pos', $7, $8, $9)
RETURNING id, idempotency_key, total_cents, created_at`,
[
input.customerId ?? null,
input.idempotencyKey,
subtotalCents,
discountCents,
taxCents,
totalCents,
input.terminalId,
input.cashSessionId,
sessionRow.store_id,
],
);
const orderRow = orderResult.rows[0];
if (!orderRow) { await client.query('ROLLBACK'); throw new AppError(500, 'ORDER_CREATE_FAILED', 'Order insert returned no row'); }
@@ -87,7 +133,7 @@ export class CreatePosSaleUseCase {
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)],
[kind, `pos-${orderId}-${p.kind}`, `pos-${Date.now()}`, orderId, p.amountCents, 'EUR', 'succeeded', 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'); }
@@ -119,3 +165,9 @@ export class CreatePosSaleUseCase {
}
}
}
function providerToPaymentKind(provider: string): PosPaymentResult['kind'] {
if (provider === 'pos_cash') return 'cash';
if (provider === 'pos_card') return 'card';
return 'other';
}