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

@@ -7045,13 +7045,15 @@
"description": "POST /api/pos/sales returns HTTP 500 when completing a TPV sale",
"priority": "high",
"risk": "high",
"status": "pending",
"status": "done",
"created_at": "2026-08-22",
"gates": {
"reviewer": false,
"security": false,
"qa": false
}
"reviewer": true,
"security": true,
"qa": true,
"close": true
},
"completed_at": "2026-08-22T17:05:11Z"
},
{
"id": "F-180",
@@ -7082,6 +7084,36 @@
"security": false,
"qa": false
}
},
{
"id": "F-182",
"type": "feature",
"title": "Add touch category navigation and quick products to POS",
"description": "Build large touch buttons to navigate category to subcategory to product, plus four persistent easy-touch quick product slots at bottom",
"priority": "high",
"risk": "med",
"status": "pending",
"created_at": "2026-08-22",
"gates": {
"reviewer": false,
"security": false,
"qa": false
}
},
{
"id": "F-183",
"type": "fix",
"title": "Replace corrupted Almagro font assets and restore storefront HMR",
"description": "Browser OTS rejects almagro woff2 and ttf files; storefront dev chunks return 403 and HMR WebSocket fails on LAN",
"priority": "high",
"risk": "med",
"status": "pending",
"created_at": "2026-08-22",
"gates": {
"reviewer": false,
"security": false,
"qa": false
}
}
]
}

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

View File

@@ -0,0 +1,3 @@
# F-179 — Design
Persist payments as succeeded; map provider back to POS kind on replay. Lock/validate session terminal, persist source=pos with terminal/session/store, and assign order customer only when selected.

View File

@@ -0,0 +1,3 @@
# F-179
TPV sales complete successfully and retries remain idempotent.

View File

@@ -0,0 +1,3 @@
# F-179
Cause: payments_transactions status CHECK rejects `COMPLETED`. POS now writes `succeeded`, validates session-terminal ownership, stores source/terminal/session/store/customer on the order, and correctly maps idempotent replay payment/item rows. Typecheck and POS tests 8/8 pass. Runtime sale and replay both HTTP 201 with the same order `0ea1004d-1fd5-41c6-be7c-c4910c88f481`.

View File

@@ -0,0 +1 @@
{"feature_id":"F-179","agent":"leader","stage":"close","verdict":"APPROVED","checks":[{"item":"all gates/runtime/verify","ok":true}],"issues":[]}

View File

@@ -0,0 +1 @@
{"feature_id":"F-179","agent":"qa","stage":"qa_gate","verdict":"APPROVED","checks":[{"item":"sale HTTP 201","ok":true},{"item":"idempotent replay HTTP 201 same order","ok":true},{"item":"typecheck/tests","ok":true}],"issues":[]}

View File

@@ -0,0 +1 @@
{"feature_id":"F-179","agent":"reviewer","stage":"review_gate","verdict":"APPROVED","checks":[{"item":"payment status schema alignment","ok":true},{"item":"POS order snapshots","ok":true},{"item":"idempotent replay","ok":true}],"issues":[]}

View File

@@ -0,0 +1 @@
{"feature_id":"F-179","agent":"security","stage":"security_gate","verdict":"APPROVED","checks":[{"item":"terminal-session ownership validation","ok":true},{"item":"transaction rollback retained","ok":true}],"issues":[]}

View File

@@ -1,3 +1,3 @@
# F-177Add stale order notifications to admin header
# F-179Fix POS sale Internal Server Error
Create an admin-only stale-order endpoint and header bell. Notify when an order remains in `PENDING` or `SHIPPED` for at least 24 hours since its last state transition. Show badge count, dropdown details, elapsed time and direct links to each order. Refresh automatically and on demand.
Cause: POS writes payment status `COMPLETED`, but database accepts lowercase lifecycle values such as `succeeded`. Repair payment persistence, idempotent replay, and POS order source/session/store/customer snapshots so completed sales are valid and reportable.

View File

@@ -1,68 +1,68 @@
{
"feature_id": "F-177",
"feature_id": "F-179",
"stage": "close",
"agent": "leader",
"action": "Close admin stale-order notifications",
"action": "close",
"state": "running",
"next_agent": "leader",
"waiting_for": "Seleccionar una feature pending y actualizar este estado",
"updated_at": "2026-08-22T16:54:21Z",
"updated_at": "2026-08-22T17:05:11Z",
"timeline": [
{
"ts": "2026-08-22T16:49:42Z",
"ts": "2026-08-22T17:02:10Z",
"agent": "leader",
"stage": "intake",
"state": "running",
"message": "Define stale pending/shipped order notifications"
"message": "Reproduce and diagnose POS sale 500"
},
{
"ts": "2026-08-22T16:50:13Z",
"ts": "2026-08-22T17:03:45Z",
"agent": "architect",
"stage": "design",
"state": "running",
"message": "Design state-age endpoint and admin header bell"
"message": "Align POS sale transaction with current payment and order schema"
},
{
"ts": "2026-08-22T16:50:24Z",
"ts": "2026-08-22T17:03:45Z",
"agent": "implementer",
"stage": "build",
"state": "running",
"message": "Implement stale-order endpoint, state timestamp and header dropdown"
"message": "Repair POS sale transaction"
},
{
"ts": "2026-08-22T16:53:33Z",
"ts": "2026-08-22T17:05:11Z",
"agent": "reviewer",
"stage": "review_gate",
"state": "running",
"message": "Review state-age semantics and header component"
"message": "review"
},
{
"ts": "2026-08-22T16:53:42Z",
"ts": "2026-08-22T17:05:11Z",
"agent": "security",
"stage": "security_gate",
"state": "running",
"message": "Audit notification endpoint RBAC and query bounds"
"message": "security"
},
{
"ts": "2026-08-22T16:53:51Z",
"ts": "2026-08-22T17:05:11Z",
"agent": "qa",
"stage": "qa_gate",
"state": "running",
"message": "Validate 24-hour stale-order notification acceptance"
"message": "qa"
},
{
"ts": "2026-08-22T16:54:10Z",
"ts": "2026-08-22T17:05:11Z",
"agent": "documenter",
"stage": "document",
"state": "running",
"message": "Document stale-order notification behavior"
"message": "document"
},
{
"ts": "2026-08-22T16:54:21Z",
"ts": "2026-08-22T17:05:11Z",
"agent": "leader",
"stage": "close",
"state": "running",
"message": "Close admin stale-order notifications"
"message": "close"
}
]
}