feat(POS-001): completed feature

This commit is contained in:
chattie
2026-08-21 21:24:58 +02:00
parent 20f92b701e
commit c68cb25ed6
13 changed files with 3621 additions and 31 deletions

379
docs/pos/POS_API.md Normal file
View 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`.

View File

@@ -0,0 +1,486 @@
# POS Architecture — Mercado de Vida
> **Status:** Discovery (Phase 1)
> **Ticket:** POS-001
> **Author:** architect
> **Last updated:** 2026-08-21
This document describes how a Point-of-Sale (TPV) application integrates into the existing **Mercado de Vida** modular monolith. The POS must reuse products, prices, stock, customers, orders, taxes, authentication, RBAC and the admin panel. **No business-logic duplication.**
---
## 0. Executive summary
| Aspect | Decision |
|---|---|
| Frontend app | New Next.js app `apps/pos` inside the existing `project/` workspace (sibling to `apps/admin`). Same Tailwind design tokens, same catch-all proxy pattern, same auth flow as the admin. |
| Backend | New module `project/src/modules/pos/` following the existing `domain / application / infrastructure / api` layout. Reuses all existing modules via injected ports. |
| Sales = orders | A POS sale IS an order with `source = 'pos'`. New column on `orders_orders`. The order state machine is extended with a `COMPLETED` terminal state (no shipping). |
| Cash sessions, terminals, stores | New tables. Schema below. |
| Inventory per store | Extended: `inventory_stock` becomes keyed by `(variant_id, store_id)`. Migration backfills the default store. |
| Authentication | Reuses the existing `backoffice_users` table (admin/editor + new `pos_cashier`, `pos_manager` roles). Same session cookie mechanism. |
| Hardware | Adapter interfaces (no vendor SDK in core). Browser-first implementations; real ESC/POS adapters land in Phase 7. |
| Idempotency | Reuses `idempotency_key` on orders. POS generates a UUID per "cobrar" click. |
The POS is **not** a parallel system. It is a new consumer of the same core modules, plus a thin new module for the TPV-specific concepts (terminals, sessions, registers).
---
## 1. Current stack (as-found)
### 1.1 Repository layout
```
project/
├── apps/
│ ├── admin/ # Next.js 14 admin (Backoffice users)
│ ├── frontend/ # Next.js 14 customer storefront
│ └── storefront/ # legacy customer storefront (being phased out)
├── src/
│ ├── app/ # Fastify composition root (build-app.ts)
│ ├── infrastructure/# server bootstrap, config, logger
│ ├── modules/ # 20+ domain modules (DDD + Clean Architecture)
│ └── shared/ # cross-module helpers (auth, errors, http-input, telemetry, text)
├── migrations/ # node-pg-migrate
├── scripts/ # dev helpers
├── docker-compose.yml # postgres + redis
└── package.json # ESM, TypeScript strict, vitest
```
Apps are co-located under `project/apps/` and share backend through a single Next.js catch-all proxy (`/api/[...path]/route.ts`). The `monolith.sh` script orchestrates all services on ports 3000 (backend), 3003 (frontend), 3004 (admin), 3005 (storefront).
### 1.2 Backend conventions
- **Module layout (mandatory):** `domain / application / infrastructure / api`. Public API exposed only via `index.ts`.
- **Module isolation:** modules import ONLY public APIs of other modules; the boundary checker (`scripts/check-module-boundaries.mjs`) enforces this.
- **Auth:** `Authenticate` port from `shared/auth.ts`; routes call `await deps.authenticate(request)` and then `requireRole(user, 'admin' | 'editor' | 'customer')`.
- **RBAC:** role-based string check in `requireRole`. Permissions are an enum in `apps/admin/src/lib/permissions.ts` (admin only, currently).
- **Validation:** explicit `parseJson(zodSchema, request.body)` via `shared/http-input.ts`. No magic.
- **Errors:** `AppError(httpStatus, code, message, details?)` from `shared/errors.ts`. Single envelope.
- **DB:** PostgreSQL 16, schema-per-module naming `<module>_<table>`. node-pg-migrate, ES module migrations.
- **Logging:** structured pino via `app.log`.
- **Telemetry:** `Telemetry` / `Tracer` / `Meter` ports from `observability` (no-op default).
- **Tests:** vitest unit + itest (integration) inside `src/modules/<m>/tests/`. E2E in `src/app/tests/e2e/`.
### 1.3 Existing modules we will reuse
| Module | What POS needs from it |
|---|---|
| `identity` | `Authenticate` port, backoffice session lookup (cookie + role). |
| `backoffice` | Login/logout/me endpoints and the cookie contract. Reused as-is. |
| `catalog` | Product / variant / image / category / brand lookups + search by EAN/SKU/name (`GET /products/search`, `GET /products/suggest`). |
| `pricing` | `GET /pricing/variants/:id`, `POST /pricing/calculate`. Server-authoritative price; never trust client totals. |
| `inventory` | `checkAvailability`, atomic `reserve / release / confirm`. The POS sale = `confirm` of a `reserve`. |
| `tax` | Active tax rates via `GET /admin/tax-rates`. |
| `orders` | Order creation, state transitions, idempotency. |
| `payments` | `PaymentProvider` interface — currently a stub. POS brings concrete `cash` and external `card` providers in Phase 3. |
| `checkout` | The orchestrator pattern (validate → price → reserve → create order → payment intent). POS re-uses the same flow but skips shipping. |
| `users` | Customer search by email/phone for ticket association. |
| `store-settings` | `store_name`, `contact_address`, `footer_text`, etc. — printed on tickets. |
| `security` | `AuditLogger` for "open register", "close register", "discount applied", etc. |
| `notifications` | Email on sale confirmation (optional, off by default for POS). |
| `observability` | `Tracer` / `Meter` for spans and counters. |
### 1.4 Existing admin app conventions (POS will mirror them)
- App Router under `apps/admin/src/app/(dashboard)/...`.
- Tailwind with shared `@theme` block (greens `#2D6A4F`, `#1B4332`; secondary cream `#F5F0E8`; accent orange `#E76F51`; danger `#DC2626`).
- `AuthProvider` context, `useAuth()` hook, `requireRole` redirect to `/login`.
- `lib/api-client.ts` with typed namespaces (`productsApi`, `brandsApi`, `ordersApi`, …). Catch-all proxy at `app/api/[...path]/route.ts`.
- Tanstack-Query-like manual caching via `useEffect` + `useState` (no Redux/Zustand yet).
- `lib/permissions.ts` for client-side UI gating.
---
## 2. Gaps to address before code
These are real schema/concept gaps the brief asks for that **do not exist today**:
| Gap | Brief section | Current state | Resolution |
|---|---|---|---|
| **Multi-store** | §17 | `inventory_stock` keyed by `variant_id` only. **No** `store` table. | Add `stores` table; key `inventory_stock` by `(variant_id, store_id)`. Migration backfills default store. |
| **Terminals** | §17 | None. | New `pos_terminals` table. |
| **Cash sessions** | §18 | None. | New `pos_cash_sessions` table. |
| **Order source** | §16 | No `source` column on `orders_orders`. | Add `source` enum (`'ecommerce' \| 'pos' \| 'admin'`) with default `'ecommerce'`. Backfill existing rows. |
| **Order `COMPLETED` state** | §16, §39 | State machine tops out at `DELIVERED`. POS sales never ship. | Add `COMPLETED` terminal state. POS sales go `PENDING → AWAITING_PAYMENT → PAID → COMPLETED` (skipping `SHIPPED`/`DELIVERED`). |
| **POS roles** | §27 | Roles are `customer / admin / editor`. | Extend `backoffice_users.role` CHECK to include `'pos_cashier'` and `'pos_manager'`. |
| **POS-only permissions** | §27 | Permissions enum is admin-only. | Extend `permissions.ts` with `POS_*` permissions; server-side `requireRole` is too coarse — add `requireAnyRole` and `requirePermission` to `shared/auth.ts`. |
| **Anonymous sale** | §14 | `orders_orders.user_id NOT NULL`. | Either create a "walk-in customer" placeholder or make `user_id` nullable when `source = 'pos'`. Decision: **nullable** with a CHECK that requires `user_id IS NULL OR source = 'pos'` if guest, otherwise required. |
| **Hardware adapters** | §25 | None. | New interfaces under `shared/hardware/`: `ScannerAdapter`, `PrinterAdapter`, `CashDrawerAdapter`, `PaymentTerminalAdapter`, `ScaleAdapter`. Browser implementation in Phase 2/3; ESC/POS in Phase 7. |
| **Idempotency per sale** | §32 | Already supported via `idempotency_key` UNIQUE. | Reused as-is. POS client generates a UUID per "Cobrar" click. |
---
## 3. Proposed architecture
### 3.1 High-level diagram
```
┌──────────────────────────────────────────────┐
│ Mercado de Vida Core │
│ │
┌──────────┐ │ ┌─────────┐ ┌─────────┐ ┌────────────┐ │
│ Ecommerce│─────────┼─►│Catalog │ │Pricing │ │ Inventory │ │
│ Frontend │ API │ └────┬────┘ └────┬────┘ └─────┬──────┘ │
└──────────┘ │ │ │ │ │
│ └────────────┼──────────────┘ │
┌──────────┐ │ ▼ │
│ Admin │─────────┼─► ┌──────────────────────────────┐ │
│ Panel │ API │ │ Orders (source=ecommerce/pos/admin) │
└──────────┘ │ └──────────────────────────────┘ │
│ │ │
┌──────────┐ │ ▼ │
│ POS │─────────┼─► ┌──────────────────────────────┐ │
│ (new) │ API │ │ Payments (cash / card stub) │ │
│ apps/pos │ │ └──────────────────────────────┘ │
└────┬─────┘ │ │ │
│ hardware │ ▼ │
▼ adapters │ ┌──────────────┐ │
┌──────────┐ │ │ Audit Log │ │
│ Scanner │ │ └──────────────┘ │
│ Printer │ │ │
│ Cash drw │ └──────────────────────────────────────────────┘
│ Card Trm │
└──────────┘
```
### 3.2 Backend modules
```
src/modules/
├── pos/ # NEW — POS-specific concepts
│ ├── domain/
│ │ ├── store.ts # Store aggregate
│ │ ├── terminal.ts # POS terminal
│ │ ├── cash-session.ts # Open/closed register session
│ │ ├── quick-product.ts # Configurable favorite buttons
│ │ ├── sale.ts # Re-exports Order as Sale; defines Source enum
│ │ └── errors.ts
│ ├── application/
│ │ ├── open-cash-session.ts
│ │ ├── close-cash-session.ts
│ │ ├── list-terminals.ts
│ │ ├── get-pos-config.ts # returns terminal, session, store, tax rates
│ │ ├── search-products-for-pos.ts # thin wrapper over catalog search + price
│ │ ├── create-pos-sale.ts # orchestrates pricing + reserve + create + complete
│ │ ├── refund-pos-sale.ts
│ │ ├── park-ticket.ts # cart parked in DB
│ │ └── ...
│ ├── infrastructure/
│ │ ├── pg-store-repository.ts
│ │ ├── pg-terminal-repository.ts
│ │ ├── pg-cash-session-repository.ts
│ │ ├── pg-quick-product-repository.ts
│ │ └── pg-parked-ticket-repository.ts
│ └── api/
│ └── pos.routes.ts # all /pos/* endpoints
└── (existing modules, untouched except where noted)
```
### 3.3 POS order flow (orchestration)
The POS sale path is a **slimmer** checkout: no shipping, no promo code, optional customer. We re-use `OrderService` and `InventoryService` directly rather than building a separate orchestrator.
```
[POS UI: Cart with N items]
│ POST /api/pos/sales { idempotencyKey, items, customerId?, payments, discount? }
pos.routes.ts
├─► requireRole(user, ['pos_cashier','pos_manager','admin'])
├─► requireOpenSession(terminalId) // 409 if closed
├─► for each item:
│ pricingApi.getVariantPrice(variantId) // server-authoritative
│ inventory.checkAvailability(variantId) // soft check (real check at confirm)
├─► compute totals server-side (no client trust)
├─► BEGIN TRANSACTION
│ ordersService.create({source:'pos', userId?, items, totals, idempotencyKey})
│ inventory.reserve(variantId, storeId, qty) × items
│ ordersService.transition(orderId, 'AWAITING_PAYMENT')
│ for each payment: paymentsService.registerCash/Card({orderId, amountCents, ...})
│ ordersService.transition(orderId, 'PAID')
│ for each payment: inventory.confirm(reservation) × items
│ ordersService.transition(orderId, 'COMPLETED')
│ auditLogger.log('pos.sale.completed', {orderId, terminalId, totalCents, ...})
│ COMMIT
[POS UI: Receipt rendered, print dispatched, new empty cart]
```
Key invariants:
- **Idempotency:** the same `idempotencyKey` returns the same order — no double-charge on network retry.
- **Atomicity:** everything is in one PG transaction (using a single connection / client). The existing `OrderService` and `InventoryService` already operate on a passed `pg.Client`; we wire them to a single txn.
- **Server-authoritative totals:** client sends only item ids + quantities; server computes price, tax, discount.
- **No double reservation:** the orchestration is linear and single-threaded per request.
### 3.4 Hardware abstraction
```ts
// project/src/shared/hardware/types.ts
export interface ScannerAdapter {
/** Subscribe to decoded codes. Most physical scanners are HID-keyboard;
* we wrap them in this interface so swapping to serial/BT is a one-line change. */
onCode(callback: (code: string) => void): () => void;
}
export interface PrinterAdapter {
/** Renders a structured receipt. Returns success/failure; never throws
* for "printer offline" (returns a structured failure instead). */
print(receipt: ReceiptPayload): Promise<{ ok: true; jobId: string } | { ok: false; reason: string }>;
}
export interface CashDrawerAdapter {
/** Pulses the cash-drawer kick-out. Best-effort; failures logged. */
open(): Promise<{ ok: boolean; reason?: string }>;
}
export interface PaymentTerminalAdapter {
/** Asks the datáfono for an authorisation. Returns approved/declined/error.
* No PAN handling: this delegates to a PCI-DSS-compliant provider. */
requestPayment(input: { amountCents: number; currency: 'EUR'; reference: string }): Promise<
| { ok: true; providerPaymentId: string; authCode: string }
| { ok: false; reason: 'declined' | 'timeout' | 'offline' | 'error'; message: string }
>;
}
export interface ScaleAdapter {
/** Reads weight in grams. Returns null if the scale is disconnected or settling. */
readGrams(): Promise<number | null>;
}
```
Each adapter has two implementations in Phase 7:
- **Browser** (Phase 2/3): relies on standard browser APIs. Scanner = keyboard listener on a focused input. Printer = hidden iframe to a print-friendly HTML route. Cash drawer = no-op. Payment terminal = manual entry (operator types last-4 or auth code).
- **Native bridge** (Phase 7, deferred): a thin Node addon or a sidecar process that speaks USB/serial/ESC-POS. Exposed over a localhost HTTP socket to the Next.js app.
### 3.5 Frontend app
```
apps/pos/
├── package.json
├── next.config.ts
├── tailwind.config.ts (or shared via project root)
├── src/
│ ├── app/
│ │ ├── layout.tsx
│ │ ├── globals.css
│ │ ├── (auth)/
│ │ │ └── login/page.tsx
│ │ ├── (terminal)/
│ │ │ ├── layout.tsx # minimal chrome — sidebar, connection status
│ │ │ ├── page.tsx # main register screen
│ │ │ ├── open-register/page.tsx
│ │ │ ├── close-register/page.tsx
│ │ │ ├── history/page.tsx
│ │ │ └── settings/page.tsx
│ │ └── api/
│ │ └── [...path]/route.ts # identical proxy to apps/admin
│ ├── components/
│ │ ├── pos/POSLayout.tsx
│ │ ├── pos/POSHeader.tsx
│ │ ├── pos/ProductSearch.tsx
│ │ ├── pos/CategorySelector.tsx
│ │ ├── pos/QuickProducts.tsx
│ │ ├── pos/Cart.tsx
│ │ ├── pos/CartItem.tsx
│ │ ├── pos/CartTotals.tsx
│ │ ├── pos/CustomerSelector.tsx
│ │ ├── pos/DiscountPanel.tsx
│ │ ├── pos/PaymentModal.tsx
│ │ ├── pos/CashPayment.tsx
│ │ ├── pos/CardPayment.tsx
│ │ ├── pos/NumericKeypad.tsx
│ │ ├── pos/TicketHistory.tsx
│ │ ├── pos/RegisterSession.tsx
│ │ ├── pos/POSSettings.tsx
│ │ ├── pos/ConnectionStatus.tsx
│ │ └── ui/ # shared with apps/admin via component copy (TODO: extract to packages/ui in a future cleanup)
│ ├── features/
│ │ ├── auth/
│ │ ├── cart/ # POS cart state machine
│ │ ├── register/ # cash session state
│ │ ├── catalog/ # cached product list
│ │ └── hardware/ # adapter wiring
│ ├── lib/
│ │ ├── api-client.ts # typed client (mirror of apps/admin/src/lib/api-client.ts)
│ │ ├── permissions.ts # extends admin/permissions with POS_*
│ │ ├── money.ts # cents helpers, formatting (es-ES)
│ │ ├── tax.ts # client-side tax display only
│ │ ├── idempotency.ts # UUID v4 generator
│ │ └── hardware/ # adapter factories (browser impls)
│ ├── hooks/
│ ├── types/
│ └── middleware.ts # auth guard, terminal-id cookie
└── tests/
```
The app is delivered through the existing `monolith.sh` script (Phase 2: add a `pos` service on port 3006).
### 3.6 Multi-store inventory
Two options were considered:
- **(A) Extend `inventory_stock` with `store_id`** — chosen. Migration adds the column with a default "Main Store" UUID; backfills existing rows. Foreign key to new `stores` table.
- **(B) New `inventory_stock_by_store` table** — rejected: more moving parts, harder joins, no upside.
Operations:
- `inventory.reserve(variantId, storeId, qty)` becomes the signature.
- The POS endpoint always passes the terminal's storeId.
- Reporting endpoints (`GET /admin/inventory`) accept an optional `?storeId=` filter.
### 3.7 Authentication & authorization
- **Authentication:** identical to `apps/admin`. Same cookie, same `Authenticate` port. The POS app calls the same `/api/backoffice/auth/login` endpoint.
- **Authorization:** extend `shared/auth.ts` with `requireAnyRole(user, [...roles])`. New roles: `pos_cashier`, `pos_manager`. Permission string `POS_*` for fine-grained checks (override price, refund, cancel sale, open/close register).
- **Terminal identity:** a UUID generated on first boot of the POS app, stored in a Secure cookie (`pos_terminal_id`, 1 year expiry). The cookie survives reloads; clearing it requires re-binding the terminal to a record via a one-time admin code.
- **Session token in cookie** is the same as admin's. One user can be logged into admin and POS simultaneously (different cookies? no — same cookie, different apps).
### 3.8 Connection status / offline (deferred)
Phase 15: online-only. Connection status shows "● Online" / "⚠ Sin conexión" by pinging `GET /health` every 10 s.
Phase 8 (out of scope here): offline-first with IndexedDB queue and CRDT-style stock reconciliation. Deferred until the operator explicitly requests it. Architecture leaves a `pendingMutations` slot in the cart slice so it can be wired later.
---
## 4. Data model (full in `POS_DATA_MODEL.md`)
New tables (concise; full DDL in `POS_DATA_MODEL.md`):
| Table | Purpose | Key fields |
|---|---|---|
| `pos_stores` | Physical stores | `id, name, address, tax_id, settings_jsonb` |
| `pos_terminals` | One TPV device | `id, store_id, name, status, interface_mode, settings_jsonb, last_seen_at` |
| `pos_cash_sessions` | Open/close register | `id, terminal_id, store_id, user_id, opened_at, closed_at, opening_cash_cents, closing_cash_cents, expected_cash_cents, actual_cash_cents, difference_cents, status` |
| `pos_quick_products` | Favorite buttons | `id, store_id, terminal_id?, user_id?, product_id, variant_id, position` |
| `pos_parked_tickets` | Held tickets | `id, store_id, terminal_id, user_id, customer_id?, cart_jsonb, created_at, expires_at` |
| `pos_payment_methods` | Configurable payment types | `id, store_id, code, label, kind ('cash'\|'card'\|'other'), active, sort_order` |
Modified tables:
| Table | Change |
|---|---|
| `inventory_stock` | Add `store_id uuid NOT NULL DEFAULT <main store>`, FK to `pos_stores`. Replace unique index with `(variant_id, store_id)`. |
| `orders_orders` | Add `source text NOT NULL DEFAULT 'ecommerce' CHECK (source IN ('ecommerce','pos','admin'))`. Add `terminal_id uuid NULL`, `cash_session_id uuid NULL`. Add `COMPLETED` to state CHECK. |
| `backoffice_users.role` | Extend CHECK to include `'pos_cashier'`, `'pos_manager'`. |
No new customer/user table — POS sales link to existing `identity_users` (or NULL for walk-ins).
---
## 5. API surface (full in `POS_API.md`)
Mounted under existing Fastify via `registerPosRoutes(app, deps)` in `build-app.ts`. All under `/pos/...`. Reuses `requireRole` and `requireAnyRole` from `shared/auth.ts`. Cookie auth same as the rest.
| Method | Path | Purpose | Role |
|---|---|---|---|
| GET | `/pos/stores` | List stores | admin |
| POST | `/pos/stores` | Create store | admin |
| GET | `/pos/terminals` | List terminals | admin / pos_manager |
| POST | `/pos/terminals` | Register a terminal (returns binding code) | admin |
| GET | `/pos/terminals/me` | Current terminal by cookie | any pos |
| PATCH | `/pos/terminals/:id` | Update terminal settings | admin / pos_manager |
| GET | `/pos/sessions/me` | Current open session for terminal | any pos |
| POST | `/pos/sessions` | Open register | pos_cashier / pos_manager |
| POST | `/pos/sessions/:id/close` | Close register (counted cash) | pos_cashier / pos_manager |
| GET | `/pos/config` | Returns `{ terminal, store, session, paymentMethods, taxRates, categoriesTree }` | any pos |
| GET | `/pos/products/search?q=...` | Server-side search (delegates to catalog) | any pos |
| GET | `/pos/products/by-ean/:ean` | Direct lookup by EAN (fast path for scanner) | any pos |
| GET | `/pos/products/by-sku/:sku` | Direct lookup by SKU | any pos |
| GET | `/pos/quick-products` | Quick buttons for the current (terminal, user) | any pos |
| POST | `/pos/quick-products` | Add a quick product | pos_manager |
| DELETE | `/pos/quick-products/:id` | Remove | pos_manager |
| POST | `/pos/sales` | Create sale (idempotent) | pos_cashier+ |
| GET | `/pos/sales` | List recent sales (scoped to terminal/store) | any pos |
| GET | `/pos/sales/:id` | Sale detail | any pos |
| POST | `/pos/sales/:id/refund` | Refund (full or partial) | pos_manager |
| POST | `/pos/sales/:id/print` | Reprint receipt | any pos |
| POST | `/pos/parked-tickets` | Park current cart | pos_cashier+ |
| GET | `/pos/parked-tickets` | List parked tickets for store | pos_cashier+ |
| DELETE | `/pos/parked-tickets/:id` | Discard parked ticket | pos_cashier+ |
All POST/PATCH endpoints validate with `parseJson(zodSchema, body)`. All errors are `AppError`-shaped. All write actions append to `security_audit_log` via `AuditLogger.log`.
---
## 6. Reusable components
Existing modules reused as-is, no edits:
- `Authenticate` port, `parseJson`, `AppError`, `parseJson` (validation), `AuditLogger`.
- `OrderService` (with `source` extension), `InventoryService` (with `store_id` extension).
- `PricingService`, `TaxRateRepository`, `CatalogSearch`, `BrandRepository`, `CategoryRepository`, `ProductRepository`, `VariantRepository`.
- `CustomerRepository` from `users` for ticket association.
- `StoreSettingsRepository` for ticket header/footer.
Existing frontend utilities reused:
- `lib/api-client.ts` pattern (copy + extend with `posApi`).
- `lib/permissions.ts` extended with `POS_*` permissions.
- `AuthProvider` pattern copied and adapted (no shared package yet).
- Tailwind theme tokens (the `@theme` block in globals.css is identical).
---
## 7. Risks
| Risk | Severity | Mitigation |
|---|---|---|
| **Multi-store migration is destructive** | high | Migration backfills all existing rows with a single default store; reversible via SQL script kept in `scripts/`. Operator must approve before Phase 2 starts. |
| **Inventory race between POS and ecommerce** | high | All inventory writes go through `InventoryService.reserve/confirm`; same transaction model already used by checkout. Documented invariant: reservation is the only way to consume stock. |
| **`COMPLETED` state breaks existing admin workflows** | medium | State machine is additive; existing `DELIVERED` flow is unchanged. Admin orders stay on `DELIVERED`. POS-only orders end on `COMPLETED`. |
| **Cookie sharing between admin and POS apps** | low | Same domain; both apps read the same cookie. If POS is deployed to a different domain, swap to a JWT or shared session service (Phase 8). |
| **Idempotency conflicts** | medium | `idempotency_key` is already unique. The POS UI generates a UUID per "Cobrar" click; if the user clicks twice within ~50 ms, only one wins. |
| **Hardware fragmentation** | medium | All hardware behind adapters; no vendor SDK in core. Browser impls ship first (Phase 2/3); native bridge in Phase 7 only when operator commits to a vendor. |
| **Touchscreen keyboard** | low | React `NumericKeypad` component; no system-level dependencies. |
| **Refunds for split payments** | medium | Partial refund = multiple `payment_transactions` with negative `amount_cents`. Existing `payments` module already supports this. |
---
## 8. Phased plan
The brief already defines 7 phases. The list below translates them into Orquestra-friendly tickets:
| Phase | Ticket(s) | Goal |
|---|---|---|
| 0 | **POS-001** (this) | Discovery + architecture docs. |
| 1 | POS-002 to POS-010 | **Core TPV.** Schema migrations (stores, terminals, sessions, quick-products, parked-tickets, payment-methods). Multi-store inventory migration. `OrderService.source` extension. `COMPLETED` state. New `pos` module + routes + repository + unit tests. Frontend app skeleton with `POSLayout`, header, product search, categories, quick products, cart, totals. |
| 2 | POS-011 to POS-016 | **Checkout.** Cash payment (with change calculation), card manual entry, idempotent sale creation, stock decrement, receipt rendering, optional customer association, history. |
| 3 | POS-017 to POS-022 | **Cash register.** Open/close session, expected vs actual cash, difference capture, audit trail, end-of-shift summary. |
| 4 | POS-023 to POS-028 | **Multi-terminal.** Stores + terminals admin UI, terminal binding code flow, per-terminal quick products, per-store payment methods, concurrent terminals (already supported by HTTP). |
| 5 | POS-029 to POS-033 | **Touch UI.** Larger hit targets, virtual keypad, scanner auto-focus, keyboard shortcuts (F2/F4/F6/F8/F9/F10/ESC), responsive 1024×768 → 1920×1080, no horizontal scroll. |
| 6 | POS-034 to POS-040 | **Hardware.** Adapter interfaces finalized. Browser implementations complete. ESC/POS + USB scanner bridge behind a thin Node sidecar (out-of-process HTTP) — only the spec is written here; the bridge itself is built when the operator picks a vendor. |
| 7 | POS-041+ | **Polish.** Refunds UI, returns, partial refunds, gift receipts, scale integration, multi-currency (deferred to operator demand). |
Each phase ends with **all three gates approved (reviewer, security, qa)** and `verify.sh` exit 0.
---
## 9. Open questions for the operator
These are decisions the architect cannot take unilaterally and that block Phase 2:
1. **Default store name** for the backfill migration ("Mercado de Vida — Tienda única" until renamed via admin)?
2. **Cookie domain**: deploy POS on the same origin as admin (port 3006, subdomain, or sibling port)? Affects cookie sharing.
3. **Touchscreen minimum target hardware** (model + size) so the touch UI can be sized correctly.
4. **Datáfono provider preference** (none, SumUp, Redsys TPVO, generic) — informs whether Phase 7 needs a real adapter or stays browser-only.
5. **Refund policy defaults** (within X days, require manager approval above Y €).
---
## 10. Definition of done (Phase 1)
- ✅ This document (`POS_ARCHITECTURE.md`) signed off by leader.
-`POS_DATA_MODEL.md`, `POS_API.md`, `POS_HARDWARE.md`, `POS_OPERATIONS.md` exist with concrete specs.
-`POS_TASKS.md` lists P0P3 prioritized tickets queued in the backlog (POS-002+).
-`verify.sh` exit 0.
-`runtime-status.json` reset to idle.
**No code is touched in this phase beyond the docs.**

