# 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<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. |

```ts
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). |

```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 <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`.
