14 KiB
POS Operations — Mercado de Vida
Companion to:
POS_ARCHITECTURE.mdStatus: 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)
-
Run migrations on the backend host:
cd project npm run build npm run db:status # confirm migrations are trackedThe POS migrations (
043–046) run automatically with the nextnpm run db:migrate. They are additive and non-destructive. -
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'); -
Generate binding codes for each TPV device from
/admin/pos/terminals. -
On each TPV device, open the POS app and enter the binding code on first launch. The browser stores
pos_terminal_idin a Secure cookie for one year. -
Configure POS users: assign
pos_cashierorpos_managerto 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:
- Login at
https://<host>:3006/login. - The terminal shows "Caja cerrada" with an "Abrir caja" button.
- Click "Abrir caja" → enter the opening cash amount (e.g., 100,00 € for the standard float) → confirm.
- 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):
- Cashier scans a product (or types in the search box).
- Product is added to the cart.
- Repeat for additional products.
- Optional: associate a customer (search by email/phone).
- Optional: apply a line or global discount (within server-enforced limits).
- Click "Cobrar".
- Choose payment method (Efectivo / Tarjeta / Otro).
- For cash: enter amount tendered, see the change. Click "Confirmar".
- For card: enter the auth code from the datáfono. Click "Confirmar".
- Server creates the order + reservation + payment + COMPLETED state in one transaction.
- Receipt is printed automatically.
- Cart clears for the next sale.
Audit: pos.sale.created with all relevant fields.
2.3 Closing a register
End of shift:
- Click "Cerrar caja" in the header.
- 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)
- Cashier counts the cash in the drawer and enters the actual amount.
- Server computes
differenceCents = actualCashCents − expectedCashCents. - Session is closed (
status='CLOSED',closedAt=now()). - Audit:
pos.session.closedwith all totals and the difference.
2.4 Held / parked tickets
If a customer walks away mid-sale:
- Click "Aparcar" → enter an optional label ("Juan — vuelve en 10 min").
- The cart is persisted in
pos_parked_ticketswith a 24-hour expiry. - 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_PRICEpermission.
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
idempotencyKeyensures 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.printreturns{ 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 043–046. Side effects:
pos_*tables are dropped.inventory_stock.store_idcolumn 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.sourcecolumn is dropped — existing rows havesource='ecommerce'after rollback, so they're restored to their pre-POS state.COMPLETEDorder state is rejected by the original CHECK constraint; any open POS orders must be moved toDELIVEREDfirst 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) asprovider_auth_code(string). They are the vendor-issued reference, not the card data. - Cookies are Secure + HttpOnly + SameSite=Lax. The catch-all proxy strips
Secureforlocalhost; production deployments must terminate TLS at the proxy and setSecureon the cookie. - Audit retention: the existing
security_audit_loghas 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: includepattern inapi-client.tsprotect 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_dumpcovers allpos_*tables. - The
pos_terminal.settingsJSONB 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:
- Open the register with 100 € float.
- Scan a known product (e.g.
8412345678901) — should appear in the cart. - Type a name in the search box — should suggest products.
- Click a quick-product button — should add to the cart.
- Click "Cobrar" → "Efectivo" → enter 50 € → change should display.
- Confirm sale — receipt should print (or browser print dialog opens).
- Verify in admin
/admin/ordersthat the order exists withsource='pos', stateCOMPLETED, total correct. - Verify in admin
/admin/inventorythat stock decreased for the variant. - Open "Tickets aparcados" — park current cart.
- Reload page — recall ticket — cart restored.
- Close the register — count cash — verify the difference matches expectation.
- 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
:3006for 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.