374 lines
15 KiB
Markdown
374 lines
15 KiB
Markdown
# 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 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 <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.
|