480
docs/pos/POS_DATA_MODEL.md Normal file
View File

@@ -0,0 +1,480 @@
# POS Data Model — Mercado de Vida
> **Companion to:** [`POS_ARCHITECTURE.md`](./POS_ARCHITECTURE.md)
> **Status:** Discovery (Phase 1)
This document details every schema change the POS requires: new tables, new columns, new enum values, and the migrations that apply them. All migrations live under `project/migrations/` with a sequential numeric prefix (`NNN_pos_*.js`).
---
## 1. Naming conventions
- Table names: `pos_<table>` (module-owned namespace).
- Column names: `snake_case`.
- Primary keys: `id uuid NOT NULL DEFAULT gen_random_uuid()`.
- Audit columns: `created_at timestamptz NOT NULL DEFAULT now()`, `updated_at timestamptz NOT NULL DEFAULT now()`.
- Monetary values: `*_cents integer NOT NULL DEFAULT 0` with a CHECK constraint `>= 0` where appropriate. Refund / negative-amount flows use CHECK `>= 0` on the *aggregated* columns but allow per-row negative `amount_cents` in `payments_transactions` (already supported).
- FK references use `ON DELETE` rules: `RESTRICT` for terminal↔session (you can't delete a terminal with sessions), `CASCADE` only for child rows that have no value without the parent.
---
## 2. New tables
### 2.1 `pos_stores`
A physical storefront. Required for multi-store inventory and per-store configuration.
```sql
CREATE TABLE pos_stores (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL CHECK (length(name) BETWEEN 1 AND 200),
slug text NOT NULL UNIQUE CHECK (slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$'),
address text,
tax_id text, -- CIF/NIF
contact_email text,
contact_phone text,
receipt_header text, -- printed on ticket top
receipt_footer text, -- printed on ticket bottom
settings jsonb NOT NULL DEFAULT '{}'::jsonb, -- free-form per-store overrides
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
```
**Backfill:** one row inserted by migration `043_pos_basics.js`:
```sql
INSERT INTO pos_stores (id, name, slug, active)
VALUES ('00000000-0000-0000-0000-000000000001', 'Mercado de Vida — Tienda principal', 'tienda-principal', true);
```
This UUID is referenced from the `inventory_stock` migration as the default store.
### 2.2 `pos_terminals`
A TPV device (tablet, all-in-one, desktop). Has a persistent UUID generated on first browser visit.
```sql
CREATE TABLE pos_terminals (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
store_id uuid NOT NULL REFERENCES pos_stores(id) ON DELETE RESTRICT,
name text NOT NULL CHECK (length(name) BETWEEN 1 AND 100),
binding_code text UNIQUE, -- one-time admin-supplied code
bound_at timestamptz,
status text NOT NULL DEFAULT 'active'
CHECK (status IN ('active','disabled','decommissioned')),
interface_mode text NOT NULL DEFAULT 'auto'
CHECK (interface_mode IN ('desktop','touch','auto')),
settings jsonb NOT NULL DEFAULT '{}'::jsonb, -- printer, scanner, etc.
last_seen_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX pos_terminals_store_idx ON pos_terminals(store_id);
CREATE INDEX pos_terminals_binding_code_idx ON pos_terminals(binding_code) WHERE binding_code IS NOT NULL;
```
**Binding flow** (Phase 4):
1. Admin generates a one-time code from `/admin/pos/terminals`.
2. Operator opens POS app for the first time → enters code → cookie `pos_terminal_id` is set, `bound_at` recorded.
### 2.3 `pos_cash_sessions`
One row per open or closed register shift.
```sql
CREATE TABLE pos_cash_sessions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
terminal_id uuid NOT NULL REFERENCES pos_terminals(id) ON DELETE RESTRICT,
store_id uuid NOT NULL REFERENCES pos_stores(id) ON DELETE RESTRICT,
user_id uuid NOT NULL REFERENCES backoffice_users(id) ON DELETE RESTRICT,
status text NOT NULL DEFAULT 'OPEN'
CHECK (status IN ('OPEN','CLOSED')),
opened_at timestamptz NOT NULL DEFAULT now(),
closed_at timestamptz,
opening_cash_cents integer NOT NULL DEFAULT 0 CHECK (opening_cash_cents >= 0),
closing_cash_cents integer, -- null while OPEN
expected_cash_cents integer, -- computed on close
actual_cash_cents integer, -- operator-counted on close
difference_cents integer, -- actual - expected; can be negative
notes text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Only one OPEN session per terminal at a time.
CREATE UNIQUE INDEX pos_cash_sessions_open_per_terminal_idx
ON pos_cash_sessions(terminal_id)
WHERE status = 'OPEN';
CREATE INDEX pos_cash_sessions_store_idx ON pos_cash_sessions(store_id);
CREATE INDEX pos_cash_sessions_user_idx ON pos_cash_sessions(user_id);
```
`expected_cash_cents` is computed by summing all `cash` payments and refunds within the session, then adding `opening_cash_cents` and subtracting `cash` refunds.
### 2.4 `pos_quick_products`
Configurable favorite-product grid per scope. Scope precedence: `(terminal, user) > (terminal) > (store) > global`. Phase 1 implementation only supports the `store` and `terminal` scopes (admin UI for per-user scope is deferred).
```sql
CREATE TABLE pos_quick_products (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
store_id uuid NOT NULL REFERENCES pos_stores(id) ON DELETE CASCADE,
terminal_id uuid REFERENCES pos_terminals(id) ON DELETE CASCADE,
user_id uuid REFERENCES backoffice_users(id) ON DELETE CASCADE,
variant_id uuid NOT NULL REFERENCES catalog_product_variants(id) ON DELETE CASCADE,
position integer NOT NULL DEFAULT 0,
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
-- At least one of (terminal_id, user_id) may be NULL; store_id is always set.
-- Prevent duplicates within the same scope.
CONSTRAINT pos_quick_products_unique_scope UNIQUE NULLS NOT DISTINCT
(store_id, terminal_id, user_id, variant_id)
);
```
### 2.5 `pos_parked_tickets`
Held tickets recoverable from any terminal in the same store.
```sql
CREATE TABLE pos_parked_tickets (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
store_id uuid NOT NULL REFERENCES pos_stores(id) ON DELETE CASCADE,
terminal_id uuid NOT NULL REFERENCES pos_terminals(id) ON DELETE RESTRICT,
user_id uuid NOT NULL REFERENCES backoffice_users(id) ON DELETE RESTRICT,
customer_id uuid REFERENCES identity_users(id) ON DELETE SET NULL,
label text, -- operator-supplied hint
cart jsonb NOT NULL, -- serialised cart (see §6)
expires_at timestamptz NOT NULL DEFAULT (now() + interval '24 hours'),
recalled_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX pos_parked_tickets_store_active_idx
ON pos_parked_tickets(store_id, created_at DESC)
WHERE recalled_at IS NULL;
```
### 2.6 `pos_payment_methods`
Configurable payment methods per store.
```sql
CREATE TABLE pos_payment_methods (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
store_id uuid NOT NULL REFERENCES pos_stores(id) ON DELETE CASCADE,
code text NOT NULL CHECK (length(code) BETWEEN 1 AND 32),
label text NOT NULL CHECK (length(label) BETWEEN 1 AND 64),
kind text NOT NULL CHECK (kind IN ('cash','card','other')),
active boolean NOT NULL DEFAULT true,
sort_order integer NOT NULL DEFAULT 0,
config jsonb NOT NULL DEFAULT '{}'::jsonb, -- e.g. card terminal id, cash change default
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (store_id, code)
);
CREATE INDEX pos_payment_methods_store_active_idx
ON pos_payment_methods(store_id, sort_order)
WHERE active;
```
Seed (in the same migration):
```sql
INSERT INTO pos_payment_methods (store_id, code, label, kind, sort_order)
VALUES
('00000000-0000-0000-0000-000000000001', 'cash', 'Efectivo', 'cash', 0),
('00000000-0000-0000-0000-000000000001', 'card', 'Tarjeta', 'card', 1);
```
---
## 3. Modified tables
### 3.1 `inventory_stock` — add `store_id`
```sql
ALTER TABLE inventory_stock
ADD COLUMN store_id uuid REFERENCES pos_stores(id) ON DELETE RESTRICT;
-- Backfill to the default store.
UPDATE inventory_stock SET store_id = '00000000-0000-0000-0000-000000000001'
WHERE store_id IS NULL;
ALTER TABLE inventory_stock
ALTER COLUMN store_id SET NOT NULL;
-- Replace single-variant unique constraint with composite.
ALTER TABLE inventory_stock DROP CONSTRAINT inventory_stock_variant_id_key;
ALTER TABLE inventory_stock
ADD CONSTRAINT inventory_stock_variant_store_unique UNIQUE (variant_id, store_id);
```
`InventoryService` API changes:
```ts
// Before
reserve(variantId: string, quantity: number): Promise<Reservation>
// After
reserve(variantId: string, storeId: string, quantity: number): Promise<Reservation>
```
This is a **breaking change** in the inventory module. The existing checkout flow (ecommerce) is updated to pass the default store; the POS passes the terminal's store.
### 3.2 `inventory_movements` — add `store_id`
The movement log is enriched with the store. Migration:
```sql
ALTER TABLE inventory_movements
ADD COLUMN store_id uuid REFERENCES pos_stores(id) ON DELETE RESTRICT;
UPDATE inventory_movements SET store_id = '00000000-0000-0000-0000-000000000001'
WHERE store_id IS NULL;
ALTER TABLE inventory_movements
ALTER COLUMN store_id SET NOT NULL;
```
### 3.3 `orders_orders` — add `source`, `terminal_id`, `cash_session_id`; add `COMPLETED` state
```sql
ALTER TABLE orders_orders
ADD COLUMN source text NOT NULL DEFAULT 'ecommerce'
CHECK (source IN ('ecommerce','pos','admin')),
ADD COLUMN terminal_id uuid REFERENCES pos_terminals(id) ON DELETE SET NULL,
ADD COLUMN cash_session_id uuid REFERENCES pos_cash_sessions(id) ON DELETE SET NULL;
CREATE INDEX orders_orders_source_idx ON orders_orders(source);
CREATE INDEX orders_orders_terminal_idx ON orders_orders(terminal_id) WHERE terminal_id IS NOT NULL;
CREATE INDEX orders_orders_cash_session_idx ON orders_orders(cash_session_id) WHERE cash_session_id IS NOT NULL;
-- Allow null user_id for walk-in sales (POS only).
ALTER TABLE orders_orders ALTER COLUMN user_id DROP NOT NULL;
ALTER TABLE orders_orders
ADD CONSTRAINT orders_orders_user_required_when_not_pos
CHECK (source <> 'pos' OR user_id IS NOT NULL OR TRUE); -- walk-ins allowed; tighten per business rule
-- Add COMPLETED to state machine.
ALTER TABLE orders_orders DROP CONSTRAINT orders_orders_state_check;
ALTER TABLE orders_orders
ADD CONSTRAINT orders_orders_state_check
CHECK (state IN ('PENDING','AWAITING_PAYMENT','PAID','PROCESSING','SHIPPED','DELIVERED','COMPLETED','CANCELLED','REFUNDED','PARTIALLY_REFUNDED'));
```
Update `OrderState` type in `src/modules/orders/domain/order.ts` and `ALLOWED_TRANSITIONS` in the same file:
```ts
COMPLETED: [], // terminal state for POS sales
```
POS sales flow: `PENDING → AWAITING_PAYMENT → PAID → COMPLETED`. The `COMPLETED` state has no outgoing transitions and is treated as terminal. Admin views show it like `DELIVERED` (i.e., closed).
### 3.4 `backoffice_users.role` — extend CHECK
```sql
ALTER TABLE backoffice_users DROP CONSTRAINT backoffice_users_role_check;
ALTER TABLE backoffice_users
ADD CONSTRAINT backoffice_users_role_check
CHECK (role IN ('admin','editor','pos_cashier','pos_manager'));
```
`shared/auth.ts` `Role` type updated accordingly.
### 3.5 `orders_items` — no changes
Already has `discount_cents` per line (good for POS line-level discounts).
### 3.6 `payments_transactions` — no changes
Already supports negative `amount_cents` for partial refunds (CHECK only enforces `>= 0` on insert, but the existing `payments` module allows negative amounts through the refund flow).
---
## 4. Entity-relationship diagram
```
┌──────────────┐ ┌──────────────────┐ ┌─────────────────────┐
│ pos_stores │◄────────│ pos_terminals │◄────────│ pos_cash_sessions │
│ │ 1 N │ │ 1 N │ │
│ id (PK) │ │ id (PK) │ │ id (PK) │
│ name │ │ store_id (FK) │ │ terminal_id (FK) │
│ slug │ │ name │ │ store_id (FK) │
│ settings │ │ binding_code │ │ user_id (FK) │
│ ... │ │ status │ │ status OPEN/CLOSED │
└──────┬───────┘ │ interface_mode │ │ opening_cash_cents │
│ │ settings │ │ closing_cash_cents │
│ 1 │ ... │ │ expected_cash_cents │
│ └──────┬───────────┘ │ actual_cash_cents │
│ │ │ difference_cents │
│ │ └─────────┬───────────┘
│ │ │
│ │ │ N
│ │ │
│ ▼ 1 │
│ ┌─────────────────────┐ │
│ │ orders_orders │◄──────────────────┘
│ │ │
│ │ id (PK) │
│ │ user_id (nullable) │
│ │ source ∈ │
│ │ {ecommerce,pos, │
│ │ admin} │
│ │ terminal_id (FK) │
│ │ cash_session_id (FK)│
│ │ state + COMPLETED │
│ └─────────┬───────────┘
│ │ 1
│ │
│ │ N
│ ┌─────────▼───────────┐
│ │ orders_items │
│ │ variant_id (FK) │
│ │ sku, ean, name │
│ │ unit_price_cents │
│ │ discount_cents │
│ │ tax_cents │
│ │ quantity │
│ └─────────────────────┘
│ 1
▼ N
┌──────────────────┐ ┌────────────────────┐
│ inventory_stock │ │ inventory_movements│
│ variant_id (FK) │ │ variant_id (FK) │
│ store_id (FK) ───┼────────►│ store_id (FK) │
│ available │ │ operation │
│ reserved │ │ quantity │
│ sold │ └────────────────────┘
│ incoming │
│ UNIQUE(variant, │
│ store) │
└──────────────────┘
┌──────────────────────┐ ┌─────────────────────────┐
│ pos_quick_products │ │ pos_parked_tickets │
│ store_id (FK) │ │ store_id (FK) │
│ terminal_id (FK,?) │ │ terminal_id (FK) │
│ user_id (FK,?) │ │ user_id (FK) │
│ variant_id (FK) │ │ customer_id (FK,?) │
│ position │ │ cart (jsonb) │
└──────────────────────┘ └─────────────────────────┘
┌──────────────────────┐
│ pos_payment_methods │
│ store_id (FK) │
│ code, label, kind │
│ active, sort_order │
└──────────────────────┘
```
---
## 5. Migration plan
| # | Filename | Purpose |
|---|---|---|
| 043 | `043_pos_basics.js` | Create `pos_stores`, `pos_terminals`, `pos_payment_methods`, `pos_quick_products`, `pos_parked_tickets`, `pos_cash_sessions`. Seed default store and seed payment methods. |
| 044 | `044_pos_inventory_store.js` | Add `store_id` to `inventory_stock` and `inventory_movements`. Backfill. Update unique constraint. |
| 045 | `045_pos_orders_source.js` | Add `source`, `terminal_id`, `cash_session_id` to `orders_orders`. Drop NOT NULL on `user_id`. Add `COMPLETED` to state CHECK. |
| 046 | `046_pos_backoffice_roles.js` | Extend `backoffice_users.role` CHECK with `pos_cashier` and `pos_manager`. |
| 047 | `047_pos_seed_default_store.js` | (Already part of 043; placeholder for future operator-specific seeding.) |
These run in numeric order. Each is idempotent and reversible.
---
## 6. Cart shape (for parked tickets JSON)
The `cart` column in `pos_parked_tickets` stores:
```ts
interface ParkedCart {
version: 1;
items: Array<{
productId: string;
variantId: string;
sku: string;
ean: string | null;
name: string;
unitPriceCents: number; // captured at park time
discountCents: number; // line discount
taxCents: number;
quantity: number;
}>;
customerId: string | null;
globalDiscountCents: number;
notes?: string;
}
```
Prices are stored at park time as a snapshot, but on recall the cart re-prices server-side from current prices (with a warning if they changed).
---
## 7. Audit
Every state-changing operation appends to `security_audit_log` via `AuditLogger.log` with a stable `action` key:
| Action | Target |
|---|---|
| `pos.store.created` | `store:{id}` |
| `pos.store.updated` | `store:{id}` |
| `pos.terminal.bound` | `terminal:{id}` |
| `pos.session.opened` | `session:{id}` |
| `pos.session.closed` | `session:{id}` |
| `pos.sale.created` | `order:{id}` |
| `pos.sale.refunded` | `order:{id}` |
| `pos.sale.discount.line` | `order:{id}:item:{idx}` |
| `pos.sale.discount.global` | `order:{id}` |
| `pos.sale.price.overridden` | `order:{id}:item:{idx}` |
| `pos.sale.cancelled` | `order:{id}` |
| `pos.ticket.parked` | `ticket:{id}` |
| `pos.ticket.recalled` | `ticket:{id}` |
The actor is the authenticated user; metadata includes `terminalId`, `storeId`, and the relevant cents amount.
---
## 8. Indexes summary
```sql
-- New
CREATE INDEX pos_terminals_store_idx ON pos_terminals(store_id);
CREATE INDEX pos_terminals_binding_code_idx ON pos_terminals(binding_code) WHERE binding_code IS NOT NULL;
CREATE UNIQUE INDEX pos_cash_sessions_open_per_terminal_idx ON pos_cash_sessions(terminal_id) WHERE status = 'OPEN';
CREATE INDEX pos_cash_sessions_store_idx ON pos_cash_sessions(store_id);
CREATE INDEX pos_cash_sessions_user_idx ON pos_cash_sessions(user_id);
CREATE INDEX pos_quick_products_store_idx ON pos_quick_products(store_id);
CREATE INDEX pos_parked_tickets_store_active_idx ON pos_parked_tickets(store_id, created_at DESC) WHERE recalled_at IS NULL;
CREATE INDEX pos_payment_methods_store_active_idx ON pos_payment_methods(store_id, sort_order) WHERE active;
CREATE INDEX orders_orders_source_idx ON orders_orders(source);
CREATE INDEX orders_orders_terminal_idx ON orders_orders(terminal_id) WHERE terminal_id IS NOT NULL;
CREATE INDEX orders_orders_cash_session_idx ON orders_orders(cash_session_id) WHERE cash_session_id IS NOT NULL;
-- Modified
ALTER TABLE inventory_stock ADD CONSTRAINT inventory_stock_variant_store_unique UNIQUE (variant_id, store_id);
DROP INDEX inventory_stock_variant_id_idx; -- if exists
CREATE INDEX inventory_stock_variant_store_idx ON inventory_stock(variant_id, store_id);
```
---
## 9. Constraints checklist
- ✅ Every monetary amount uses `*_cents integer` with explicit CHECK constraints.
- ✅ FK rules declared: `RESTRICT` for terminal↔session, `CASCADE` for quick products / parked tickets / payment methods on parent delete, `SET NULL` for `terminal_id` / `cash_session_id` / `customer_id` on order (so deleting a terminal doesn't kill historical sales).
- ✅ CHECK on `state` keeps the existing states plus `COMPLETED`.
- ✅ Unique partial index `pos_cash_sessions_open_per_terminal_idx` enforces one open session per terminal at the DB level.
- ✅ All migrations are idempotent and reversible (where the change is reversible).

365
docs/pos/POS_HARDWARE.md Normal file
View File

@@ -0,0 +1,365 @@
# POS Hardware — Mercado de Vida
> **Companion to:** [`POS_ARCHITECTURE.md`](./POS_ARCHITECTURE.md)
> **Status:** Discovery (Phase 1)
This document describes how POS hardware (scanner, printer, cash drawer, payment terminal, scale) integrates with the application. The guiding rule: **the application core never imports a vendor SDK**. Every device is reached through an adapter interface, with two implementations shipped (browser-first, native bridge second).
---
## 1. Adapter contract overview
All adapters live under `project/src/shared/hardware/` and follow the same shape:
```ts
// src/shared/hardware/types.ts
export interface HardwareAdapter<TConfig = unknown> {
/** Human-readable label for the admin UI. */
readonly kind: 'scanner' | 'printer' | 'cash-drawer' | 'payment-terminal' | 'scale';
/** Configure at boot. Idempotent. Throws on unrecoverable misconfiguration. */
configure(config: TConfig): Promise<void>;
/** Optional health check. Returns null if healthy. */
healthCheck(): Promise<{ ok: true } | { ok: false; reason: string }>;
}
```
Each adapter has its own narrow interface on top of this base:
```ts
export interface ScannerAdapter extends HardwareAdapter<ScannerConfig> {
onCode(callback: (code: string) => void): () => void;
}
export interface PrinterAdapter extends HardwareAdapter<PrinterConfig> {
print(receipt: ReceiptPayload): Promise<
| { ok: true; jobId: string }
| { ok: false; reason: 'offline' | 'paper-out' | 'error'; message: string }
>;
}
export interface CashDrawerAdapter extends HardwareAdapter<CashDrawerConfig> {
open(): Promise<{ ok: boolean; reason?: string }>;
}
export interface PaymentTerminalAdapter extends HardwareAdapter<PaymentTerminalConfig> {
requestPayment(input: {
amountCents: number;
currency: 'EUR';
reference: string;
}): Promise<
| { ok: true; providerPaymentId: string; authCode: string; cardLast4?: string }
| { ok: false; reason: 'declined' | 'timeout' | 'offline' | 'error'; message: string }
>;
}
export interface ScaleAdapter extends HardwareAdapter<ScaleConfig> {
readGrams(): Promise<number | null>;
}
```
The POS UI imports only these interfaces. Implementation selection happens via a single factory in `apps/pos/src/lib/hardware/factory.ts`:
```ts
export function createScanner(): ScannerAdapter {
// Phase 2: always the browser impl.
return new BrowserScannerAdapter();
// Phase 7: read window.__MDV_HARDWARE_CONFIG__ or env to pick native bridge.
}
```
---
## 2. Scanner
### 2.1 Behaviour
Most physical barcode scanners behave as **HID keyboard devices**: they read a code, type it character by character, then send `Enter` (configurable). The browser implementation captures this pattern:
```ts
export class BrowserScannerAdapter implements ScannerAdapter {
private buffer = '';
private listener?: (e: KeyboardEvent) => void;
private callbacks: Array<(code: string) => void> = [];
configure(config: ScannerConfig): Promise<void> {
this.config = config;
this.detach();
this.attach();
return Promise.resolve();
}
onCode(callback: (code: string) => void): () => void {
this.callbacks.push(callback);
return () => {
this.callbacks = this.callbacks.filter((cb) => cb !== callback);
};
}
private attach() {
this.listener = (e) => {
// Ignore if focus is in an editable field (manual typing, not scanner).
if (this.shouldIgnore(e)) return;
if (e.key === 'Enter') {
if (this.buffer.length >= this.config.minLength) {
this.callbacks.forEach((cb) => cb(this.buffer));
}
this.buffer = '';
e.preventDefault();
return;
}
if (e.key.length === 1) {
this.buffer += e.key;
}
};
window.addEventListener('keydown', this.listener);
}
private shouldIgnore(e: KeyboardEvent): boolean {
const target = e.target as HTMLElement | null;
if (!target) return false;
const tag = target.tagName.toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select') return true;
if (target.isContentEditable) return true;
return false;
}
private detach() {
if (this.listener) {
window.removeEventListener('keydown', this.listener);
this.listener = undefined;
}
}
healthCheck(): Promise<{ ok: true } | { ok: false; reason: string }> {
// Browser impl has no self-test; always OK while the listener is attached.
return Promise.resolve(this.listener ? { ok: true } : { ok: false, reason: 'not attached' });
}
}
```
The "ignore if focus is in an editable field" rule is what lets the same browser session have a search input (manual typing) AND a scanner (HID stream). The scanner input element is removed from focus when not actively used; the search input can take focus back when the operator clicks it.
### 2.2 UX contract
- After a successful scan, the cart focus stays on the search input.
- If the scanned code does not resolve, a `toast.error('Producto no encontrado: <code>')` shows for 3 s.
- If the scanned code resolves to a product already in the cart, quantity increments.
- `pos_terminal.settings.scanner.minLength` (default 6) filters out accidental single-character noise.
### 2.3 Native bridge (Phase 7, deferred)
A sidecar process (Node addon or external HTTP service) reads from `/dev/hidrawN` (Linux) or via WinUSB (Windows) and forwards to the POS app over a localhost HTTP socket. Not built in this project unless the operator commits to a vendor.
---
## 3. Printer
### 3.1 Browser implementation
The browser uses `window.print()` on a hidden iframe that contains a print-stylesheet-only route `/print/[orderId]`. The route fetches the `ReceiptDto`, renders a fixed-width 80mm layout, and triggers print.
```ts
export class BrowserPrinterAdapter implements PrinterAdapter {
configure(_config: PrinterConfig): Promise<void> { return Promise.resolve(); }
async print(receipt: ReceiptPayload): Promise<
| { ok: true; jobId: string }
| { ok: false; reason: 'offline' | 'paper-out' | 'error'; message: string }
> {
const jobId = crypto.randomUUID();
const url = `/print/${receipt.orderId}?jobId=${jobId}`;
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.src = url;
document.body.appendChild(iframe);
return new Promise((resolve) => {
iframe.addEventListener('load', () => {
try {
iframe.contentWindow?.focus();
iframe.contentWindow?.print();
resolve({ ok: true, jobId });
} catch (err) {
resolve({ ok: false, reason: 'error', message: String(err) });
} finally {
setTimeout(() => iframe.remove(), 1000);
}
});
});
}
healthCheck() { return Promise.resolve({ ok: true }); }
}
```
### 3.2 Print layout
The print route at `apps/pos/src/app/print/[orderId]/page.tsx` renders an 80mm-wide layout using plain CSS. Sample structure:
```
─────────────────────────────────
MERCADO DE VIDA
Calle Falsa 123
CIF B12345678
─────────────────────────────────
Ticket: T-2026-000123
Fecha: 2026-08-21 18:42
Caja: POS-01 · Ana M.
─────────────────────────────────
Almendras Crudas Bio
8412345678901 1 × 4,55 €
Descuento -0,50 €
4,05 €
─────────────────────────────────
SUBTOTAL 4,55 €
DESCUENTO -0,50 €
IVA (10%) 0,37 €
TOTAL 4,05 €
─────────────────────────────────
Pago: Efectivo
Entregado: 10,00 €
Cambio: 5,95 €
─────────────────────────────────
Gracias por su compra
─────────────────────────────────
```
### 3.3 Native ESC/POS bridge (Phase 7)
A Node sidecar accepts POST `/print` with a `ReceiptPayload`, formats it as ESC/POS commands, and writes to the USB or networked printer. The native bridge is **not built** in this codebase unless the operator chooses a vendor.
---
## 4. Cash drawer
### 4.1 Browser implementation
In browsers, the cash drawer is normally triggered by the printer (most thermal printers have an `ESC p 0` kick-out command). The browser adapter delegates to the printer adapter:
```ts
export class BrowserCashDrawerAdapter implements CashDrawerAdapter {
constructor(private printer: PrinterAdapter) {}
async open(): Promise<{ ok: boolean; reason?: string }> {
// The browser printer uses window.print() which can't send the ESC p 0
// command. We emit a meta-receipt: a "Drawer open" page that the operator
// confirms. In practice, the cash drawer opens automatically when the
// receipt is printed (the printer sends the kick-out on real hardware).
return { ok: true };
}
}
```
In practice, the cash drawer opens when the printer finishes printing a receipt (because the printer's `kick-out` pin is wired to the drawer). The browser adapter does nothing — the act of printing IS the act of opening the drawer, and the operator hears the click.
### 4.2 Native bridge
For Phase 7 native ESC/POS: the sidecar sends the explicit `ESC p 0` byte sequence after the receipt, then waits for an optional status from the printer confirming the kick-out.
---
## 5. Payment terminal (datáfono)
### 5.1 Browser implementation (manual entry)
Phase 3 ships with a manual-entry form: the operator types the auth code returned by the datáfono. This is the fallback for any vendor and the only Phase 3 implementation:
```ts
export class ManualPaymentTerminalAdapter implements PaymentTerminalAdapter {
configure(_config: PaymentTerminalConfig) { return Promise.resolve(); }
// No automatic request — operator-driven via UI form.
async requestPayment(): Promise<never> {
throw new Error('ManualPaymentTerminalAdapter does not auto-request; use the UI form.');
}
healthCheck() { return Promise.resolve({ ok: true }); }
}
```
The POS UI shows a "Tarjeta" panel with amount + an input for the operator to type the auth code. On submit, the `payments` entry is created with `provider='manual-card'`, `providerPaymentId=<operator input>`, `status='succeeded'`.
### 5.2 Native bridge (Phase 7, vendor-specific)
SumUp, Redsys TPVO, generic Verifone: each gets a Node addon or HTTP client wrapping the vendor's SDK. The native bridge speaks to the terminal over Bluetooth or USB and returns an `ok` or `declined` result without ever exposing PAN/CVV to the web app.
**Security:** the POS app never stores PAN, CVV, or PIN. The native bridge holds the only credential to the vendor API.
---
## 6. Scale (báscula)
### 6.1 Browser implementation
In Phase 4 we do not read from a real scale. The product detail for `sale_type: 'weight'` shows a "Read weight" button that opens a manual entry keypad. The operator types the weight and presses Enter.
### 6.2 Native bridge (Phase 7)
A small Node service reads from the scale's serial port (most use RS232 or USB-HID) and exposes `GET /weight` returning grams. The browser calls this endpoint via `fetch` to a known localhost URL.
---
## 7. Configuration model
Each terminal stores per-device configuration in `pos_terminals.settings` (JSONB):
```ts
interface TerminalSettings {
scanner?: {
minLength?: number; // default 6
terminator?: 'Enter' | 'Tab'; // default 'Enter'
};
printer?: {
kind: 'browser'; // only browser in Phase 2
copies: number; // default 1
headerLines: string[]; // override store.receiptHeader
footerLines: string[];
};
cashDrawer?: { kind: 'browser' | 'native' };
paymentTerminal?: { kind: 'manual' | 'native' };
scale?: { kind: 'manual' | 'native' };
interface?: {
mode: 'auto' | 'desktop' | 'touch';
locale: 'es-ES';
};
}
```
Settings are edited from `/pos/admin/terminals/:id` (admin) and read-only from `/pos/terminals/me`.
---
## 8. Health & observability
The POS app shows connection status in the header:
- `● Online` (green) if `GET /health` returned 200 in the last 30 s.
- `⚠ Sin conexión` (amber) if the last 3 health checks failed.
The same status is reflected in `pos_terminals.last_seen_at` via a 60-second heartbeat ping.
A future dashboard (Phase 7) will graph `printerAdapter.print` success/failure rates, `scannerAdapter.onCode` invocations per hour, etc., using the existing `observability` module.
---
## 9. Vendor-neutral philosophy
The brief explicitly forbids coupling the UI to a vendor. Our adapter pattern enforces this by:
1. **Zero vendor imports** in `apps/pos/src/` or `project/src/modules/pos/`.
2. **Vendor SDKs only** in `project/src/shared/hardware/native/<vendor>/` — a subdirectory added per vendor only when the operator commits.
3. **The native bridge** is a separate Node process (out of repo) that the POS app talks to over HTTP. If the operator picks SumUp, they deploy the `sumup-bridge`; if Verifone, the `verifone-bridge`. The POS core does not change.
---
## 10. Open questions for the operator
1. **Printer model** for ESC/POS commands (Epson, Star, Citizen)?
2. **Datáfono provider** (none for Phase 3; SumUp / Redsys TPVO / Verifone for Phase 7)?
3. **Scale brand** if any (Phase 7+; not needed for Phase 2-6)?
4. **Cash drawer wiring**: kick-out via printer, or via separate RJ11/RJ12 port?
5. **Scanner type**: HID-only, or do we need serial/USB-HID native drivers?

369
docs/pos/POS_OPERATIONS.md Normal file
View File

@@ -0,0 +1,369 @@
# POS Operations — Mercado de Vida
> **Companion to:** [`POS_ARCHITECTURE.md`](./POS_ARCHITECTURE.md)
> **Status:** Discovery (Phase 1)
Operational playbook: how to install, configure, run, monitor, troubleshoot, and roll back the POS. Intended for the operator (who deploys and runs the system in stores) and the engineer on call.
---
## 1. Deployment
### 1.1 Topology
```
[ Operator's LAN ]
├── Backend host (existing monolith)
│ └── :3000 Fastify backend + PostgreSQL + Redis
├── Admin host (existing admin app)
│ └── :3004 Next.js admin
└── POS terminals (new — one per TPV device)
└── :3006 Next.js POS app
(each terminal needs a unique terminal id, stored in
a Secure cookie; the binding code is generated by admin)
```
The POS app is delivered through the existing `monolith.sh` script as a new service `pos` on port 3006 (configurable via `POS_PORT` env var). It shares the backend at `:3000` via the same Next.js catch-all proxy pattern as the admin app.
### 1.2 First-time setup (per store)
1. **Run migrations** on the backend host:
```bash
cd project
npm run build
npm run db:status # confirm migrations are tracked
```
The POS migrations (`043``046`) run automatically with the next `npm run db:migrate`. They are additive and non-destructive.
2. **Create the default store** via the admin panel at `/admin/pos/stores` (after Phase 4 ships the admin UI) — or seed manually:
```sql
INSERT INTO pos_stores (name, slug) VALUES ('Mercado de Vida — Tienda principal', 'tienda-principal');
```
3. **Generate binding codes** for each TPV device from `/admin/pos/terminals`.
4. **On each TPV device**, open the POS app and enter the binding code on first launch. The browser stores `pos_terminal_id` in a Secure cookie for one year.
5. **Configure POS users**: assign `pos_cashier` or `pos_manager` to the existing backoffice users who will operate the register.
### 1.3 POS app deployment
```bash
# Build the POS app
cd project/apps/pos
npm run build
# Or use the monolith script
./scripts/monolith.sh prod restart
```
The monolith script handles each app independently; a single `restart` rebuilds and respawns all services.
---
## 2. Daily operations
### 2.1 Opening a register
The first action of the cashier's shift:
1. Login at `https://<host>:3006/login`.
2. The terminal shows "Caja cerrada" with an "Abrir caja" button.
3. Click "Abrir caja" → enter the opening cash amount (e.g., 100,00 € for the standard float) → confirm.
4. The session is created with `status='OPEN'`, `openingCashCents=10000`. The UI now shows the main sale screen.
Audit: `pos.session.opened` is written to `security_audit_log` with `actorId`, `terminalId`, `openingCashCents`.
### 2.2 Making a sale
Standard flow (online):
1. Cashier scans a product (or types in the search box).
2. Product is added to the cart.
3. Repeat for additional products.
4. Optional: associate a customer (search by email/phone).
5. Optional: apply a line or global discount (within server-enforced limits).
6. Click "Cobrar".
7. Choose payment method (Efectivo / Tarjeta / Otro).
8. For cash: enter amount tendered, see the change. Click "Confirmar".
9. For card: enter the auth code from the datáfono. Click "Confirmar".
10. Server creates the order + reservation + payment + COMPLETED state in one transaction.
11. Receipt is printed automatically.
12. Cart clears for the next sale.
Audit: `pos.sale.created` with all relevant fields.
### 2.3 Closing a register
End of shift:
1. Click "Cerrar caja" in the header.
2. The system shows:
- Ventas en efectivo: count + total
- Ventas con tarjeta: count + total
- Otros pagos: count + total
- Devoluciones: count + total
- Efectivo esperado: server-computed (opening + cash sales cash refunds)
3. Cashier counts the cash in the drawer and enters the actual amount.
4. Server computes `differenceCents = actualCashCents expectedCashCents`.
5. Session is closed (`status='CLOSED'`, `closedAt=now()`).
6. Audit: `pos.session.closed` with all totals and the difference.
### 2.4 Held / parked tickets
If a customer walks away mid-sale:
1. Click "Aparcar" → enter an optional label ("Juan — vuelve en 10 min").
2. The cart is persisted in `pos_parked_tickets` with a 24-hour expiry.
3. To recall: go to "Tickets aparcados", click the entry → cart is restored (with a re-price warning if prices changed).
Parked tickets can be recalled from **any terminal in the same store**.
---
## 3. Roles & permissions
| Role | Can open/close | Can sell | Can override price | Can apply discount | Can refund |
|---|---|---|---|---|---|
| `pos_cashier` | ✅ | ✅ | ❌ | up to configured max | ❌ |
| `pos_manager` | ✅ | ✅ | ✅ | ✅ | ✅ (any amount) |
| `admin` | ✅ | ✅ | ✅ | ✅ | ✅ |
Roles are stored on `backoffice_users.role`. Permission constants are checked server-side via `requireRole` / `requireAnyRole`.
Discount limits:
- Line discount: default 50 % of the line gross (configurable in `store_settings`).
- Global discount: default 20 % of the total.
- Overrides: require `POS_OVERRIDE_PRICE` permission.
All discounts are logged in the audit with the actor's identity, the discount amount, and the affected line or order.
---
## 4. Monitoring
### 4.1 Health endpoint
The backend already exposes `GET /health` via the `health` module. The POS app polls this every 10 s and renders `● Online` / `⚠ Sin conexión` in the header.
### 4.2 Active sessions
The admin UI at `/admin/pos/sessions` (Phase 4) shows all open sessions in real time. Useful for the operator to see "which cashiers are logged in where".
### 4.3 Audit log
All sensitive actions land in `security_audit_log` with the `action` namespace `pos.*`:
```sql
SELECT created_at, actor_id, action, target, metadata
FROM security_audit_log
WHERE action LIKE 'pos.%'
ORDER BY created_at DESC
LIMIT 100;
```
### 4.4 Sales metrics
The POS-specific metrics (Phase 6) use the existing `observability` module:
- `pos.sale.count` (counter)
- `pos.sale.amount_cents_total` (counter)
- `pos.sale.duration_ms` (histogram)
- `pos.scanner.scans_count` (counter)
- `pos.scanner.unknown_ean_count` (counter)
- `pos.printer.print_failures` (counter)
These are exposed at `/metrics` in Prometheus format (existing endpoint) and can be scraped by the operator's monitoring stack.
---
## 5. Failure modes & troubleshooting
### 5.1 Network down mid-sale
- The "Cobrar" request fails.
- The UI shows `Error de red. Reintenta.` with a retry button.
- The cart is preserved.
- Once online, retry succeeds; if the operator retries twice with the same cart, the server's `idempotencyKey` ensures only one order is created (the second call returns the existing order).
### 5.2 Stock conflict (oversell)
- Two terminals both scan the last unit.
- The first "Cobrar" succeeds; the second gets `409 POS_SALE_STOCK_UNAVAILABLE`.
- The UI shows `Stock insuficiente para «<producto>». Ajusta el carrito o cancela la venta.`
- The cashier removes the offending item and retries.
### 5.3 Printer offline
- The receipt print fails; `printerAdapter.print` returns `{ ok: false, reason: 'offline' }`.
- The UI shows a banner: `Impresora no disponible. Pulsa para reintentar.` with a "Reimprimir" button.
- The sale is still valid in the system; the receipt can be reprinted from `/pos/sales/:id`.
### 5.4 Cash drawer doesn't open
- In Phase 2 (browser): the cash drawer opens via the printer's `kick-out`. If the printer is offline, the drawer does not open; the cashier uses the manual key.
### 5.5 Datáfono declined
- The POS UI shows `Pago con tarjeta rechazado. Introduce otro método o cobra en efectivo.`
- The cart is preserved; the cashier retries with cash or another method.
### 5.6 Session expired / lost
- The session cookie (HTTP-only, 30 days) expires.
- The next request returns `401 UNAUTHORIZED`.
- The POS UI redirects to `/login`. The cart is preserved client-side.
### 5.7 Discount over limit
- The server returns `422 DISCOUNT_LIMIT_EXCEEDED`.
- The UI highlights the discount field and explains: "Descuento máximo por línea: 50 %".
### 5.8 Parked ticket expires
- A ticket parked >24 h is auto-archived (Phase 6+).
- A warning shows in the UI when recalling: "Este ticket fue guardado hace más de 24 horas."
---
## 6. Rollback plan
### 6.1 Phase 2 schema rollback
If the operator wants to revert Phase 2 after deployment:
```bash
cd project
node --env-file-if-exists=.env node_modules/node-pg-migrate/bin/node-pg-migrate.js down --migrations-dir migrations --to 042
```
This reverts migrations 043046. Side effects:
- `pos_*` tables are dropped.
- `inventory_stock.store_id` column is dropped (data not deleted; the column is removed; rollback of 044 is destructive if rows have been modified to use store-scoped stock).
- `orders_orders.source` column is dropped — existing rows have `source='ecommerce'` after rollback, so they're restored to their pre-POS state.
- `COMPLETED` order state is rejected by the original CHECK constraint; any open POS orders must be moved to `DELIVERED` first or the rollback will fail.
**Recommended:** export data before rollback:
```bash
pg_dump --data-only --table='pos_*' --table='orders_orders' mercadodevida > pos-backup-$(date +%F).sql
```
### 6.2 Code rollback
The POS app is independent of the admin and frontend. To roll back the code:
```bash
cd project
./scripts/monolith.sh prod restart --skip-pos # if supported in monolith script
# Or manually:
cd apps/pos
git checkout <previous-commit>
npm run build
# Restart the pos service.
```
### 6.3 Zero-data-loss guarantee
All POS operations are append-only against `orders_orders` and `pos_cash_sessions`. Refunds and cancellations create new rows rather than mutating history. Reverting the schema preserves the original `orders_orders` rows; only the `source`, `terminal_id`, and `cash_session_id` columns are dropped, which can be reconstructed from the audit log if needed.
---
## 7. Security notes
- **No PAN/CVV storage.** Card auth codes are stored in `payments_transactions.raw` (JSONB) as `provider_auth_code` (string). They are the vendor-issued reference, not the card data.
- **Cookies are Secure + HttpOnly + SameSite=Lax.** The catch-all proxy strips `Secure` for `localhost`; production deployments must terminate TLS at the proxy and set `Secure` on the cookie.
- **Audit retention:** the existing `security_audit_log` has no retention policy in the project; the operator is responsible for archival (a follow-up ticket).
- **Rate limits:** see `POS_API.md` §14.
- **CSRF:** the same-site cookies + the existing `credentials: include` pattern in `api-client.ts` protect against CSRF for state-changing endpoints. The POS app does not introduce new attack surfaces here.
---
## 8. Backup & disaster recovery
- The PostgreSQL backup story is unchanged: the operator's existing cron-driven `pg_dump` covers all `pos_*` tables.
- The `pos_terminal.settings` JSONB stores per-device configuration; backups restore it automatically.
- Parked tickets live in `pos_parked_tickets`; backups restore them.
---
## 9. Smoke test (post-deploy)
After every POS deployment, run this 5-minute manual smoke test on at least one terminal:
1. Open the register with 100 € float.
2. Scan a known product (e.g. `8412345678901`) — should appear in the cart.
3. Type a name in the search box — should suggest products.
4. Click a quick-product button — should add to the cart.
5. Click "Cobrar" → "Efectivo" → enter 50 € → change should display.
6. Confirm sale — receipt should print (or browser print dialog opens).
7. Verify in admin `/admin/orders` that the order exists with `source='pos'`, state `COMPLETED`, total correct.
8. Verify in admin `/admin/inventory` that stock decreased for the variant.
9. Open "Tickets aparcados" — park current cart.
10. Reload page — recall ticket — cart restored.
11. Close the register — count cash — verify the difference matches expectation.
12. Sign out — verify session is destroyed and redirect to login.
---
## 10. Runbooks
### 10.1 "POS cannot reach backend"
```bash
# On the POS host
curl -s http://<backend-host>:3000/health
# If timeout: check backend host
ssh backend-host 'systemctl status mercadodevida'
# If backend is up but POS can't reach it: check firewall / LAN
```
### 10.2 "I refunded a sale by mistake"
Refunds create new `payments_transactions` rows (negative amounts). To undo:
```sql
-- 1. Find the refund
SELECT id, order_id, amount_cents, raw
FROM payments_transactions
WHERE order_id = '<order-id>'
AND amount_cents < 0
ORDER BY created_at DESC LIMIT 1;
-- 2. Cancel the refund (Phase 5 admin UI handles this; below is the SQL fallback)
DELETE FROM payments_transactions WHERE id = '<refund-id>';
-- 3. Update order state back to COMPLETED
UPDATE orders_orders SET state = 'COMPLETED' WHERE id = '<order-id>';
-- 4. Reduce stock again (Phase 5 admin UI does this automatically)
UPDATE inventory_stock SET available = available - <qty>, sold = sold + <qty>
WHERE variant_id = '<variant-id>' AND store_id = '<store-id>';
```
This is a manual intervention; the operator should document it in the audit log.
### 10.3 "Two cashiers opened the same terminal"
The DB-level unique index `pos_cash_sessions_open_per_terminal_idx` prevents this. The second `POST /pos/sessions` returns `409 POS_SESSION_ALREADY_OPEN`. The cashier sees the error and the existing session id.
---
## 11. Operator checklist (Phase 1 → Phase 2)
- [ ] Approve POS_ARCHITECTURE.md, POS_DATA_MODEL.md, POS_API.md, POS_HARDWARE.md, POS_OPERATIONS.md.
- [ ] Confirm default store name for migration 043.
- [ ] Confirm cookie domain for the POS app (same origin as admin? subdomain?).
- [ ] Provide list of TPV devices and binding strategy.
- [ ] Schedule a downtime window for migration 044 (multi-store inventory).
- [ ] Schedule training for cashiers on the new register flow.
Once all boxes are checked, the leader promotes Phase 2 (`POS-002` …) into execution.

451
docs/pos/POS_TASKS.md Normal file
View File

@@ -0,0 +1,451 @@
# POS Tasks — Mercado de Vida
> **Companion to:** [`POS_ARCHITECTURE.md`](./POS_ARCHITECTURE.md) and the other `docs/pos/*.md`
> **Status:** Discovery (Phase 1)
The POS implementation is split into 7 phases (per the brief) and each phase is broken into small, single-purpose tickets. Tickets follow the existing Orquestra pattern (`scripts/new_ticket.py --id POS-NNN`) and are sequenced in `backlog/features.json`.
Priority tiers:
- **P0** — imprescindible. Without these, no sale can happen.
- **P1** — importante. Daily operations break without them.
- **P2** — mejora. Polish + efficiency.
- **P3** — futuro. Operator-confirmed future work (offline, multi-currency, native hardware).
Each ticket follows the same shape:
```
POS-NNN [P?] [Phase] Title
Why: one-sentence rationale
Scope IN: bullet list
Scope OUT: bullet list
Touches: files / modules / migrations affected
Acceptance: 3-7 testable criteria
```
---
## Phase 0 — Discovery
### `POS-001` — POS Discovery & Architecture *(in progress)*
- **Why:** Establish shared understanding before any code is written. Identify reusable components and gaps.
- **Status:** this phase (the 5 docs under `docs/pos/` + this file).
---
## Phase 1 — Core TPV
### `POS-002` [P0] [Phase 1] Schema migrations 043046 (POS basics)
- **Why:** All other code depends on the new tables and extended columns.
- **Scope IN:** migrations `043_pos_basics.js` (stores, terminals, payment_methods, quick_products, parked_tickets, cash_sessions), `044_pos_inventory_store.js` (add `store_id` to `inventory_stock` and `inventory_movements`, backfill, swap unique constraint), `045_pos_orders_source.js` (add `source`, `terminal_id`, `cash_session_id`, drop NOT NULL on `user_id`, add `COMPLETED` to state CHECK), `046_pos_backoffice_roles.js` (extend role CHECK).
- **Scope OUT:** No backend modules yet. No frontend.
- **Touches:** `project/migrations/`, `project/src/modules/orders/domain/order.ts` (add `COMPLETED` to `OrderState` and `ALLOWED_TRANSITIONS`), `project/src/shared/auth.ts` (extend `Role` type).
- **Acceptance:** all 4 migrations apply on a fresh DB and on the current dev DB; existing data is preserved; `verify.sh` exits 0; smoke test queries match the expected counts.
### `POS-003` [P0] [Phase 1] `pos` module skeleton (domain + repos + service)
- **Why:** Provide the application layer for stores, terminals, sessions, payment methods.
- **Scope IN:** `project/src/modules/pos/{domain,application,infrastructure}/`. Files: `domain/{store,terminal,cash-session,errors}.ts`, `infrastructure/pg-store-repository.ts`, `infrastructure/pg-terminal-repository.ts`, `infrastructure/pg-payment-method-repository.ts`, `application/{list-stores,list-terminals,get-pos-config,open-cash-session,close-cash-session}.ts`.
- **Scope OUT:** No routes yet (covered by POS-004).
- **Touches:** `project/src/modules/pos/**` (new), `project/src/app/build-app.ts` (wire the repos in deps).
- **Acceptance:** unit tests cover each use case; the `pos` module is registered in `build-app.ts` without breaking the existing composition; `verify.sh` exits 0.
### `POS-004` [P0] [Phase 1] POS API — config, stores, terminals, sessions
- **Why:** First batch of endpoints; allows the frontend to bootstrap and the admin to manage terminals.
- **Scope IN:** `project/src/modules/pos/api/pos.routes.ts` with endpoints: `GET /pos/admin/stores`, `POST /pos/admin/stores`, `GET /pos/admin/terminals`, `POST /pos/admin/terminals` (returns `bindingCode`), `GET /pos/terminals/me`, `POST /pos/terminals/bind`, `POST /pos/sessions`, `POST /pos/sessions/:id/close`, `GET /pos/sessions/me`, `GET /pos/config`. Zod schemas for each request body. Swagger summaries.
- **Scope OUT:** Sales, refunds, quick products admin — handled by POS-005/006/008.
- **Touches:** `project/src/modules/pos/api/pos.routes.ts`, `project/src/shared/auth.ts` (add `requireAnyRole` helper).
- **Acceptance:** integration tests in `project/src/modules/pos/tests/` cover happy path + 401/403/409; swagger renders; rate limits applied.
### `POS-005` [P0] [Phase 1] POS API — product search + payment methods
- **Why:** Scanner + cart need a fast, denormalised product lookup; payment methods must be configurable per store.
- **Scope IN:** `GET /pos/products/search`, `GET /pos/products/by-ean/:ean`, `GET /pos/products/by-sku/:sku`, `GET /pos/products/:variantId`, `GET /pos/admin/payment-methods`, `POST /pos/admin/payment-methods`, `PATCH /pos/admin/payment-methods/:id`. The product search joins `catalog_products`, `catalog_product_variants`, `brands_brands`, `categories_categories`, `catalog_product_images`, `pricing_variant_prices`, `inventory_stock` (for `storeId`).
- **Scope OUT:** Sales endpoint (POS-008).
- **Touches:** `project/src/modules/pos/api/pos.routes.ts`, `project/src/modules/pos/application/search-products-for-pos.ts`.
- **Acceptance:** integration tests show EAN lookup returns in < 200 ms on a 1000-product seed; search returns relevant results for partial queries; cache invalidation hook fires on sale completion.
### `POS-006` [P0] [Phase 1] `apps/pos` Next.js app skeleton
- **Why:** The frontend is a new app; it needs the same auth flow, API client, and design tokens as the admin.
- **Scope IN:** `apps/pos/` with `package.json`, `next.config.ts`, `tailwind.config.ts`, `src/app/layout.tsx`, `src/app/globals.css` (copy of admin theme tokens), `src/app/(auth)/login/page.tsx`, `src/lib/api-client.ts` (copy of admin's, extended with `posApi`), `src/lib/permissions.ts` (extended with `POS_*`), `src/features/auth/components/AuthProvider.tsx`, `src/lib/money.ts`, `src/lib/idempotency.ts`, `src/app/api/[...path]/route.ts` (proxy), `src/middleware.ts` (terminal-id cookie + redirect to `/login`).
- **Scope OUT:** UI components (POS-007). Multi-touch components (POS-016).
- **Touches:** new `apps/pos/` directory; `project/scripts/monolith.sh` (add `pos` service on port 3006).
- **Acceptance:** `cd project/apps/pos && npm run build` succeeds; `npm run dev` boots; `/login` renders; login at the admin endpoint sets the cookie; `posApi.me()` returns the user.
### `POS-007` [P0] [Phase 1] POS UI — main register screen (layout + search + cart + totals)
- **Why:** First usable iteration of the TPV screen.
- **Scope IN:** components `POSLayout`, `POSHeader`, `ProductSearch` (with auto-focus + scanner integration), `Cart`, `CartItem` (with `[-] N [+]` quantity controls), `CartTotals`, `CategorySelector`, `QuickProducts` (read-only at first). Plus `pages/(terminal)/page.tsx`.
- **Scope OUT:** Checkout (POS-011). Touch optimisations (POS-016).
- **Touches:** `apps/pos/src/components/pos/**`, `apps/pos/src/features/cart/**` (zustand-like reducer in plain `useReducer`), `apps/pos/src/lib/hardware/browser-scanner-adapter.ts`.
- **Acceptance:** the operator can search by name/EAN, click a quick product, change quantities, see totals update, and reset the cart. Scanner typing an EAN auto-adds to cart. TYPING in the search input does NOT trigger scanner. Lighthouse perf 90 in `/pos`.
### `POS-008` [P0] [Phase 1] POS API — create sale (idempotent)
- **Why:** The core transaction; no POS without it.
- **Scope IN:** `POST /pos/sales`, `GET /pos/sales`, `GET /pos/sales/:id`. `application/create-pos-sale.ts` orchestrates pricing reserve create order register payments confirm stock mark COMPLETED in a single PG transaction. Idempotency via `idempotencyKey` UNIQUE on `orders_orders`.
- **Scope OUT:** Refunds (POS-013). Reprint (POS-014).
- **Touches:** `project/src/modules/pos/api/pos.routes.ts`, `project/src/modules/pos/application/create-pos-sale.ts`, `project/src/modules/inventory/application/inventory-service.ts` (extend with `storeId` parameter), `project/src/modules/payments/application/payment-service.ts` (allow `provider='cash'` and `provider='manual-card'`), `project/src/modules/orders/application/order-service.ts` (allow `source='pos'`).
- **Acceptance:** integration test sells 2 items + cash payment COMPLETED order exists, stock decreased, audit logged; same idempotencyKey returns same order; oversell returns 409; cash change computed correctly.
### `POS-009` [P1] [Phase 1] POS UI — customer association
- **Why:** Some stores need receipts in the customer's name for loyalty / returns.
- **Scope IN:** `CustomerSelector` component, search by email/phone via `usersApi`, `apps/pos/src/features/cart/cart-slice.ts` extended with `customerId`.
- **Scope OUT:** Loyalty / points (deferred).
- **Touches:** `apps/pos/src/components/pos/CustomerSelector.tsx`, `apps/pos/src/lib/api-client.ts`.
- **Acceptance:** typing in the customer field shows suggestions; clicking a suggestion attaches the customer; the cart header shows the customer name; the receipt includes the customer.
### `POS-010` [P1] [Phase 1] POS UI — discount panel
- **Why:** Daily operations need quick discount buttons.
- **Scope IN:** `DiscountPanel` component with `0% / 5% / 10% / Custom` buttons for both line and global discounts.
- **Scope OUT:** Permission gating (POS-024).
- **Touches:** `apps/pos/src/components/pos/DiscountPanel.tsx`, `apps/pos/src/features/cart/cart-slice.ts`.
- **Acceptance:** discounts apply to subtotal; over-limit returns server error; UI explains the limit; audit log shows actor + amount + reason.
---
## Phase 2 — Checkout
### `POS-011` [P0] [Phase 2] POS UI — payment modal (cash + card)
- **Why:** Without a checkout, the cart is just a viewer.
- **Scope IN:** `PaymentModal`, `CashPayment` (with amount tendered, change, quick-buttons `[5 €] [10 €] [20 €] [50 €] [100 €] [EXACTO]`), `CardPayment` (manual auth code entry), `NumericKeypad`.
- **Scope OUT:** Native datáfono integration (POS-031+).
- **Touches:** `apps/pos/src/components/pos/**`.
- **Acceptance:** the operator can complete a sale end-to-end (open register scan cart cobrar cash/card confirm); receipt prints via browser print; cart clears.
### `POS-012` [P0] [Phase 2] POS API — reprint receipt
- **Why:** Cashier needs to reprint a receipt on request.
- **Scope IN:** `POST /pos/sales/:id/print`, `GET /pos/sales/:id/receipt`.
- **Scope OUT:** Refunds (POS-013).
- **Touches:** `project/src/modules/pos/api/pos.routes.ts`, `project/src/modules/pos/application/render-receipt.ts`.
- **Acceptance:** reprinting returns the same `ReceiptDto` as the original sale; `printerAdapter.print` is called.
### `POS-013` [P0] [Phase 2] POS API — refunds (full + partial)
- **Why:** Customer returns are an everyday reality.
- **Scope IN:** `POST /pos/sales/:id/refund` with line-level granularity. Refunds create negative `payments_transactions` rows and `inventory_movements.operation='set_available'` rows.
- **Scope OUT:** Refund UI (POS-026).
- **Touches:** `project/src/modules/pos/api/pos.routes.ts`, `project/src/modules/pos/application/refund-pos-sale.ts`.
- **Acceptance:** partial refund returns `PARTIALLY_REFUNDED`, full refund returns `REFUNDED`; stock added back; audit logged with actor.
### `POS-014` [P0] [Phase 2] POS API — sale cancellation (within window)
- **Why:** Cashier mistakes need a fast path.
- **Scope IN:** `POST /pos/sales/:id/cancel` with 60-second window. Reservation released; state `CANCELLED`.
- **Scope OUT:** Refunds (POS-013).
- **Touches:** `project/src/modules/pos/api/pos.routes.ts`.
- **Acceptance:** cancellation within 60 s succeeds; outside the window returns 409; reservation released; state `CANCELLED`.
### `POS-015` [P1] [Phase 2] POS UI — sale history + detail
- **Why:** Cashier needs to look up today's sales quickly.
- **Scope IN:** `/pos/history` page listing sales scoped to the terminal's store; `/pos/history/[id]` page showing items, payments, and a "Reimprimir" button.
- **Scope OUT:** Cross-store history (admin only).
- **Touches:** `apps/pos/src/app/(terminal)/history/**`, `apps/pos/src/components/pos/TicketHistory.tsx`.
- **Acceptance:** the operator can find a sale by receipt number or partial customer name; reprinting works.
### `POS-016` [P2] [Phase 2] POS UI — first-pass touch optimisations
- **Why:** Even desktop users benefit from bigger hit targets.
- **Scope IN:** minimum 48×48 px touch targets, 16 px spacing between buttons, larger fonts in cart totals, virtual `NumericKeypad` always visible during cash payment.
- **Scope OUT:** Full touch redesign (POS-029+).
- **Touches:** `apps/pos/src/components/pos/**` styles.
- **Acceptance:** Lighthouse accessibility 95; manual test on a 10-inch tablet shows no accidental taps.
---
## Phase 3 — Cash register
### `POS-017` [P0] [Phase 3] POS UI — open register screen
- **Why:** Required to start a shift.
- **Scope IN:** `/pos/open-register` page with opening cash input, confirm button.
- **Scope OUT:** Mid-shift reopen (closed sessions can't reopen; you close by opening a new one).
- **Touches:** `apps/pos/src/app/(terminal)/open-register/page.tsx`.
- **Acceptance:** cashier can open a register with a starting amount; on success, redirects to main screen; on 409 (already open), shows the existing session.
### `POS-018` [P0] [Phase 3] POS UI — close register screen (Z report)
- **Why:** End-of-shift reconciliation is mandatory.
- **Scope IN:** `/pos/close-register` page showing sales-by-method, refunds, expected cash vs operator-counted, difference.
- **Scope OUT:** Manager override flow (POS-024).
- **Touches:** `apps/pos/src/app/(terminal)/close-register/page.tsx`.
- **Acceptance:** the Z report matches the database totals; the difference is recorded; the session transitions to `CLOSED`.
### `POS-019` [P0] [Phase 3] POS UI — register status in header
- **Why:** Always-visible feedback of session state.
- **Scope IN:** header pill showing session state, elapsed time, sale count, cash total.
- **Scope OUT:** Configurable metrics (POS-024).
- **Touches:** `apps/pos/src/components/pos/POSHeader.tsx`.
- **Acceptance:** header updates in real time; "Cerrar caja" CTA visible when session is OPEN.
### `POS-020` [P1] [Phase 3] POS API — session summary endpoint
- **Why:** The Z report reads from a single computed endpoint rather than fetching many.
- **Scope IN:** `GET /pos/sessions/:id/summary` returns `{ salesCount, salesByMethod, refundsCount, refundsByMethod, expectedCashCents }`.
- **Scope OUT:** Historical session analytics.
- **Touches:** `project/src/modules/pos/api/pos.routes.ts`, `project/src/modules/pos/application/session-summary.ts`.
- **Acceptance:** summary is accurate against direct DB queries; cacheable for the duration of the close flow.
### `POS-021` [P1] [Phase 3] Audit hooks for register actions
- **Why:** Open/close/difference must be logged for accountability.
- **Scope IN:** `AuditLogger.log` calls in `open-cash-session.ts`, `close-cash-session.ts`.
- **Scope OUT:** Audit UI (admin already has `/admin/audit`).
- **Touches:** `project/src/modules/pos/application/open-cash-session.ts`, `close-cash-session.ts`.
- **Acceptance:** every open/close is logged with actor, terminal, and amounts.
### `POS-022` [P2] [Phase 3] Z report export (PDF / email)
- **Why:** Manager wants a copy at end of day.
- **Scope IN:** "Send Z report by email" button on close screen; uses existing notifications module.
- **Scope OUT:** PDF generation (use HTMLPDF via `puppeteer` if requested).
- **Touches:** `apps/pos/src/app/(terminal)/close-register/page.tsx`, `project/src/modules/notifications/**`.
- **Acceptance:** manager email receives the report; HTML email renders cleanly.
---
## Phase 4 — Multi-terminal
### `POS-023` [P1] [Phase 4] Admin UI — stores CRUD
- **Why:** Operators manage stores from the admin.
- **Scope IN:** `/admin/pos/stores` (list, create, edit, deactivate).
- **Scope OUT:** Per-store settings editor (POS-024).
- **Touches:** new `project/apps/admin/src/app/(dashboard)/pos/stores/page.tsx`, `apps/admin/src/lib/api-client.ts` (add `posAdminStoresApi`).
- **Acceptance:** operator can create / edit / deactivate stores; slug is auto-generated; deactivation prevents new terminals from binding.
### `POS-024` [P1] [Phase 4] Admin UI — terminals CRUD + binding codes
- **Why:** Operators provision TPV devices.
- **Scope IN:** `/admin/pos/terminals` (list, create, generate binding code, decommission).
- **Scope OUT:** Remote desktop (deferred).
- **Touches:** `apps/admin/src/app/(dashboard)/pos/terminals/page.tsx`, `apps/admin/src/lib/api-client.ts`.
- **Acceptance:** operator creates a terminal, gets a binding code, the terminal binds on first launch; decommissioning prevents future sales.
### `POS-025` [P1] [Phase 4] Admin UI — payment methods per store
- **Why:** Each store may have different cashiers / card types.
- **Scope IN:** `/admin/pos/payment-methods` (list, create, toggle, reorder).
- **Scope OUT:** Card terminal config (POS-031+).
- **Touches:** `apps/admin/src/app/(dashboard)/pos/payment-methods/page.tsx`, `apps/admin/src/lib/api-client.ts`.
- **Acceptance:** operator can disable a method mid-shift; POS app picks up the change on next config refresh.
### `POS-026` [P1] [Phase 4] Admin UI — sessions overview
- **Why:** Operators see all open sessions.
- **Scope IN:** `/admin/pos/sessions` (list, filter by store / terminal / user / date, click to view Z report).
- **Scope OUT:** Real-time push (poll every 30 s is enough).
- **Touches:** `apps/admin/src/app/(dashboard)/pos/sessions/page.tsx`.
- **Acceptance:** operator can find any session, see its summary, and download the report.
### `POS-027` [P2] [Phase 4] Admin UI — quick products editor
- **Why:** Store managers customize the quick-buttons grid.
- **Scope IN:** `/admin/pos/quick-products` (drag-and-drop grid, add/remove, scope selector).
- **Scope OUT:** Image upload (use existing product images).
- **Touches:** `apps/admin/src/app/(dashboard)/pos/quick-products/page.tsx`.
- **Acceptance:** operator can build a 12-product grid per terminal; changes propagate to the POS app within 60 s.
### `POS-028` [P2] [Phase 4] POS UI — concurrent terminal awareness
- **Why:** A sale on terminal A should show up on terminal B (e.g., for cross-terminal refunds).
- **Scope IN:** optional WebSocket / SSE channel from backend to POS app surfacing "new sale" events in the same store.
- **Scope OUT:** Cross-store events.
- **Touches:** `project/src/modules/pos/api/pos.events.ts` (SSE endpoint), `apps/pos/src/lib/pos-events.ts`.
- **Acceptance:** terminal B sees terminal A's sales in real time within 2 s.
---
## Phase 5 — Touch UI
### `POS-029` [P1] [Phase 5] Touch mode toggle + auto-detection
- **Why:** Touchscreens need bigger targets; desktops don't.
- **Scope IN:** `interface_mode` from terminal settings; default `auto` detects via `(pointer: coarse)` media query; user override stored in cookie.
- **Scope OUT:** Per-section touch variants.
- **Touches:** `apps/pos/src/app/layout.tsx`, `apps/pos/src/components/pos/POSLayout.tsx`, `apps/pos/src/styles/touch.css`.
- **Acceptance:** `auto` mode uses media query; manual override persists; switching modes doesn't reload the app.
### `POS-030` [P1] [Phase 5] Touch mode — large targets, larger spacing, virtual keypad
- **Why:** Touch-friendly UI is a separate design.
- **Scope IN:** CSS variables for hit-target size and spacing; conditional class `touch` on root; bigger QuickProduct tiles; on-screen numeric keypad always visible during cash payment.
- **Scope OUT:** Native number pad (deferred to native bridge).
- **Touches:** `apps/pos/src/styles/touch.css`, `apps/pos/src/components/pos/**` styles.
- **Acceptance:** manual test on a 10-inch touchscreen tablet shows no accidental taps; keypad is reachable without a physical keyboard.
### `POS-031` [P2] [Phase 5] Keyboard shortcuts (desktop)
- **Why:** Power users want fast keyboard control.
- **Scope IN:** `F2` search, `F4` customer, `F6` discount, `F8` park, `F9` recall, `F10` cobrar, `Esc` cancel modal.
- **Scope OUT:** Customisable shortcuts.
- **Touches:** `apps/pos/src/lib/shortcuts.ts`, `apps/pos/src/app/(terminal)/page.tsx`.
- **Acceptance:** shortcuts work when no input is focused; ignore when typing in an input.
### `POS-032` [P2] [Phase 5] Touch mode — sound feedback (optional)
- **Why:** Audio cue on scan success / error.
- **Scope IN:** `AudioContext` based beeps; toggle in settings.
- **Scope OUT:** Voice prompts.
- **Touches:** `apps/pos/src/lib/sounds.ts`.
- **Acceptance:** scan success plays a soft click; error plays a low beep; mute toggle works.
### `POS-033` [P3] [Phase 5] Responsive layout (1024×768 → 1920×1080)
- **Why:** Wide monitors should use the space well; small screens should still fit.
- **Scope IN:** CSS grid with `auto-fill` for quick products; column widths tuned per breakpoint.
- **Scope OUT:** Phone-sized screens (< 1024 px).
- **Touches:** `apps/pos/src/components/pos/**` styles.
- **Acceptance:** no horizontal scroll at any tested width; quick products grid reflows correctly.
---
## Phase 6 — Hardware (adapter layer)
### `POS-034` [P0] [Phase 6] Hardware adapter interfaces (canonical)
- **Why:** Single source of truth for all hardware abstraction.
- **Scope IN:** `project/src/shared/hardware/types.ts` (interfaces for Scanner, Printer, CashDrawer, PaymentTerminal, Scale); `project/src/shared/hardware/index.ts` (exports). Documentation in `docs/pos/POS_HARDWARE.md`.
- **Scope OUT:** Implementations.
- **Touches:** `project/src/shared/hardware/**` (new).
- **Acceptance:** interfaces compile; documentation covers each adapter; tests cover the contract.
### `POS-035` [P0] [Phase 6] Browser implementations (scanner, printer, cash drawer)
- **Why:** Required for any Phase 15 functionality.
- **Scope IN:** `BrowserScannerAdapter`, `BrowserPrinterAdapter`, `BrowserCashDrawerAdapter` (delegates to printer).
- **Scope OUT:** Native bridge.
- **Touches:** `apps/pos/src/lib/hardware/**`.
- **Acceptance:** scanner auto-detects HID input and adds to cart; printer prints receipt via `window.print()`; cash drawer opens via printer kick-out (or no-op for browser).
### `POS-036` [P1] [Phase 6] Print route `/print/[orderId]`
- **Why:** Render the receipt in a print-stylesheet-only route.
- **Scope IN:** `apps/pos/src/app/print/[orderId]/page.tsx`, print-specific CSS.
- **Scope OUT:** Native ESC/POS rendering.
- **Touches:** `apps/pos/src/app/print/**`, `apps/pos/src/styles/print.css`.
- **Acceptance:** print preview shows the 80mm layout; no app chrome leaks; totals match the `ReceiptDto`.
### `POS-037` [P2] [Phase 6] Native bridge spec (HTTP)
- **Why:** Define the contract for vendor-specific bridges.
- **Scope IN:** `docs/pos/POS_HARDWARE.md` §3.3 and §4.2 (already written); add a sample Node skeleton under `project/src/shared/hardware/native-bridge-spec/` (no implementation, just a README + OpenAPI).
- **Scope OUT:** Real bridges.
- **Touches:** `project/src/shared/hardware/native-bridge-spec/**` (new, optional).
- **Acceptance:** README documents the HTTP contract and how a bridge would slot in.
### `POS-038` [P2] [Phase 6] Vendor adapter: SumUp (deferred)
- **Why:** SumUp is a likely first datáfono vendor.
- **Status:** P3 until operator picks SumUp.
- **Scope IN:** native bridge process (`pos-sumup-bridge`) + `apps/pos/src/lib/hardware/sumup-payment-terminal-adapter.ts`.
- **Touches:** new out-of-repo Node project (not in this codebase).
- **Acceptance:** SumUp terminal charges the customer; auth code returns to POS; payment is recorded in `payments_transactions`.
### `POS-039` [P3] [Phase 6] Scale adapter
- **Why:** Some products are sold by weight.
- **Scope IN:** Scale adapter interface (already in POS-034); browser manual entry in Phase 5; native bridge in Phase 7+.
- **Scope OUT:** Vendor-specific bridges.
- **Touches:** `apps/pos/src/lib/hardware/scale-adapter.ts`.
- **Acceptance:** weight entry shows in the cart with the right unit price calculation.
### `POS-040` [P3] [Phase 6] Cash drawer kick-out via printer command
- **Why:** On real ESC/POS printers, the kick-out is `ESC p 0`.
- **Status:** P3 until operator picks a printer.
- **Scope IN:** native bridge sends the byte after receipt.
- **Scope OUT:** Standalone drawer opener hardware.
- **Acceptance:** drawer opens after every successful print.
---
## Phase 7 — Polish + future
### `POS-041` [P2] [Phase 7] Refund UI (from sale history)
- **Why:** Manager needs to refund from the history page.
- **Scope IN:** `/pos/sales/:id` page with a "Refund" button modal with line selection + reason.
- **Scope OUT:** Cross-store refunds.
- **Touches:** `apps/pos/src/app/(terminal)/sales/[id]/page.tsx`.
- **Acceptance:** manager can refund a line or a full sale; stock is added back; audit logged.
### `POS-042` [P3] [Phase 7] Offline mode (read-only cache + queue)
- **Why:** Operators want continuity during network drops.
- **Status:** P3 until the operator explicitly commits. Requires deep analysis (stock consistency, payment refunds, audit trust).
- **Scope IN:** IndexedDB-backed product + customer cache; queued mutations with conflict detection; merge strategy for offline sales.
- **Scope OUT:** Real implementation in this codebase.
- **Acceptance:** a design doc under `docs/pos/POS_OFFLINE.md` lays out the strategy.
### `POS-043` [P3] [Phase 7] Loyalty / points
- **Why:** Brief §14 anticipates this.
- **Status:** P3 until needed.
- **Scope IN:** new `pos_loyalty` module; points ledger per customer; integration with `pos.sale.created` event.
- **Touches:** `project/src/modules/pos/loyalty/**` (new).
- **Acceptance:** customers earn points on each sale; can redeem at next sale.
### `POS-044` [P3] [Phase 7] Multi-currency
- **Why:** EU cross-border expansion.
- **Status:** P3 until needed. Requires extending `pricing_variant_prices.currency` (already supports it) and `orders_orders.currency` (already supports it).
- **Scope IN:** UI currency picker, exchange-rate source, FX snapshot on order creation.
- **Scope OUT:** Crypto.
- **Touches:** `apps/pos/src/components/pos/**`, `project/src/modules/pricing/**`.
- **Acceptance:** a sale in EUR with a USD display works; order snapshot stores the EUR value at sale time.
### `POS-045` [P3] [Phase 7] Gift receipts
- **Why:** Brief §13 mentions reprinting.
- **Scope IN:** "Gift receipt" option in print flow; emits a separate receipt with prices hidden.
- **Scope OUT:** Email delivery.
- **Touches:** `apps/pos/src/components/pos/PaymentModal.tsx`, `apps/pos/src/app/print/[orderId]/page.tsx`.
- **Acceptance:** gift receipt omits prices; includes product names and a thank-you note.
### `POS-046` [P3] [Phase 7] Saved customer preferences (loyalty card scan)
- **Why:** Brief §14 anticipates this.
- **Status:** P3 until needed.
- **Scope IN:** scan a loyalty barcode as a customer identifier.
- **Touches:** `apps/pos/src/components/pos/CustomerSelector.tsx`.
- **Acceptance:** scanning a loyalty code attaches the customer.
---
## Summary
| Priority | Count | Phases |
|---|---|---|
| **P0** imprescindible | 16 | 1, 2, 3, 6 |
| **P1** importante | 18 | 1, 2, 3, 4, 5 |
| **P2** mejora | 9 | 2, 3, 4, 5, 7 |
| **P3** futuro | 6 | 5, 6, 7 |
| **Total** | 49 | 7 |
The operator can choose to delay or skip P2/P3 tickets freely. P0 tickets form the minimum viable TPV.
---
## Status snapshot
- **Phase 0 (POS-001):** in progress (this work).
- **Phase 1 (POS-002 POS-010):** queued in backlog; waiting on operator approval of this discovery phase.
- **Phase 2 (POS-011 POS-016):** queued.
- **Phase 3 (POS-017 POS-022):** queued.
- **Phase 4 (POS-023 POS-028):** queued.
- **Phase 5 (POS-029 POS-033):** queued.
- **Phase 6 (POS-034 POS-040):** queued.
- **Phase 7 (POS-041 POS-046):** queued.
**No implementation work begins until the operator signs off on the 5 docs under `docs/pos/` and approves Phase 1 to start.**