382 lines
15 KiB
Markdown
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`.
|