Files
mercadodevida/docs/pos/POS_API.md
2026-08-21 21:24:58 +02:00

14 KiB

POS API — Mercado de Vida

Companion to: POS_ARCHITECTURE.md · POS_DATA_MODEL.md Status: Discovery (Phase 1)

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).
// 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<string, unknown>;
  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.
interface TerminalDto {
  id: string;
  storeId: string;
  name: string;
  status: 'active' | 'disabled' | 'decommissioned';
  interfaceMode: 'desktop' | 'touch' | 'auto';
  settings: Record<string, unknown>;
  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).
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.
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.
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.
// 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)

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:

{
  "error": {
    "code": "POS_SALE_STOCK_UNAVAILABLE",
    "message": "Stock insuficiente para la variante <uuid>",
    "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.