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

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.