# 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 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.
