# POS API — Mercado de Vida > **Companion to:** [`POS_ARCHITECTURE.md`](./POS_ARCHITECTURE.md) · [`POS_DATA_MODEL.md`](./POS_DATA_MODEL.md) > **Status:** Discovery (Phase 1) > > **F-186 update:** the implemented mixed-payment/free-item/receipt contract is documented in [`POS_CHECKOUT.md`](./POS_CHECKOUT.md). Where this discovery document still shows the legacy one-payment request, `POS_CHECKOUT.md` takes precedence. This document describes every HTTP endpoint exposed under `/pos/*`. Auth is the same cookie-based session used by the admin app (backoffice session). Roles: `admin`, `pos_manager`, `pos_cashier`. Every request is validated through `parseJson(zodSchema, body)` and every error is `AppError`-shaped. Base URL conventions: - Public storefront admin paths: already `/admin/*`. - POS paths: `/pos/*`. - POS admin (terminal / store CRUD): `/pos/admin/*` (mounted alongside the POS UI's admin functions; not the same as the existing `/admin/*`). --- ## 1. Stores | Method | Path | Roles | Notes | |---|---|---|---| | GET | `/pos/admin/stores` | `admin` | List all stores. | | POST | `/pos/admin/stores` | `admin` | Create store. Body: `{ name, slug, address?, taxId?, contactEmail?, contactPhone?, receiptHeader?, receiptFooter? }`. | | GET | `/pos/admin/stores/:id` | `admin` | Detail. | | PATCH | `/pos/admin/stores/:id` | `admin` | Partial update. | | POST | `/pos/admin/stores/:id/deactivate` | `admin` | Soft delete (sets `active=false`). | ```ts // Response shape interface StoreDto { id: string; name: string; slug: string; address: string | null; taxId: string | null; contactEmail: string | null; contactPhone: string | null; receiptHeader: string | null; receiptFooter: string | null; settings: Record; active: boolean; createdAt: string; updatedAt: string; } ``` --- ## 2. Terminals | Method | Path | Roles | Notes | |---|---|---|---| | GET | `/pos/admin/terminals` | `admin`, `pos_manager` | Filterable by `?storeId=`. | | POST | `/pos/admin/terminals` | `admin` | Body: `{ storeId, name }`. Returns the terminal with a one-time `bindingCode` (6-char alphanumeric, expires in 24 h). | | GET | `/pos/admin/terminals/:id` | `admin`, `pos_manager` | | | PATCH | `/pos/admin/terminals/:id` | `admin`, `pos_manager` | Update name, interface mode, settings JSON. Cannot move to another store (decommission + recreate instead). | | POST | `/pos/admin/terminals/:id/decommission` | `admin` | Status → `decommissioned`. Terminal cannot be used. | **Self-service (no admin role required):** | Method | Path | Notes | |---|---|---| | GET | `/pos/terminals/me` | Returns the terminal resolved from the `pos_terminal_id` cookie. 404 if not bound. | | POST | `/pos/terminals/bind` | Body: `{ bindingCode }`. Sets `pos_terminal_id` cookie (Secure, 1y), updates DB. Used on first launch. | ```ts interface TerminalDto { id: string; storeId: string; name: string; status: 'active' | 'disabled' | 'decommissioned'; interfaceMode: 'desktop' | 'touch' | 'auto'; settings: Record; boundAt: string | null; lastSeenAt: string | null; } ``` --- ## 3. Cash sessions | Method | Path | Roles | Notes | |---|---|---|---| | GET | `/pos/sessions/me` | any pos | Returns the open session for the current terminal, or 204. | | POST | `/pos/sessions` | `pos_cashier`, `pos_manager`, `admin` | Body: `{ terminalId, openingCashCents }`. 409 if a session is already open. | | POST | `/pos/sessions/:id/close` | `pos_cashier`, `pos_manager`, `admin` | Body: `{ closingCashCents, notes? }`. Server computes `expectedCashCents`, sets `differenceCents`. | | GET | `/pos/admin/sessions` | `admin`, `pos_manager` | List sessions, filterable by `?terminalId=&from=&to=`. | | GET | `/pos/admin/sessions/:id` | `admin`, `pos_manager` | Detail with summary (sales count, totals per payment method, refunds). | ```ts interface CashSessionDto { id: string; terminalId: string; storeId: string; userId: string; status: 'OPEN' | 'CLOSED'; openedAt: string; closedAt: string | null; openingCashCents: number; closingCashCents: number | null; expectedCashCents: number | null; actualCashCents: number | null; differenceCents: number | null; notes: string | null; } ``` --- ## 4. Configuration bundle (single round-trip) | Method | Path | Roles | Notes | |---|---|---|---| | GET | `/pos/config` | any pos | Returns everything the POS UI needs to bootstrap. Includes terminal, store, current session (if open), payment methods, active tax rates, top-level categories. | ```ts interface PosConfigDto { terminal: TerminalDto; store: StoreDto; session: CashSessionDto | null; paymentMethods: PaymentMethodDto[]; taxRates: Array<{ appliesTo: 'general'|'reduced'|'super-reduced'; name: string; ratePercent: number }>; categories: Array<{ id: string; name: string; slug: string; emoji: string | null }>; } ``` This is the only endpoint the POS hits on startup. Cached client-side for the session duration; the `updated_at` on store_settings forces a re-fetch when the operator changes store info. --- ## 5. Product search (POS-shaped) The POS re-uses catalog search but asks for what it needs (price + image + tax rate) in one call. | Method | Path | Roles | Notes | |---|---|---|---| | GET | `/pos/products/search?q=&limit=20` | any pos | Delegates to `catalog.search`. Returns up to `limit` results with denormalised price (for the terminal's store). | | GET | `/pos/products/by-ean/:ean` | any pos | Exact lookup. 404 if not found. Used by the scanner. | | GET | `/pos/products/by-sku/:sku` | any pos | Exact lookup. 404 if not found. | | GET | `/pos/products/:variantId` | any pos | Detail. Used when opening a quick-product tile without prior search. | ```ts interface PosProductDto { productId: string; variantId: string; sku: string; ean: string | null; name: string; brandName: string | null; categoryNames: string[]; // flattened path mainImageUrl: string | null; unitPriceCents: number; // net (without VAT) grossPriceCents: number; // VAT-inclusive, displayed vatRate: 'general'|'reduced'|'super-reduced'; vatPercent: number; available: number; // inventory for THIS store } ``` All four endpoints return the same shape. Search results are sorted by relevance (catalog's existing FTS ranking) then alphabetically. **Performance budget:** p95 ≤ 200 ms (cached price + cached category + 1 stock check). The `PosProductCache` module (Phase 1 already has `cache` module) keeps a 5-minute TTL keyed by `(storeId, ean|sku|q)`. Cache is invalidated when a sale completes. --- ## 6. Quick products | Method | Path | Roles | Notes | |---|---|---|---| | GET | `/pos/quick-products` | any pos | Returns the active quick products visible to this terminal: union of `store + terminal + user`. Sorted by `position`. | | POST | `/pos/admin/quick-products` | `admin`, `pos_manager` | Body: `{ scope: 'store'\|'terminal'\|'user', storeId, terminalId?, userId?, variantId, position? }`. | | PATCH | `/pos/admin/quick-products/:id` | `admin`, `pos_manager` | Reorder or toggle active. | | DELETE | `/pos/admin/quick-products/:id` | `admin`, `pos_manager` | | --- ## 7. Sales ### 7.1 Create sale (the critical endpoint) | Method | Path | Roles | Notes | |---|---|---|---| | POST | `/pos/sales` | `pos_cashier`, `pos_manager`, `admin` | Body (see below). Returns the completed order with receipt. | ```ts // Request interface CreatePosSaleRequest { idempotencyKey: string; // UUID v4 generated client-side terminalId: string; // from cookie cashSessionId: string; // from current open session customerId?: string; // optional items: Array<{ variantId: string; quantity: number; // >= 1 discountCents?: number; // optional line discount }>; globalDiscountCents?: number; // applied after lines payments: Array<{ methodCode: string; // 'cash' | 'card' | other configured code amountCents: number; cashTenderedCents?: number; // for 'cash' — what the customer gave providerAuthCode?: string; // for 'card' — operator-entered providerPaymentId?: string; // for 'card' — optional }>; notes?: string; } // Response interface PosSaleResponse { order: OrderDto; receipt: ReceiptDto; cashChangeCents: number | null; // computed for cash payments warnings: string[]; // e.g. 'price changed since cart' } ``` **Behaviour:** - If `idempotencyKey` already exists, return the existing order (200, not 201). - The server re-prices every item from `PricingService.getVariantPrice`; client-sent prices are ignored. - The server checks `InventoryService.checkAvailability` per item. - The server computes `subtotalCents`, `discountCents`, `taxCents`, `totalCents`. - The whole flow runs in a single PG transaction: 1. `INSERT INTO orders_orders ... source='pos', state='PENDING'`. 2. `INSERT INTO orders_items` (one per item). 3. For each item: `InventoryService.reserve(variantId, storeId, qty)`. 4. `UPDATE orders_orders SET state='AWAITING_PAYMENT'`. 5. For each payment: `INSERT INTO payments_transactions`. 6. `UPDATE orders_orders SET state='PAID'`. 7. For each item: `InventoryService.confirm(reservation)`. 8. `UPDATE orders_orders SET state='COMPLETED'`. 9. `AuditLogger.log('pos.sale.created', ...)`. - On any error inside the transaction: `ROLLBACK`. The API returns 4xx/5xx without partial state. - `warnings` is non-empty when a price or stock changed between cart and confirm. **Status codes:** - `201 Created` on first success. - `200 OK` if the `idempotencyKey` already exists (returns the existing order). - `400` if validation fails. - `409 Conflict` if stock is unavailable (`PRICING_STOCK_UNAVAILABLE`) or session is closed (`POS_SESSION_CLOSED`). - `422` if a discount exceeds the configured limit. - `500` for unexpected errors. ### 7.2 List and detail | Method | Path | Roles | Notes | |---|---|---|---| | GET | `/pos/sales?from=&to=&limit=20` | any pos | Scoped to the terminal's store. | | GET | `/pos/sales/:id` | any pos | Detail. Includes items, payments, audit excerpt. | | GET | `/pos/sales/:id/receipt` | any pos | Renders a structured receipt (for re-printing). | ### 7.3 Refunds | Method | Path | Roles | Notes | |---|---|---|---| | POST | `/pos/sales/:id/refund` | `pos_manager`, `admin` | Body: `{ items: Array<{ variantId, quantity }>, reason, payments?: Array<{ methodCode, amountCents }> }`. Negative `payments_transactions` entries are created. State → `PARTIALLY_REFUNDED` (partial) or `REFUNDED` (full). Stock is added back per refunded item via `InventoryService.setAvailable` (creates a new movement with `operation='set_available'`). | ### 7.4 Cancel | Method | Path | Roles | Notes | |---|---|---|---| | POST | `/pos/sales/:id/cancel` | `pos_manager`, `admin` | Only allowed within 60 s of creation and only on `OPEN` cash sessions. Body: `{ reason }`. State → `CANCELLED`. Stock reservations released. | ### 7.5 Reprint | Method | Path | Roles | Notes | |---|---|---|---| | POST | `/pos/sales/:id/print` | any pos | Returns a `ReceiptDto` payload; client dispatches to `PrinterAdapter.print`. Does not affect state. | --- ## 8. Parked tickets | Method | Path | Roles | Notes | |---|---|---|---| | POST | `/pos/parked-tickets` | any pos | Body: `{ cart, label? }`. Returns the parked ticket id. | | GET | `/pos/parked-tickets` | any pos | Lists active parked tickets in the terminal's store. | | GET | `/pos/parked-tickets/:id` | any pos | Detail. | | POST | `/pos/parked-tickets/:id/recall` | any pos | Returns the cart to be loaded into the active cart. Marks `recalledAt`. | | DELETE | `/pos/parked-tickets/:id` | any pos | Discard. | Parked tickets older than 24 hours are auto-expired by a nightly job (Phase 6+). --- ## 9. Receipt payload (for printing) ```ts interface ReceiptDto { receiptNumber: string; // human-readable, store-prefixed store: { name: string; address: string | null; taxId: string | null }; terminal: { id: string; name: string }; cashier: { email: string }; sessionId: string; orderId: string; issuedAt: string; // ISO items: Array<{ name: string; sku: string; ean: string | null; quantity: number; unitGrossCents: number; // with VAT lineDiscountCents: number; lineTotalCents: number; }>; subtotalCents: number; discountCents: number; taxBreakdown: Array<{ vatPercent: number; baseCents: number; taxCents: number }>; totalCents: number; payments: Array<{ methodLabel: string; amountCents: number; tenderedCents: number | null; changeCents: number | null; authCode: string | null; }>; customer: { name: string; email: string; taxId: string | null } | null; receiptHeader: string | null; receiptFooter: string | null; } ``` The client renders this with the `PrinterAdapter` (browser version = `window.print()` on a hidden iframe with print-only CSS). --- ## 10. Discounts — server validation rules `POST /pos/sales` validates discounts server-side: - A line-level discount cannot exceed 50 % of the line's gross subtotal unless the user has `POS_OVERRIDE_PRICE`. - A global discount cannot exceed 20 % of the total. - Negative discounts are not allowed. - Each discount is recorded in the audit log with `actor`, `amountCents`, and (optionally) `reason`. These limits are configurable in `store_settings` (`max_line_discount_percent`, `max_global_discount_percent`). --- ## 11. Connection status The POS app pings `GET /health` every 10 s. The admin panel already exposes this endpoint via the `health` module; no new endpoint is required. Status is rendered as `● Online` / `⚠ Sin conexión` based on the last successful response. --- ## 12. Error envelope All errors share the shape: ```json { "error": { "code": "POS_SALE_STOCK_UNAVAILABLE", "message": "Stock insuficiente para la variante ", "details": [{ "path": "items.0.quantity", "message": "exceeds available" }] } } ``` Client-side, errors are mapped to user-facing Spanish messages via a single lookup table in `apps/pos/src/lib/errors.ts`. --- ## 13. Versioning POS endpoints are unversioned (`/pos/sales` not `/pos/v1/sales`). Breaking changes will be communicated via a `Sunset` header and a 12-month deprecation window. The endpoint set is small enough that we expect < 2 breaking changes in the lifetime of the project. --- ## 14. Rate limits | Endpoint | Limit | |---|---| | `POST /pos/sales` | 60 / min / user | | `POST /pos/sessions` | 6 / hour / terminal (sessions shouldn't open that often) | | `GET /pos/products/search` | 600 / min / user (scanner spam protection) | | All others | standard 600 / min / IP | Enforced via the existing `security.RateLimiter`.