Files
mercadodevida/docs/pos/POS_API.md
2026-08-22 22:08:30 +02:00

382 lines
15 KiB
Markdown

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