feat(POS-001): completed feature
This commit is contained in:
379
docs/pos/POS_API.md
Normal file
379
docs/pos/POS_API.md
Normal file
@@ -0,0 +1,379 @@
|
||||
# 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)
|
||||
|
||||
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`.
|
||||
Reference in New Issue
Block a user