Files
mercadodevida/docs/pos/POS_OPERATIONS.md
2026-08-21 21:55:43 +02:00

14 KiB
Raw Blame History

POS Operations — Mercado de Vida

Companion to: 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:

    cd project
    npm run build
    npm run db:status   # confirm migrations are tracked
    

    The POS migrations (043046) 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:

    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

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

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:

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:

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:

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"

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

-- 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 — "Natural - Mercado de Vida".
  • 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.