15 KiB
POS API — Mercado de Vida
Companion to:
POS_ARCHITECTURE.md·POS_DATA_MODEL.mdStatus: Discovery (Phase 1)F-186 update: the implemented mixed-payment/free-item/receipt contract is documented in
POS_CHECKOUT.md. Where this discovery document still shows the legacy one-payment request,POS_CHECKOUT.mdtakes 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). |
// 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. |
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). |
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. |
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. |
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. |
// 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
idempotencyKeyalready 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.checkAvailabilityper item. - The server computes
subtotalCents,discountCents,taxCents,totalCents. - The whole flow runs in a single PG transaction:
INSERT INTO orders_orders ... source='pos', state='PENDING'.INSERT INTO orders_items(one per item).- For each item:
InventoryService.reserve(variantId, storeId, qty). UPDATE orders_orders SET state='AWAITING_PAYMENT'.- For each payment:
INSERT INTO payments_transactions. UPDATE orders_orders SET state='PAID'.- For each item:
InventoryService.confirm(reservation). UPDATE orders_orders SET state='COMPLETED'.AuditLogger.log('pos.sale.created', ...).
- On any error inside the transaction:
ROLLBACK. The API returns 4xx/5xx without partial state. warningsis non-empty when a price or stock changed between cart and confirm.
Status codes:
201 Createdon first success.200 OKif theidempotencyKeyalready exists (returns the existing order).400if validation fails.409 Conflictif stock is unavailable (PRICING_STOCK_UNAVAILABLE) or session is closed (POS_SESSION_CLOSED).422if a discount exceeds the configured limit.500for 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)
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:
{
"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.