# 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://: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 «». 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 043–046. 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 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://: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 = '' 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 = ''; -- 3. Update order state back to COMPLETED UPDATE orders_orders SET state = 'COMPLETED' WHERE id = ''; -- 4. Reduce stock again (Phase 5 admin UI does this automatically) UPDATE inventory_stock SET available = available - , sold = sold + WHERE variant_id = '' AND 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) - [x] Approve POS_ARCHITECTURE.md, POS_DATA_MODEL.md, POS_API.md, POS_HARDWARE.md, POS_OPERATIONS.md. - [x] Confirm default store name for migration 043 — **"Natural - Mercado de Vida"**. - [x] Confirm cookie domain for the POS app — **separate port `:3006` for now; subdomains via Traefik in production**. - [ ] 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. - [ ] Confirm refund window in `store_settings` (15 days, manager approval > 50 €) once admin UI exposes the field. - [ ] Procure Epson thermal receipt printer + USB HID scanner per terminal. Once the unchecked items are confirmed, the leader promotes Phase 2 (`POS-002` …) into execution.