# API de Integración ExtraLatte — v1

> **Estado:** v1.5 — **todo lo documentado acá está en producción.** Esta
> guía describe lo que la API HACE, nunca lo que está en camino: si algo
> aparece documentado, está desplegado y lo puedes llamar hoy. Lo que
> todavía no existe vive en [`/integration/novedades`](/integration/novedades),
> bajo "Lo que todavía no", y el historial de cambios está al final.
>
> Es **OpenCore** (si instalas ExtraLatte, la tienes; sin gate de tier) y
> **multi-tenant** (tu API key solo ve y modifica tu propio negocio).
>
> Siete cosas haces con esta API: **1) mantener tu catálogo en sync**, **2)
> empujar ingredientes y stock**, **3) mandar comandas a la cocina**, **4)
> agregar rondas a una mesa abierta** (ver §6), **5) pedir la precuenta de un
> pedido** (ver §7), **6) registrar el pago y cerrar el pedido** (ver §8), y
> **7) consultar pedidos, mesas y métodos de pago** (ver §9).

- **Base URL (producción):** `https://extralatte.cl/api/v1/integration`
- **Base URL (self-host):** `https://<tu-dominio>/api/v1/integration`
- **Formato:** JSON (`Content-Type: application/json`). Campos y códigos en inglés; los mensajes de error vienen en español.
- **Clave natural:** todo se identifica por tu propio **`sku`** (tu código de producto/ingrediente). ExtraLatte hace *upsert* por `(negocio, sku)` — nunca necesitas manejar UUIDs internos nuestros.
- **Lo nuevo (para el dueño del café):** [`/integration/novedades`](/integration/novedades) — qué cambió, dónde se configura en el panel, y la lista honesta de lo que todavía no funciona.
- **Spec OpenAPI (para generar cliente):** [`https://extralatte.cl/integration/openapi.json`](https://extralatte.cl/integration/openapi.json) — impórtala en tu generador (`openapi-generator`, un GPT Action, Postman, etc.) para tener un cliente tipado en minutos.

---

## 1. Autenticación

Cada negocio genera una o más **API keys** desde **Configuración → API keys** en ExtraLatte (rol Dueño o Gerente). Al crearla se muestra **una sola vez** el secreto completo con este formato:

```
elk_9f3a2b_7c1d…e0
    └key_id┘ └─secret─┘
```

Guárdalo seguro (no se puede volver a ver; si lo pierdes, rotas la key). Envía la key en cada request:

```
Authorization: Bearer elk_9f3a2b_7c1d…e0
```

### Scopes (permisos)

Cada key se crea con uno o más scopes. Un request fuera de scope responde `403 forbidden_scope`.

| Scope | Permite |
|---|---|
| `read` | Leer catálogo, ingredientes y comandas; **listar pedidos, mesas y métodos de pago** (ver §9) |
| `catalog:write` | Crear/actualizar/deshabilitar productos |
| `inventory:write` | Upsert de ingredientes y stock |
| `orders:write` | Crear comandas; **registrar el pago de un pedido y cerrarlo** (ver §8 — toda key con este scope ganó esta capacidad al desplegar, ver Changelog v1.2) |
| `printing:write` | Encolar la impresión de una **precuenta** (ver §7) |

> **Nunca** pongas la API key en el frontend/navegador ni en un repo público. Es
> una credencial de servidor. Si se filtra, revócala y crea otra.

### Verificar la key — `GET /ping`

```bash
curl https://extralatte.cl/api/v1/integration/ping \
  -H "Authorization: Bearer elk_9f3a2b_7c1d…e0"
```

```json
{ "business_id": "b1a2…", "name": "POS Café Jazmín", "scopes": ["catalog:write","inventory:write","orders:write"] }
```

---

## 2. Convenciones

### Formato de error (uniforme)

Todos los errores de esta API tienen la **misma forma**:

```json
{ "error": { "code": "invalid_unit", "message": "La unidad 'kilo' no es válida.", "field": "unit" } }
```

| HTTP | `code` | Cuándo |
|---|---|---|
| 401 | `unauthorized` | Falta/está mal la API key |
| 403 | `forbidden_scope` | La key no tiene el scope requerido |
| 404 | `not_found` | El `sku`/recurso no existe en tu negocio |
| 409 | `idempotency_conflict` | Mismo `Idempotency-Key`, distinto body (o la operación original sigue en curso) |
| 409 | `conflict` | El recurso está en un estado que no permite la operación (p. ej. pedir la precuenta de una cuenta separada que ya está pagada) |
| 422 | `validation_error` | Body inválido (campo faltante/mal tipo/fuera de rango) |
| 422 | `invalid_unit` | Unidad no soportada |
| 429 | `rate_limited` | Demasiados requests (ver `Retry-After`) |

### Idempotencia (evitar duplicados)

En **toda escritura** (`POST`/`PUT`) puedes mandar:

```
Idempotency-Key: <uuid-o-string-único-por-operación>
```

Si reintentas con la **misma** key (p. ej. por un timeout de red), ExtraLatte
devuelve el **mismo** resultado sin volver a ejecutar la operación. Imprescindible
para comandas: reintentar no crea dos pedidos. Si el reintento llega **mientras el
original todavía se está procesando**, recibes `409 idempotency_conflict`
("operación en curso") — espera y reintenta; nunca se ejecuta dos veces.

### Bulk

Los upserts de catálogo e inventario aceptan **un objeto o un array**. La versión
`:batch` procesa muchos y responde el estado por ítem.

### Paginación

Los `GET` de listas usan `?limit=<n>&cursor=<opaco>`; la respuesta trae
`{ "items": [...], "next_cursor": "<opaco|null>" }`. El cursor es **opaco**: no
lo parsees ni lo armes tú. Es cursor y no `offset` a propósito — ver §9, donde
está la razón y el detalle.

**Ojo: el `limit` no se comporta igual en todas las listas, y conviene saberlo
antes de encontrárselo.**

| Lista | `limit` fuera de rango |
|---|---|
| `GET /orders` | **`422`.** Pedir 1000 y recibir 200 sin aviso te haría creer que tienes el día entero |
| `GET /products`, `GET /ingredients` | **Se recorta en silencio** a 500 |

La diferencia es histórica, no un diseño: las dos listas viejas ya recortaban
cuando `GET /orders` no existía, y cambiarlas ahora rompería a quien hoy manda
`limit=1000` y recibe 500 sin enterarse. El `422` es el comportamiento que
queremos —un recorte callado es una respuesta incompleta que parece completa—
así que las dos viejas se van a alinear, avisando antes. Mientras tanto: si
paginas catálogo o inventario, **fíjate en `next_cursor`**, no en cuántos ítems
pediste.

### Rate limits

Por API key (activo en la nube ExtraLatte; en self-host solo si el operador
setea `RATE_LIMIT_ENABLED`). Al excederlos: `429` con header `Retry-After`.

---

## 3. Flujo recomendado (orden de integración)

```
1. Sincroniza el CATÁLOGO    → PUT /products/{sku}              (una vez + en cada cambio)
2. Sincroniza el INVENTARIO  → PUT /ingredients/{sku} + .../stock
3. Manda COMANDAS             → POST /orders                     (en cada venta/pedido)
3b. La mesa pide DE NUEVO      → POST /orders/{order_id}/items     (agrega la ronda al MISMO pedido — ver §6)
4. Pide la PRECUENTA (◑)      → POST /orders/{order_id}/precuenta (antes de cobrar — ver §7)
5. Registra el PAGO            → POST /orders/{order_id}/payment   (al cobrar — ver §8)

En cualquier momento, para saber en qué anda el local (scope `read`, ver §9):
   GET /orders                  (listar y filtrar pedidos)
   GET /tables                  (qué mesa está ocupada y con qué pedido)
   GET /payment-methods         (qué acepta `method` al cobrar)
```

El catálogo va **primero**: las comandas referencian productos por `sku`, así que
el producto debe existir en ExtraLatte antes de venderse. El precio de la comanda
lo pone el **servidor** desde el catálogo sincronizado (no lo mandas en la orden),
por eso mantener el precio en sync es importante.

El ciclo completo de una mesa es catálogo → inventario → comanda → precuenta →
**pago**: la comanda abre el pedido, la precuenta (opcional, paso 4) le muestra a
la mesa cuánto debe, y el pago (paso 5) es lo que cierra el ciclo — descuenta
inventario, acredita lealtad y, con `close_order` (default), libera la mesa y
saca el pedido del tablero de cocina.

---

## 4. Catálogo (sync del menú)

Un "producto" es un ítem vendible de tu carta. Se identifica por tu `sku`.

### `PUT /products/{sku}` — crear o actualizar (upsert)

Crea el producto si el `sku` es nuevo; lo actualiza si ya existe (incluye
**cambios de precio**). La categoría se resuelve por **nombre** (se crea si no
existe).

**Campos del body**

| Campo | Tipo | Req | Descripción |
|---|---|---|---|
| `name` | string | ✔ | Nombre visible en la carta |
| `price` | number | ✔ | Precio de venta (moneda del negocio, sin decimales para CLP) |
| `category` | string | ✔ | Nombre de la categoría (se crea si no existe) |
| `description` | string | | Descripción opcional |
| `active` | boolean | | `true` por defecto; `false` la oculta de la carta |

```bash
curl -X PUT https://extralatte.cl/api/v1/integration/products/CAP-001 \
  -H "Authorization: Bearer elk_…" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 6f1c-CAP-001-v3" \
  -d '{ "name": "Capuchino", "price": 3500, "category": "Cafés", "active": true }'
```

**Respuesta `200 OK`**

```json
{ "sku": "CAP-001", "name": "Capuchino", "price": 3500, "category": "Cafés", "active": true, "created": false }
```

`created: true` si se creó, `false` si se actualizó.

### `POST /products:batch` — upsert masivo

```bash
curl -X POST https://extralatte.cl/api/v1/integration/products:batch \
  -H "Authorization: Bearer elk_…" -H "Content-Type: application/json" \
  -d '{ "items": [
        { "sku": "CAP-001", "name": "Capuchino", "price": 3500, "category": "Cafés" },
        { "sku": "MED-001", "name": "Medialuna",  "price": 1200, "category": "Panadería" }
      ] }'
```

```json
{ "results": [
    { "sku": "CAP-001", "status": "updated" },
    { "sku": "MED-001", "status": "created" }
] }
```

### `DELETE /products/{sku}` — deshabilitar

No borra histórico; hace `active=false` (lo saca de la carta). Para reactivarlo,
`PUT` con `active: true`.

```bash
curl -X DELETE https://extralatte.cl/api/v1/integration/products/CAP-001 \
  -H "Authorization: Bearer elk_…"
```

```json
{ "sku": "CAP-001", "active": false }
```

### `GET /products` — listar

```bash
curl "https://extralatte.cl/api/v1/integration/products?limit=100" \
  -H "Authorization: Bearer elk_…"
```

```json
{ "items": [ { "sku": "CAP-001", "name": "Capuchino", "price": 3500, "category": "Cafés", "active": true } ], "next_cursor": null }
```

### Agregados con precio — `option_groups` dentro del producto

Un agregado ("Cappuccino **+ leche de almendra $800**") **no es una línea
aparte ni un texto en `notes`**: viaja dentro del producto al que pertenece.
Por eso no tiene endpoints propios — un grupo de opciones no puede existir sin
su producto, así que se sincroniza en el mismo `PUT /products/{sku}`.

**Campos de un grupo** (`option_groups[]`)

| Campo | Tipo | Req | Descripción |
|---|---|---|---|
| `name` | string | ✔ | Nombre del grupo, p. ej. "Leche". **Es la clave**: en la próxima sync, el mismo nombre actualiza el mismo grupo |
| `min_selected` | int | | Mínimo de opciones a elegir (default `0`) |
| `max_selected` | int | | Máximo de opciones a elegir (default `1`) |
| `is_required` | boolean | | `true` obliga a elegir; requiere `min_selected >= 1` |
| `options` | array | | Los agregados del grupo |

**Campos de una opción** (`option_groups[].options[]`)

| Campo | Tipo | Req | Descripción |
|---|---|---|---|
| `sku` | string | ✔ | **Tu** clave para el agregado, única en tu negocio. Es como lo vas a nombrar al mandar el pedido |
| `name` | string | ✔ | Nombre visible, p. ej. "Leche de almendra" |
| `price` | number | | Recargo que se suma al precio del producto (default `0`, no puede ser negativo) |
| `sort_order` | int | | Orden de aparición (default `0`) |

```bash
curl -X PUT https://extralatte.cl/api/v1/integration/products/CAP-001 \
  -H "Authorization: Bearer elk_…" -H "Content-Type: application/json" \
  -d '{
        "name": "Cappuccino", "price": 3500, "category": "Cafés",
        "option_groups": [
          { "name": "Leche", "min_selected": 1, "max_selected": 1, "is_required": true,
            "options": [
              { "sku": "LECHE-ENT", "name": "Leche entera",      "price": 0 },
              { "sku": "LECHE-ALM", "name": "Leche de almendra", "price": 800 }
            ] }
        ]
      }'
```

La respuesta repite `option_groups` tal como quedó. **Un producto sin
agregados no trae la llave `option_groups`**: si nunca mandaste agregados, la
respuesta es exactamente la misma de siempre.

**Reglas de sincronización**

- **Si omites `option_groups`, no se toca nada.** Un sync de precio no borra
  tus agregados. Esto es lo que hace que una integración escrita antes de esta
  versión siga funcionando igual.
- **Si lo mandas, es un upsert**: los grupos se emparejan por `name` dentro del
  producto y las opciones por tu `sku`. Lo que no nombres queda como está —
  nada se borra solo.
- **Un `sku` de agregado pertenece a un solo producto.** Reusarlo en otro
  producto es `422`, no un traslado silencioso.
- `422` también ante: `max_selected < min_selected`, `max_selected < 1`,
  `min_selected < 0`, `is_required` con `min_selected: 0`, grupo sin nombre,
  `sku` vacío o repetido en el mismo envío, y `price` negativo.

### `DELETE /products/{sku}/options/{option_sku}` — sacar un agregado

Un agregado no tiene bandera de "activo", así que esto lo elimina de verdad —
pero solo mientras todavía se pueda: si ese agregado ya se vendió, la respuesta
es `409` (`conflict`) y el agregado se queda, porque borrarlo se llevaría un
pedazo de esa venta.

```json
{ "sku": "CAP-001", "option_sku": "LECHE-ALM", "deleted": true }
```

---

## 5. Inventario / Ingredientes

Un "ingrediente" es un insumo (materia prima). Se identifica por su `sku`. El
stock vive por **ubicación** (bodega/cocina/barra).

### `PUT /ingredients/{sku}` — crear o actualizar (upsert)

| Campo | Tipo | Req | Descripción |
|---|---|---|---|
| `name` | string | ✔ | Nombre del insumo |
| `unit` | string | ✔ | Unidad base — una de: `kg,g,l,ml,unit` |
| `cost` | number | | Costo por unidad base (para cost-plus) |
| `category` | string | | Categoría libre (p. ej. "Materia Prima") |

```bash
curl -X PUT https://extralatte.cl/api/v1/integration/ingredients/LECHE \
  -H "Authorization: Bearer elk_…" -H "Content-Type: application/json" \
  -d '{ "name": "Leche entera", "unit": "l", "cost": 950 }'
```

```json
{ "sku": "LECHE", "name": "Leche entera", "unit": "l", "cost": 950, "created": true }
```

> ⚠️ **Unidades:** la unidad base y las cantidades deben ser coherentes. Si el
> insumo es `kg` y una receta lo consume en gramos, va a sobre-consumir. Elige
> una unidad base y mantén todo en esa escala.

### `POST /ingredients:batch` — upsert masivo

Igual que `products:batch`, con array de ingredientes.

### `PUT /ingredients/{sku}/stock` — fijar stock (absoluto)

Establece el stock **exacto** (no delta) del insumo en una ubicación. Es un
upsert por `(sku, ubicación)`: reenviar re-sincroniza sin duplicar.

| Campo | Tipo | Req | Descripción |
|---|---|---|---|
| `location` | string | ✔ | Nombre de la ubicación de stock (bodega/cocina). Se crea si no existe |
| `quantity` | number | ✔ | Stock actual (en la unidad base del insumo) |
| `min_quantity` | number | | Umbral de alerta de stock bajo |

```bash
curl -X PUT https://extralatte.cl/api/v1/integration/ingredients/LECHE/stock \
  -H "Authorization: Bearer elk_…" -H "Content-Type: application/json" \
  -d '{ "location": "Bodega", "quantity": 48, "min_quantity": 10 }'
```

```json
{ "sku": "LECHE", "location": "Bodega", "quantity": 48, "min_quantity": 10 }
```

### `POST /ingredients/{sku}/stock/adjust` — ajustar stock (delta)

Suma/resta al stock actual y deja el movimiento registrado. Úsalo para entradas
(recepción de mercadería) o correcciones.

| Campo | Tipo | Req | Descripción |
|---|---|---|---|
| `location` | string | ✔ | Ubicación de stock |
| `delta` | number | ✔ | Cantidad a sumar (+) o restar (−) |
| `reason` | string | | Motivo ("recepción", "merma", "conteo") |

```bash
curl -X POST https://extralatte.cl/api/v1/integration/ingredients/LECHE/stock/adjust \
  -H "Authorization: Bearer elk_…" -H "Content-Type: application/json" \
  -d '{ "location": "Bodega", "delta": 12, "reason": "recepción" }'
```

```json
{ "sku": "LECHE", "location": "Bodega", "quantity": 60 }
```

> **Tu sistema es la fuente de verdad del stock.** Las comandas enviadas por
> esta API **no** descuentan inventario automáticamente (para no chocar con el
> descuento que ya hizo tu POS). Sincroniza los niveles con `PUT .../stock` o
> `POST .../stock/adjust`. (El auto-descuento por receta/BOM aplica solo a las
> órdenes creadas *dentro* de ExtraLatte — POS/QR —, no a las comandas de la API.)

---

## 6. Comandas (mandar pedidos a la cocina)

Crea un pedido y lo manda a la cocina (KDS) en **una sola llamada**. Los ítems se
refieren por `sku`; el **precio lo pone el servidor** desde tu catálogo
sincronizado (no lo mandas tú).

### `POST /orders`

| Campo | Tipo | Req | Descripción |
|---|---|---|---|
| `service_type` | string | ✔ | `dine_in` (mesa) \| `takeaway` \| `delivery` |
| `table` | string | ◑ | Nombre/número de mesa. **Requerido si `dine_in`** |
| `items` | array | ✔ | Líneas del pedido (ver abajo) |
| `items[].sku` | string | ✔ | SKU del producto (debe existir en el catálogo) |
| `items[].quantity` | int | ✔ | Cantidad (≥ 1) |
| `items[].notes` | string | | Nota para cocina ("sin azúcar") |
| `items[].modifiers` | array | | **Agregados con precio** de esa línea, por el `sku` de la opción (§4). El precio sale del catálogo, nunca de tu llamada |
| `customer_name` | string | | Nombre del cliente (para el ticket) |
| `mark_paid` | boolean | | `true` si el pedido ya se pagó en tu sistema (marca el pedido como pagado) |

```bash
curl -X POST https://extralatte.cl/api/v1/integration/orders \
  -H "Authorization: Bearer elk_…" -H "Content-Type: application/json" \
  -H "Idempotency-Key: ticket-2026-07-28-000451" \
  -d '{
        "service_type": "dine_in",
        "table": "5",
        "customer_name": "Ana",
        "items": [
          { "sku": "CAP-001", "quantity": 2, "notes": "uno descafeinado",
            "modifiers": ["ADD-ALM"] },
          { "sku": "MED-001", "quantity": 3 }
        ]
      }'
```

**Respuesta `201 Created`**

```json
{
  "order_id": "a1b2…",
  "comanda_number": 12,
  "status": "in_kitchen",
  "service_type": "dine_in",
  "table": "5",
  "total": 12200,
  "currency": "CLP",
  "items": [
    { "sku": "CAP-001", "name": "Capuchino", "quantity": 2, "unit_price": 3500, "line_total": 8600,
      "modifiers": [{ "name": "Leche de almendra", "price": 800 }] },
    { "sku": "MED-001", "name": "Medialuna", "quantity": 3, "unit_price": 1200, "line_total": 3600, "modifiers": [] }
  ]
}
```

- `comanda_number` es el número diario legible (reinicia cada día por local).
- El pedido aparece en la pantalla de cocina (KDS) y se encola su comanda de
  impresión al instante.
- Si un `sku` no existe: `404 not_found` con `field: "items[0].sku"` — sincroniza
  el catálogo primero.

**Cómo se cobran los agregados.** `unit_price` sigue siendo el precio **base**
del producto: el agregado nunca se suma adentro. Lo que sí lo incluye es
`line_total`, con la fórmula **`(base + Σ agregados) × cantidad`** — en el
ejemplo, `(3500 + 800) × 2 = 8600`. Ese es el número que llega al `total` del
pedido, a la precuenta y al pago, y el agregado se imprime como una línea
`+ Leche de almendra` bajo el producto en la comanda y en la cuenta. El precio
del agregado queda **congelado en la venta**: si mañana subes la opción a $2000,
la venta de hoy sigue diciendo $800 (igual que el precio del producto).

- El agregado tiene que pertenecer a **ese** producto: una opción de otro
  producto (o de otro café) es `422 validation_error` con
  `field: "items[0].modifiers[0]"`.
- `min_selected` / `max_selected` / `is_required` del grupo se validan acá
  también: si el grupo es obligatorio y no mandas ninguna opción, es `422` con
  `field: "items[0].modifiers"`. Tu pantalla puede validar lo mismo, pero la
  que decide es esta.

**La llave `modifiers` está SIEMPRE presente en el eco de cada línea de
`POST /orders`**, aunque esa línea no tenga agregados — en ese caso es un
array vacío (`"modifiers": []`, como `MED-001` en el ejemplo de arriba),
nunca la ausencia de la llave. Si tu parser es estricto, no te va a
sorprender: no necesitas manejar "a veces no viene". Y ese eco **lee lo que
quedó persistido**, no lo recalcula — es la misma plata que después vas a
ver en la precuenta y en el pago.

> **Nota de `GET /orders/{order_id}` (más abajo en esta sección):** hoy esa consulta **no**
> repite `modifiers` por línea — agrupa por producto (`sku`) nomás, así que
> dos unidades del mismo producto con agregados distintos (p. ej. un
> capuchino solo y uno con leche de almendra) aparecen como **una sola
> línea combinada**: la cantidad y el `line_total` suman bien (la plata
> cuadra), pero no vas a ver ahí cuál agregado corresponde a cuál unidad.
> El desglose de agregados hoy solo existe en el eco de `POST /orders`.

### `POST /orders/{order_id}/items` — agregar una ronda a un pedido abierto

La mesa pidió de nuevo. Con esto la mesa sigue siendo **un pedido y una
cuenta**: agregas las líneas nuevas al pedido que ya existe y la cocina recibe
**solo lo nuevo**, con el número de ronda en el encabezado
(`Mesa 5 · ronda 2`). No necesitas abrir un pedido por ronda — si lo hacías,
era porque esta llamada no existía, y cada pedido nuevo era, de nuestro lado,
otra cuenta con su propia precuenta y su propio cobro.

Scope: `orders:write` (el mismo de `POST /orders`, sin scope nuevo).

| Campo | Tipo | Req | Descripción |
|---|---|---|---|
| `items` | array | ✔ | Las líneas que agregas. **Misma forma exacta que en `POST /orders`**: `sku`, `quantity`, `notes`, `modifiers` |
| `print_full_order` | boolean | | `false` (default): la comanda trae **solo las líneas nuevas**. `true`: imprime el pedido **completo**, marcando cada línea nueva con `NUEVO` — para el local que prefiere que el cocinero vea la mesa entera |

```bash
curl -X POST https://extralatte.cl/api/v1/integration/orders/a1b2…/items \
  -H "Authorization: Bearer elk_…" -H "Content-Type: application/json" \
  -H "Idempotency-Key: ticket-2026-08-18-000451-r2" \
  -d '{
        "items": [
          { "sku": "CAP-001", "quantity": 1, "modifiers": ["ADD-ALM"] }
        ]
      }'
```

**Respuesta `201 Created`**

```json
{
  "order_id": "a1b2…",
  "comanda_number": 12,
  "round": 2,
  "status": "in_kitchen",
  "total": 16500,
  "currency": "CLP",
  "comanda_printed": true,
  "printed_full_order": false,
  "items": [
    { "sku": "CAP-001", "name": "Capuchino", "quantity": 1, "unit_price": 3500, "line_total": 4300,
      "modifiers": [{ "name": "Leche de almendra", "price": 800 }] }
  ]
}
```

- `round` es la ronda que acabas de mandar: `2` es la segunda vez que esa mesa
  dispara cocina.
- `items` son **solo las líneas de esta llamada**. El pedido completo lo lees
  con `GET /orders/{order_id}`.
- `total` es el del **pedido entero, todas las rondas incluidas** — eso es
  exactamente lo que ganas al agregar en vez de abrir otro pedido: una sola
  precuenta y un solo cobro por la mesa.
- El pedido vuelve a `in_kitchen` aunque estuviera `ready`: hay comida nueva
  que hacer.

**Qué sale por la impresora.** Solo lo nuevo. La comanda de la ronda 2 no
repite la ronda 1, y la ronda 3 no repite la 2: cada línea queda marcada como
despachada cuando entra en una comanda, y eso es lo que la ronda siguiente
excluye. Si prefieres el contexto completo, `print_full_order: true` imprime
todo el pedido con las líneas nuevas marcadas `NUEVO`.

**Cuándo NO se puede agregar (`409 conflict`)**

| Situación | Mensaje |
|---|---|
| El pedido ya está pagado | `El pedido ya está pagado. Abre uno nuevo.` |
| El pedido ya está cerrado | `El pedido ya está cerrado. Abre uno nuevo.` |
| El pedido ya tiene pagos registrados (cuenta separada cobrada) | `El pedido ya tiene pagos registrados. Abre uno nuevo.` |

La razón es la misma en los tres casos: alguien ya pagó contra un total que
esta línea movería. Un pedido anulado responde `422` (`El pedido está
anulado.`), y un `sku` inexistente o un agregado ilegal responden igual que en
`POST /orders` (`404` / `422` con el `field` apuntando a la línea).

**Idempotencia — lee esto antes de reintentar.** Dos capas, y la primera es la
que importa:

1. **`Idempotency-Key`**: reintentar con la misma key devuelve la respuesta
   original, sin agregar nada. **Es la única protección contra duplicar
   líneas.**
2. El `event_key` de la comanda de la ronda
   (`ronda:<pedido>:r<n>:<estación>:comanda_sector`) garantiza que **una misma
   ronda nunca se imprime dos veces** ni pisa el papel de la anterior.

> **Sin `Idempotency-Key`, llamar dos veces agrega las líneas dos veces — a
> propósito.** Agregar ítems no es una operación naturalmente idempotente: una
> mesa puede pedir el mismo café dos veces en dos minutos, y adivinar que el
> segundo es un reintento sería inventarle una intención al garzón. Manda la
> key.

### `GET /orders/{order_id}` — estado del pedido

```bash
curl https://extralatte.cl/api/v1/integration/orders/a1b2… \
  -H "Authorization: Bearer elk_…"
```

```json
{
  "order_id": "a1b2…", "comanda_number": 12, "status": "ready",
  "payment_status": "unpaid", "service_type": "dine_in",
  "total": 10600, "currency": "CLP",
  "items": [
    { "sku": "CAP-001", "name": "Capuchino", "quantity": 2, "unit_price": 3500, "line_total": 7000 }
  ]
}
```

Estados: `new` → `in_kitchen` → `ready` → `delivered`.

---

## 7. Precuenta (imprimir la cuenta antes de cobrar)

Una **precuenta** es el ticket de "cuánto se debe" que se le muestra a la
mesa **antes** de cobrar — no es una boleta ni una factura. Sirve para pedir
la cuenta desde tu propio sistema (una app de mesero, un totem, etc.) sin
pasar por la caja de ExtraLatte.

> **Requisito operativo, antes de usar esto:** el local necesita un
> **conector de impresión** (`cafe-printerd`) pareado y con su **impresora de
> tickets de cliente** configurada — hoy eso se hace desde **Configuración →
> Impresoras** en el panel del negocio (config-sync, ver
> [`docs/printer_daemon_admin.md`](printer_daemon_admin.md)), sin tener que
> tocar la PC del local. Esto requiere el conector **v0.3.0 o superior**. Un
> conector **más viejo** igual imprime la precuenta (el campo `is_precuenta`
> es aditivo y un daemon anterior lo ignora), pero con el **formato legacy de
> boleta**: sin encabezado "PRECUENTA" y sin la leyenda "Este documento no es
> boleta ni factura." — actualiza el conector antes de depender de esto.

### `POST /orders/{order_id}/precuenta`

Requiere el scope **`printing:write`**. Encola un trabajo de impresión en la
impresora de tickets de cliente del local; no imprime de forma síncrona (la
respuesta es `202`, no `200`).

**El servidor es dueño de todos los montos.** No mandas contenido ni
precios — solo pides la impresión y, como mucho, porcentajes. El total, el
desglose y los montos de propina/descuento salen del pedido ya sincronizado
en ExtraLatte.

**Campos del body** (todos opcionales — un POST vacío imprime la precuenta
del pedido completo, sin propina sugerida ni descuento)

| Campo | Tipo | Rango | Descripción |
|---|---|---|---|
| `split_number` | int | ≥ 1, ≤ cuentas separadas del pedido | Ausente = precuenta del pedido completo. Presente = la cuenta de esa persona (ver "Cuentas separadas" abajo) |
| `tip_suggested_pct` | number | 0–30 | Imprime "Propina sugerida (N%)" + el monto ya calculado + "La propina es voluntaria." Fuera de rango → `422` |
| `discount_pct` | number | 0–100 | Descuento aplicado sobre lo que esa cuenta efectivamente debe (no sobre el precio de carta bruto). Fuera de rango → `422` |
| `note` | string | ≤ 120 caracteres, **sin caracteres de control** | Línea libre impresa en el ticket (p. ej. "Feliz cumpleaños"). Un caracter de control (byte < 0x20 o 0x7F) es **`422`**, no se limpia en silencio: esos bytes son comandos ESC/POS (corte de papel, apertura de cajón, reset de la impresora) para una impresora térmica, no texto, y un llamador externo con `printing:write` no debe poder inyectarlos |

```bash
curl -X POST https://extralatte.cl/api/v1/integration/orders/a1b2c3d4-.../precuenta \
  -H "Authorization: Bearer elk_…" -H "Content-Type: application/json" \
  -H "Idempotency-Key: precuenta-mesa5-20260812-01" \
  -d '{ "split_number": 2, "tip_suggested_pct": 10, "note": "Feliz cumpleaños" }'
```

**Respuesta `202 Accepted`**

```json
{ "print_job_id": "c9f1a2b3-...", "status": "queued", "deduplicated": false }
```

### Idempotencia — dos capas, y una que no vas a esperar

1. **`Idempotency-Key`** (igual que el resto de la API): reintentar con la
   misma key devuelve la respuesta original tal cual, sin encolar de nuevo.
2. **La que no vas a esperar**: pedir la precuenta de **el mismo `order_id` +
   el mismo `split_number` (o ninguno, para el pedido completo) una segunda
   vez SIEMPRE devuelve el mismo `print_job_id` con `"deduplicated": true`**
   — aunque no mandes `Idempotency-Key`, o mandes una distinta cada vez. Esta
   garantía no vive en el header: vive en un índice **único** de la base de
   datos sobre `(negocio, pedido, cuenta)`, así que ni siquiera dos requests
   simultáneos pueden colar dos trabajos.

**No hay reimpresión por esta API, y es a propósito.** El dedupe de arriba
hace que pedir "otra vez" la misma precuenta sea, por diseño, un no-op — no
existe un parámetro ni una variante del endpoint que fuerce un segundo
trabajo. La decisión del dueño del producto fue explícita: sin esa barrera,
un bug de reintentos en tu integración podría convertirse en mil impresiones
en el mostrador. Si la mesa necesita un segundo papel (se manchó, se lo
llevó el viento), el camino es humano: el botón **"Reimprimir"** en
`/pos/print-queue`, desde el propio ExtraLatte.

### Cuentas separadas

- **`split_number` ausente** → la precuenta es del pedido completo.
- **`split_number` presente** → la precuenta es solo de esa persona: sus
  ítems, su subtotal y su descuento prorrateado (calculados desde la misma
  lógica que usa la pantalla de caja para dividir la cuenta). Debe estar
  entre `1` y la cantidad de cuentas en las que se dividió el pedido.
- Pedir la precuenta de una cuenta separada **que ya está pagada** responde
  `409 conflict` — no es un error silencioso, es tu integración pidiendo el
  "cuánto debe" de algo que ya se cobró.

### Errores propios de este endpoint

| HTTP | `code` | Cuándo |
|---|---|---|
| 403 | `forbidden_scope` | La key no tiene el scope `printing:write` |
| 404 | `not_found` | El pedido no existe **o pertenece a otro negocio** — misma respuesta para ambos casos, a propósito: esta API nunca confirma la existencia de un recurso ajeno |
| 409 | `conflict` | La cuenta separada pedida ya está pagada |
| 409 | `idempotency_conflict` | Mismo `Idempotency-Key`, distinto body (o la operación original sigue en curso) |
| 422 | `validation_error` | `tip_suggested_pct` fuera de 0–30, `discount_pct` fuera de 0–100, un número no finito (`NaN`/`Infinity`), `note` con más de 120 caracteres o con caracteres de control, `split_number` fuera de rango, o el pedido está anulado |

---

## 8. Pago (registrar el cobro y cerrar el pedido)

Le informas a ExtraLatte que un pedido **ya se cobró en tu caja** y, en el
mismo llamado, lo cierras. Es lo que le faltaba a una mesa que se paga **al
final**: hasta hoy `POST /orders` solo aceptaba `mark_paid` **al crear**
(sirve para pagar-primero — para llevar, delivery), y no existía ningún
endpoint para informar el pago después. Sin este endpoint, un pedido de mesa
se quedaba `unpaid` para siempre y nunca salía del tablero de cocina.

**Antes de leer el resto, una aclaración de marco importante**: tu sistema
**no** tiene su propia impresión — **nosotros somos tu infraestructura de
impresión**. Las comandas, la precuenta y el ruteo a estaciones salen por
ExtraLatte a través de tu conector (`cafe-printerd`). Lo **único** que hoy
emites tú es la **boleta**, y solo porque ExtraLatte todavía no emite DTE
(la landing lo dice sin vueltas: "Por ahora lee tus facturas SII… La
emisión de boletas y facturas está en nuestra hoja de ruta"). Por eso, al
registrar un pago, el documento que importa es la boleta que **tú**
emites — y por defecto **ExtraLatte no imprime nada de su lado**: ver
`print_receipt` más abajo.

Requiere el scope **`orders:write`** — el mismo que ya usas para crear
comandas (ver la nota de la tabla de scopes en §1: si tu key ya lo tenía,
ganó esta capacidad al desplegar).

### `POST /orders/{order_id}/payment`

**Campos del body**

| Campo | Tipo | Req | Descripción |
|---|---|---|---|
| `method` | string | ✔ | El método de pago **por nombre o por tipo** — nunca un UUID nuestro. Ver "Resolución de `method`" abajo |
| `amount` | number | | Ausente = el saldo pendiente completo (el mismo número que imprime la precuenta). Presente = debe **calzar exacto** con lo pendiente — ver "Por qué `amount` es un guardia" abajo |
| `tip` | number | | Propina efectivamente dejada (no la sugerida de la precuenta). Default `0` |
| `split_number` | int | | Cobra solo esa cuenta separada (1..N). Ver "Cuentas separadas" abajo |
| `card_token` | string | | Token de tarjeta de lealtad para acreditar sellos/puntos en el mismo cobro. Ver "`card_token` es fail-soft" abajo |
| `close_order` | boolean | | Default `true`. Ver "Semántica de cierre" abajo |
| `print_receipt` | boolean | | Default `false`. Ver "Por qué no imprimimos por defecto" abajo |

```bash
curl -X POST https://extralatte.cl/api/v1/integration/orders/a1b2c3d4-.../payment \
  -H "Authorization: Bearer elk_…" -H "Content-Type: application/json" \
  -H "Idempotency-Key: pago-mesa5-20260813-01" \
  -d '{ "method": "efectivo", "tip": 1500 }'
```

**Respuesta `200 OK`**

```json
{
  "order_id": "a1b2c3d4-...",
  "payment_id": "9f0e1d2c-...",
  "payment_status": "paid",
  "closed": true,
  "total_paid": 15500,
  "tip": 1500
}
```

Si mandaste `card_token`, la respuesta suma dos campos:

```json
{ "..." : "...", "reward_card_credited": true }
```

o, si el token no pudo acreditarse (ver "`card_token` es fail-soft"):

```json
{ "..." : "...", "reward_card_credited": false, "reward_card_error": "..." }
```

### Resolución de `method` — por nombre o tipo, nunca por UUID

Igual que el resto de esta API: nunca necesitas un id interno nuestro.
`method` se resuelve contra los métodos de pago de **tu propio negocio**,
sin distinguir mayúsculas ni acentos (`"Débito"` == `"debito"` ==
`"DEBITO"`), primero por **nombre** ("Efectivo", "Débito") y, si no matchea
ningún nombre, por **tipo** (`cash`/`card`/`transfer`/…). El nombre gana
sobre el tipo porque es el desempate real: un café con "Crédito" **y**
"Débito" tiene dos métodos `card`, así que `"card"` a secas es ambiguo. Ante
la ambigüedad, la API responde `422` listando las opciones — nunca elige
por ti, porque adivinar mal archivaría la plata bajo el método equivocado y
te corrompería el reporte en silencio.

### Por qué `amount` es un guardia, no un adorno

`amount` es opcional; si lo omites, se cobra el saldo pendiente completo. Si
lo mandas, **debe calzar exacto** con lo pendiente o la API responde `422`.
Esto no es un capricho: el servicio de pagos de ExtraLatte **acepta
sobrepago** (solo rechaza `amount <= 0`), así que esta comparación es la
**única** barrera contra registrar más plata de la que realmente se cobró
— o menos. Esta API no acepta pagos parciales; existen en el POS de mostrador
y están fuera de alcance a propósito.

### `print_receipt` — por qué NO imprimimos nada de nuestro lado por defecto

`print_receipt` es `false` por defecto. Esto suprime **solo dos cosas**: la
boleta térmica (`ticket_cliente`) y el aviso a la pantalla de cliente. Todo
lo demás del cobro ocurre igual (inventario, cupones, lealtad, venta en el
cajón si corresponde).

La razón, dicho sin vueltas por el marco de arriba: como **nosotros somos tu
infraestructura de impresión**, un `print_receipt: true` sacaría un segundo
papel no fiscal (`ticket_cliente`) al lado de la boleta que **tú** ya
emitiste — dos comprobantes para el mismo cobro. Por eso el default es
**no imprimir nada de nuestro lado cuando registras un pago** — no es una
falla ni un silencio accidental, es la decisión de diseño. Si tu integración
necesita igual el ticket de ExtraLatte (por ejemplo, no emites boleta propia
para ese canal), manda `print_receipt: true`.

**Excepción dura: `print_receipt: true` junto con `split_number` responde
`422`**, no se ignora en silencio. El camino de cuentas separadas
(`pay_split`) no encola ningún trabajo de impresión — nunca lo hizo y no lo
hace ahora —, así que honrar `true` ahí sería prometer un papel que jamás
sale de la impresora. Preferimos rechazar la combinación a mentir. Si
necesitas el papel de una cuenta separada, pídelo por separado con la
**precuenta** (§7, `split_number`), que sí es un documento por-persona.

Técnicamente esto usa un parámetro **nuevo** en el servicio
(`suppress_physical_outputs`), NO el flag `backdated` que ya existía. Son
dos conceptos distintos que comparten dos de tres efectos: `backdated`
además decide si la venta postea al cajón según la fecha del pedido (una
venta de ayer no debe caer en la caja de hoy), y esta API nunca quiso tocar
esa regla — el cobro de un pedido abierto desde ayer sigue posteando al
cajón con normalidad.

### Semántica de cierre — `close_order` (default `true`)

**Pago no es entrega.** `close_order: true` estampa `closed_at`, que es lo
único que las dos consultas del tablero de cocina, la de expedición y la
pantalla de cliente filtran además del estado — así que cerrar el pedido
**drena el tablero**. En ningún momento se escribe
`preparation_status = "delivered"`: nadie de este lado vio la comida llegar
a la mesa, y la API no afirma algo que no observó.

`close_order` rechaza pedidos no pagados, así que el cierre solo aplica
**tras un pago completo** — consistente con que esta API no acepta pagos
parciales.

### `card_token` es fail-soft

Si mandas `card_token`, ExtraLatte intenta acreditar el sello/punto de
lealtad **antes** de registrar el pago. Si el token no existe, la tarjeta
está inactiva, ya está adherida a otro pedido, o el pedido ya tiene algún
pago — el crédito de lealtad **no se acredita**, pero **el pago se registra
igual**. La respuesta te lo dice explícitamente (`reward_card_credited:
false` + `reward_card_error`). La alternativa — dejar que un token mal
tipeado aborte el cobro entero — convertiría "no se acreditó el sello" en
"no se registró el pago", que es mucho peor.

### Cuentas separadas

`split_number` (1..N, según cuántas cuentas tenga el pedido) cobra **solo
esa división**, reusando el mismo camino que usa la pantalla de caja para
dividir la cuenta. El pedido completo se cierra (si `close_order: true`)
**solo cuando todas las cuentas están pagadas** — cobrar la cuenta 1 de 3
nunca cierra el pedido ni saca nada del tablero de cocina todavía. Encadena
naturalmente con la precuenta: pides la precuenta de la persona 2 (§7,
`split_number: 2`), cobras, informas el pago de la persona 2 con el mismo
`split_number`.

### Idempotencia

Igual patrón que el resto de la API, doblado porque es plata: el header
`Idempotency-Key` reproduce la respuesta original ante un reintento, y por
debajo el servicio toma un `SELECT ... FOR UPDATE` sobre el pedido — un
segundo cobro sobre un pedido ya pagado responde `409 conflict`, **incluso
sin mandar el header**. Para cuentas separadas, el guardia es por
`split_number`: cobrar dos veces la misma división también responde `409`.

### Errores propios de este endpoint

| HTTP | `code` | Cuándo |
|---|---|---|
| 403 | `forbidden_scope` | La key no tiene el scope `orders:write` |
| 404 | `not_found` | El pedido no existe **o pertenece a otro negocio** — misma respuesta para ambos casos, a propósito (sin oráculo de existencia cross-tenant) |
| 409 | `conflict` | El pedido (o la cuenta separada) ya está pagado; el pedido no tiene saldo pendiente; o un cobro sobre la misma orden está en curso |
| 409 | `idempotency_conflict` | Mismo `Idempotency-Key`, distinto body (o la operación original sigue en curso) |
| 422 | `validation_error` | `method` no existe o es ambiguo (lista las opciones); `amount` no calza con el saldo pendiente, es negativo o no es un número finito; `split_number` fuera de rango o inexistente; `print_receipt: true` junto con `split_number`; el pedido está anulado |

**Aviso operativo, heredado de cómo funciona la caja de ExtraLatte**: si el
local tiene **dos cajas abiertas a la vez** en la misma sucursal, una venta
en efectivo postea a la sesión abierta **más nueva**, y un integrador
headless no es "un cajero" que la sesión pueda identificar — con dos cajas
abiertas, tu cobro puede caer en una sesión que el cajero que cierra el día
no está mirando. La plata está en la caja del local de todos modos y el
total del día cuadra igual; lo que puede cambiar es quién de los dos
cajeros la ve en su arqueo. Preexistente al POS, mencionado acá porque este
endpoint hace más fácil toparse con el caso (un integrador cobra sin ser
"nadie" del lado de caja).

---

## 9. Lecturas (recorrer lo que ya existe)

Hasta acá la API se podía **escribir** pero no **recorrer**: existía
`GET /orders/{order_id}`, pero para usarlo había que tener guardado el
`order_id`. Un programa que los guarda no lo notaba; un asistente al que le
dicen *"cobra la mesa 5"* quedaba ciego. Estos tres `GET` cierran ese hueco.

Los tres usan el scope **`read`**, devuelven **solo tu negocio** y hablan en
**tus propias claves**: la mesa por su número, el método de pago por su
nombre. El único identificador nuestro que aparece es `order_id`, que ya es
parte del contrato desde `POST /orders`.

Un `GET` **no consume `Idempotency-Key`**: mandarlo no hace daño, pero tampoco
hace nada. Leer dos veces es leer dos veces, no un reintento.

### `GET /orders` — listar pedidos

```bash
curl "https://extralatte.cl/api/v1/integration/orders?status=open&limit=50" \
  -H "Authorization: Bearer elk_…"
```

```json
{
  "items": [
    {
      "order_id": "a1b2…", "comanda_number": 12, "table": "5",
      "status": "in_kitchen", "payment_status": "unpaid",
      "total": 16500, "round": 2,
      "created_at": "2026-08-19T13:42:07.113000", "closed_at": null
    }
  ],
  "next_cursor": "MjAyNi0wOC0xOVQxMzo0MjowNy4xMTMwMDB8YTFiMg"
}
```

**Sin líneas, a propósito.** Las líneas del pedido están en
`GET /orders/{order_id}`. Una lista que las trajera invitaría a bajar 500
pedidos completos para responder "qué mesas están abiertas".

| Campo | Qué es |
|---|---|
| `order_id` | El id del pedido — el mismo que devolvió `POST /orders` |
| `comanda_number` | El número de comanda del día (`null` si el pedido nunca entró a cocina) |
| `table` | El **número de mesa tuyo**, tal cual lo mandaste en `table`. `null` si no es de mesa |
| `status` | El estado de preparación, **el mismo campo y los mismos valores que `GET /orders/{order_id}`**: `new` → `in_kitchen` → `ready` → `delivered`, o `cancelled` |
| `payment_status` | `unpaid` / `partial` / `paid` / `refunded` |
| `total` | Total del pedido, todas las rondas incluidas |
| `round` | En qué ronda va la mesa: `2` significa que disparó cocina dos veces (ver §6). Mínimo `1` |
| `created_at` | Cuándo se abrió el pedido — **UTC**, ISO-8601 sin zona |
| `closed_at` | Cuándo se cerró, o `null` si sigue abierto |

**Filtros** (todos opcionales, todos combinables, todos **dentro de tu
negocio**):

| Parámetro | Valores | Nota |
|---|---|---|
| `status` | `open`, `paid`, `closed`, `cancelled` **o** `new`, `in_kitchen`, `ready`, `delivered` | Dos vocabularios que no se pisan: los primeros cuatro son el **ciclo de vida de la cuenta**, los otros el **estado de preparación** — o sea, el mismo valor que trae el campo `status` de cada fila. Un valor desconocido es `422` con la lista de los válidos |
| `table` | Tu número de mesa (`"5"`) | Un número que **no es de tu negocio** devuelve lista vacía, no `404` |
| `since` / `until` | Fecha ISO-8601 (`2026-08-19T00:00:00`) | Sobre `created_at`, ambos **inclusive**. Con offset (`…-04:00` o `…Z`) se convierte; sin offset se interpreta **UTC** |
| `service_type` | `dine_in`, `takeaway`, `delivery` | Un valor desconocido es `422` |
| `limit` | 1 a **200**, default **50** | Fuera de rango es **`422`**, nunca un recorte silencioso: pedir 1000 y recibir 200 sin aviso te haría creer que tienes el día entero |
| `cursor` | El `next_cursor` de la respuesta anterior | Opaco: no lo parsees ni lo construyas. Uno inválido es `422` |

**Orden: del más nuevo al más viejo.**

#### Por qué cursor y no `offset`

Porque `offset` pagina por **posición**, y la posición se mueve. Si entra una
comanda entre tu página 1 y tu página 2 — que es exactamente lo que pasa en un
café a la hora punta — todas las filas bajan un lugar: ves un pedido **dos
veces** y otro **no lo ves nunca**.

El cursor es la **clave de orden de la última fila que te entregamos**
(`created_at` + `order_id`), y esa no se mueve. Un pedido nuevo ordena *por
encima* del cursor y simplemente no está en las páginas que te faltan. Lleva
las dos partes porque `created_at` solo no es único: dos comandas marcadas en
el mismo milisegundo son de lo más normal al mediodía, y una clave no única
saltea filas sin avisar.

Recorrer todo es pedir mientras `next_cursor` no sea `null`:

```python
cursor = None
while True:
    qs = "limit=200" + (f"&cursor={cursor}" if cursor else "")
    page = GET(f"/orders?{qs}")
    for order in page["items"]:
        ...
    cursor = page["next_cursor"]
    if cursor is None:
        break
```

> `next_cursor` viene con valor siempre que la página salió llena, así que la
> última página puede ser vacía. Es una llamada de más, no un error.

### `GET /payment-methods` — los métodos que acepta el cobro

```bash
curl https://extralatte.cl/api/v1/integration/payment-methods \
  -H "Authorization: Bearer elk_…"
```

```json
{
  "items": [
    { "name": "Crédito", "type": "card" },
    { "name": "Débito",  "type": "card" },
    { "name": "Efectivo", "type": "cash" }
  ]
}
```

Esto es lo que `method` acepta en `POST /orders/{order_id}/payment` (§8). Hasta
ahora esa información existía pero solo se descubría equivocándose: mandabas
un método cualquiera y leías el `422` que venía con la lista.

- Solo los **activos**. Un método desactivado es justamente el que el cobro
  rechaza; listarlo sería una invitación a un `422`.
- `name` es el nombre que le puso el café ("Efectivo", "Débito"); `type` es la
  familia (`cash`, `card`, `transfer`, `mercadopago`, …). `method` acepta
  cualquiera de los dos, y **el nombre gana** — si hay dos métodos `card`,
  mandar `"card"` es ambiguo y responde `422`.
- **No hay campo `is_default`, a propósito.** ExtraLatte no tiene un "método
  predeterminado": no existe la columna ni el interruptor en el panel. Publicar
  uno derivado (el único método en efectivo, por ejemplo) le diría "tu default
  es Efectivo" a un café cuyo default real es Débito. Si tu flujo necesita un
  predeterminado, elígelo tú y guárdalo de tu lado.

### `GET /tables` — las mesas y qué pedido tienen encima

```bash
curl https://extralatte.cl/api/v1/integration/tables \
  -H "Authorization: Bearer elk_…"
```

```json
{
  "items": [
    { "number": "5", "name": "5", "status": "occupied", "current_order_id": "a1b2…" },
    { "number": "6", "name": "6", "status": "available", "current_order_id": null }
  ]
}
```

`current_order_id` es el campo que convierte *"cobra la mesa 5"* en una llamada
posible: lees la mesa, tomas el pedido y lo cobras con §8. Sin él había que
adivinar.

- `number` es lo que mandas como `table` en `POST /orders`. ExtraLatte guarda
  **un solo rótulo por mesa** ("5", "Barra 2"), así que hoy `number` y `name`
  traen el mismo texto; usa `number` para volver a llamarnos.
- `status`: `occupied` cuando la mesa tiene un pedido vivo (sin cerrar y sin
  anular), `available` cuando no. `reserved` y `blocked` salen tal como los
  dejó el café en el panel: son decisiones de una persona, no hechos sobre
  pedidos.
- `status` se **deriva de los pedidos**, no se lee de la mesa. La marca de
  ocupación de la mesa se ha quedado pegada en la práctica (mesas "ocupadas"
  durante meses porque nunca se cerró su pedido), y una mesa que dijera
  `occupied` con `current_order_id: null` no le sirve a nadie.
- Si una mesa arrastra más de un pedido abierto, `current_order_id` es **el más
  reciente**.

---

## 10. Ejemplo completo (pseudocódigo)

```text
# 1. Al abrir el turno: sync catálogo + inventario
for producto in mi_pos.productos_activos:
    PUT /products/{producto.sku}   { name, price, category, active:true }
for producto in mi_pos.productos_discontinuados:
    DELETE /products/{producto.sku}

for insumo in mi_pos.insumos:
    PUT /ingredients/{insumo.sku}  { name, unit, cost }
    PUT /ingredients/{insumo.sku}/stock { location:"Bodega", quantity, min_quantity }

# 2. Cada venta:
POST /orders  (Idempotency-Key: mi_ticket_id)
      { service_type:"dine_in", table:"5", items:[{sku, quantity}] }

# 3. Cambio de precio → re-upsert el producto:
PUT /products/{sku}  { name, price: nuevo_precio, category }
```

---

## 11. Notas y límites

- **Multi-tenant:** tu key solo alcanza tu negocio. Un `sku` de otro negocio no
  existe para ti (`404`).
- **Sin CORS del navegador:** es una API servidor-a-servidor. No la llames desde
  JS en el navegador.
- **Idempotencia:** guarda un `Idempotency-Key` por operación (sobre todo
  comandas) para que un reintento no duplique.
- **Errores:** revisa siempre `error.code` (estable, en inglés) antes que el
  `message` (español, puede cambiar).
- **Webhooks (futuro/complemento):** ExtraLatte puede además *notificarte* a ti
  (pedido listo, stock bajo) vía webhooks salientes — ver `docs/api.md` §Webhooks.
```

## Changelog

- **v1.5 (2026-08-19):** **Lecturas** (§9) — `GET /orders`, `GET /tables` y
  `GET /payment-methods`, las tres con el scope existente `read`, sin scope
  nuevo y sin migración. Hasta acá la API se podía escribir pero no recorrer:
  existía `GET /orders/{order_id}` pero había que tener guardado el `order_id`,
  así que un asistente al que le dicen "cobra la mesa 5" no tenía por dónde
  empezar. `GET /orders` pagina **por cursor y no por offset** (un pedido nuevo
  entre página y página duplicaba una fila y escondía otra), filtra por
  `status` / `table` / `since` / `until` / `service_type`, y **no** trae las
  líneas del pedido — para eso sigue estando `GET /orders/{order_id}`. Su
  `limit` tiene tope duro de 200 y responde `422` en vez de recortar en
  silencio. `GET /tables` publica `current_order_id`, que es lo que convierte
  "cobra la mesa 5" en una llamada posible, y deriva `status` de los pedidos en
  vez de leer la marca de la mesa (que se queda pegada). `GET /payment-methods`
  publica lo que `method` acepta al cobrar, que antes solo se descubría
  equivocándose y leyendo el `422`; deliberadamente **no** trae un
  `is_default`, porque esa noción no existe en el producto. Plan:
  `.github/plans/skill-servido-y-lecturas-plan.md` §1.
- **v1.4 (2026-08-18):** **Mesa abierta** — `POST /orders/{order_id}/items`
  (scope existente `orders:write`) agrega una ronda a un pedido que sigue
  abierto e imprime **solo las líneas nuevas**, con la ronda en el encabezado
  ("Mesa 5 · ronda 2"); `print_full_order: true` imprime el pedido completo
  marcando lo nuevo. Acepta `modifiers` por línea igual que `POST /orders`.
  Con esto una mesa que pide tres veces vuelve a ser **un pedido y una
  cuenta** (una precuenta, un cobro) en vez de tres. Detrás hay una columna
  nueva, `order_items.dispatched_at` (migración `orderitemdispatch20260818`),
  que marca la línea cuando entra en una comanda: es lo que permite decir
  "solo lo nuevo". Agregar a un pedido pagado, cerrado o con pagos
  registrados es `409`. Plan:
  `.github/plans/mesa-abierta-y-modificadores-plan.md` §3.
- **v1.3 (2026-08-18, no desplegado — implementado en rama
  `feat/modificadores-mesa`):** **Agregados con precio** — el catálogo ahora
  acepta grupos de opciones y opciones dentro del `PUT /products/{sku}`
  (`option_groups`), cada opción con **tu propio `sku`** (nuevo
  `MenuOption.external_sku`, único por negocio, migración
  `menuoptsku20260818`). Sin scope nuevo: usa el existente `catalog:write`.
  Omitir `option_groups` no toca nada, así que una integración anterior sigue
  funcionando sin cambios, y un producto sin agregados conserva exactamente la
  forma de respuesta de siempre. Se agrega
  `DELETE /products/{sku}/options/{option_sku}`, que devuelve `409` si el
  agregado ya se vendió. Y la otra mitad, la que faltaba: `POST /orders`
  acepta `items[].modifiers` (por `sku` de opción) y **cobra** el agregado —
  `line_total = (base + Σ agregados) × cantidad`, precio congelado al momento
  del pedido, validación del grupo del lado del servidor. Plan:
  `.github/plans/mesa-abierta-y-modificadores-plan.md` §2.
- **v1.2 (2026-08-13, no desplegado — implementado en rama
  `feat/integration-pago`):** **Pago** — `POST
  /orders/{order_id}/payment` (scope existente `orders:write`, sin scope
  nuevo) registra el cobro de un pedido y opcionalmente lo cierra:
  resolución de `method` por nombre/tipo (nunca UUID), `amount` opcional
  que debe calzar exacto con el saldo pendiente, `tip`, cuentas separadas
  vía `split_number`, `card_token` fail-soft para lealtad, `close_order`
  (default `true`, estampa `closed_at` y **nunca** `delivered`), y
  `print_receipt` (default **`false`** — ExtraLatte no imprime nada de su
  lado al registrar un pago porque el integrador ya es dueño de su propia
  boleta; combinarlo con `split_number` es `422`, no un no-op silencioso).
  Idempotencia doblada (header + `SELECT ... FOR UPDATE`). **Cambio
  importante para keys existentes: toda API key ya emitida con el scope
  `orders:write` gana esta capacidad al desplegar, sin que el tenant vuelva
  a consentir** — ver la fila actualizada en §1. Sección "Flujo
  recomendado" (§3) actualizada: el ciclo ya no termina en la comanda,
  ahora es catálogo → inventario → comanda → precuenta → **pago**. Además
  en esta rama (documentado en `docs/modules.md`, no en esta guía porque
  no son parte del contrato de la API): `pay_split` alcanzó paridad con
  `process_payment` (Fase 1 — hasta ahora una cuenta dividida no descontaba
  inventario ni acreditaba lealtad, un defecto vivo del POS de mostrador,
  no solo de esta API) y el tablero de cocina se drena solo a las 24h
  (Fase 3). Rama `feat/integration-pago`, plan
  `.github/plans/integration-pago-cierre-plan.md`. Gate 337/337 PASS,
  hallazgo BLOCKING de `cafe-quality-review` remediado en el mismo arco
  (piso de activación del barrido automático — ver el plan). **Pendiente
  de merge a `dev` y de deploy.**
- **v1.1 (2026-08-12):** **Precuenta** — `POST /orders/{order_id}/precuenta`
  (scope nuevo `printing:write`) encola la impresión de la cuenta previa al
  cobro, con propina sugerida y descuento opcionales, cuentas separadas, y
  doble idempotencia (header + índice único en base de datos: pedir la misma
  precuenta dos veces nunca imprime dos veces). Sin reprint por API a
  propósito. Requiere el conector `cafe-printerd` v0.3.0+ para el formato
  "PRECUENTA" completo (un conector más viejo imprime con la boleta legacy).
  Rama `feat/precuenta-integration`, plan
  `.github/plans/precuenta-integration-plan.md`.
- **v1 (2026-07-28):** API keys OpenCore (bearer `elk_…`, scopes aplicados),
  catálogo (`/products`), inventario (`/ingredients`, `/stock`), comandas
  (`/orders`); upsert por SKU, idempotencia, errores uniformes. Desplegada en
  producción (`v0.30.0-integration-api`).
