Files
mercadodevida/docs/pos/POS_OPERATIONS.md

374 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.
> ⚠️ **Nunca** ejecutes `npm run dev` ni `./scripts/monolith.sh dev` en el host público del TPV. El modo dev expone HMR, React DevTools y endpoints internos a cualquier visitante, y rompe la PWA (manifest y cookies Secure). Para producción usa exclusivamente `prod`; tras el arranque, `monolith.sh` ejecuta un smoke test que falla ruidosamente si detecta un dev server activo. Detalles y runbook de recuperación en `docs/HOWTO-monolith.md` §3.1.
---
## 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)
- [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.