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

489 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# POS Architecture — Mercado de Vida
> **Status:** Discovery (Phase 1)
> **Ticket:** POS-001
> **Author:** architect
> **Last updated:** 2026-08-21
This document describes how a Point-of-Sale (TPV) application integrates into the existing **Mercado de Vida** modular monolith. The POS must reuse products, prices, stock, customers, orders, taxes, authentication, RBAC and the admin panel. **No business-logic duplication.**
---
## 0. Executive summary
| Aspect | Decision |
|---|---|
| Frontend app | New Next.js app `apps/pos` inside the existing `project/` workspace (sibling to `apps/admin`). Same Tailwind design tokens, same catch-all proxy pattern, same auth flow as the admin. |
| Backend | New module `project/src/modules/pos/` following the existing `domain / application / infrastructure / api` layout. Reuses all existing modules via injected ports. |
| Sales = orders | A POS sale IS an order with `source = 'pos'`. New column on `orders_orders`. The order state machine is extended with a `COMPLETED` terminal state (no shipping). |
| Cash sessions, terminals, stores | New tables. Schema below. |
| Inventory per store | Extended: `inventory_stock` becomes keyed by `(variant_id, store_id)`. Migration backfills the default store. |
| Authentication | Reuses the existing `backoffice_users` table (admin/editor + new `pos_cashier`, `pos_manager` roles). Same session cookie mechanism. |
| Hardware | Adapter interfaces (no vendor SDK in core). Browser-first implementations; real ESC/POS adapters land in Phase 7. |
| Idempotency | Reuses `idempotency_key` on orders. POS generates a UUID per "cobrar" click. |
The POS is **not** a parallel system. It is a new consumer of the same core modules, plus a thin new module for the TPV-specific concepts (terminals, sessions, registers).
---
## 1. Current stack (as-found)
### 1.1 Repository layout
```
project/
├── apps/
│ ├── admin/ # Next.js 14 admin (Backoffice users)
│ ├── frontend/ # Next.js 14 customer storefront
│ └── storefront/ # legacy customer storefront (being phased out)
├── src/
│ ├── app/ # Fastify composition root (build-app.ts)
│ ├── infrastructure/# server bootstrap, config, logger
│ ├── modules/ # 20+ domain modules (DDD + Clean Architecture)
│ └── shared/ # cross-module helpers (auth, errors, http-input, telemetry, text)
├── migrations/ # node-pg-migrate
├── scripts/ # dev helpers
├── docker-compose.yml # postgres + redis
└── package.json # ESM, TypeScript strict, vitest
```
Apps are co-located under `project/apps/` and share backend through a single Next.js catch-all proxy (`/api/[...path]/route.ts`). The `monolith.sh` script orchestrates all services on ports 3000 (backend), 3003 (frontend), 3004 (admin), 3005 (storefront).
### 1.2 Backend conventions
- **Module layout (mandatory):** `domain / application / infrastructure / api`. Public API exposed only via `index.ts`.
- **Module isolation:** modules import ONLY public APIs of other modules; the boundary checker (`scripts/check-module-boundaries.mjs`) enforces this.
- **Auth:** `Authenticate` port from `shared/auth.ts`; routes call `await deps.authenticate(request)` and then `requireRole(user, 'admin' | 'editor' | 'customer')`.
- **RBAC:** role-based string check in `requireRole`. Permissions are an enum in `apps/admin/src/lib/permissions.ts` (admin only, currently).
- **Validation:** explicit `parseJson(zodSchema, request.body)` via `shared/http-input.ts`. No magic.
- **Errors:** `AppError(httpStatus, code, message, details?)` from `shared/errors.ts`. Single envelope.
- **DB:** PostgreSQL 16, schema-per-module naming `<module>_<table>`. node-pg-migrate, ES module migrations.
- **Logging:** structured pino via `app.log`.
- **Telemetry:** `Telemetry` / `Tracer` / `Meter` ports from `observability` (no-op default).
- **Tests:** vitest unit + itest (integration) inside `src/modules/<m>/tests/`. E2E in `src/app/tests/e2e/`.
### 1.3 Existing modules we will reuse
| Module | What POS needs from it |
|---|---|
| `identity` | `Authenticate` port, backoffice session lookup (cookie + role). |
| `backoffice` | Login/logout/me endpoints and the cookie contract. Reused as-is. |
| `catalog` | Product / variant / image / category / brand lookups + search by EAN/SKU/name (`GET /products/search`, `GET /products/suggest`). |
| `pricing` | `GET /pricing/variants/:id`, `POST /pricing/calculate`. Server-authoritative price; never trust client totals. |
| `inventory` | `checkAvailability`, atomic `reserve / release / confirm`. The POS sale = `confirm` of a `reserve`. |
| `tax` | Active tax rates via `GET /admin/tax-rates`. |
| `orders` | Order creation, state transitions, idempotency. |
| `payments` | `PaymentProvider` interface — currently a stub. POS brings concrete `cash` and external `card` providers in Phase 3. |
| `checkout` | The orchestrator pattern (validate → price → reserve → create order → payment intent). POS re-uses the same flow but skips shipping. |
| `users` | Customer search by email/phone for ticket association. |
| `store-settings` | `store_name`, `contact_address`, `footer_text`, etc. — printed on tickets. |
| `security` | `AuditLogger` for "open register", "close register", "discount applied", etc. |
| `notifications` | Email on sale confirmation (optional, off by default for POS). |
| `observability` | `Tracer` / `Meter` for spans and counters. |
### 1.4 Existing admin app conventions (POS will mirror them)
- App Router under `apps/admin/src/app/(dashboard)/...`.
- Tailwind with shared `@theme` block (greens `#2D6A4F`, `#1B4332`; secondary cream `#F5F0E8`; accent orange `#E76F51`; danger `#DC2626`).
- `AuthProvider` context, `useAuth()` hook, `requireRole` redirect to `/login`.
- `lib/api-client.ts` with typed namespaces (`productsApi`, `brandsApi`, `ordersApi`, …). Catch-all proxy at `app/api/[...path]/route.ts`.
- Tanstack-Query-like manual caching via `useEffect` + `useState` (no Redux/Zustand yet).
- `lib/permissions.ts` for client-side UI gating.
---
## 2. Gaps to address before code
These are real schema/concept gaps the brief asks for that **do not exist today**:
| Gap | Brief section | Current state | Resolution |
|---|---|---|---|
| **Multi-store** | §17 | `inventory_stock` keyed by `variant_id` only. **No** `store` table. | Add `stores` table; key `inventory_stock` by `(variant_id, store_id)`. Migration backfills default store. |
| **Terminals** | §17 | None. | New `pos_terminals` table. |
| **Cash sessions** | §18 | None. | New `pos_cash_sessions` table. |
| **Order source** | §16 | No `source` column on `orders_orders`. | Add `source` enum (`'ecommerce' \| 'pos' \| 'admin'`) with default `'ecommerce'`. Backfill existing rows. |
| **Order `COMPLETED` state** | §16, §39 | State machine tops out at `DELIVERED`. POS sales never ship. | Add `COMPLETED` terminal state. POS sales go `PENDING → AWAITING_PAYMENT → PAID → COMPLETED` (skipping `SHIPPED`/`DELIVERED`). |
| **POS roles** | §27 | Roles are `customer / admin / editor`. | Extend `backoffice_users.role` CHECK to include `'pos_cashier'` and `'pos_manager'`. |
| **POS-only permissions** | §27 | Permissions enum is admin-only. | Extend `permissions.ts` with `POS_*` permissions; server-side `requireRole` is too coarse — add `requireAnyRole` and `requirePermission` to `shared/auth.ts`. |
| **Anonymous sale** | §14 | `orders_orders.user_id NOT NULL`. | Either create a "walk-in customer" placeholder or make `user_id` nullable when `source = 'pos'`. Decision: **nullable** with a CHECK that requires `user_id IS NULL OR source = 'pos'` if guest, otherwise required. |
| **Hardware adapters** | §25 | None. | New interfaces under `shared/hardware/`: `ScannerAdapter`, `PrinterAdapter`, `CashDrawerAdapter`, `PaymentTerminalAdapter`, `ScaleAdapter`. Browser implementation in Phase 2/3; ESC/POS in Phase 7. |
| **Idempotency per sale** | §32 | Already supported via `idempotency_key` UNIQUE. | Reused as-is. POS client generates a UUID per "Cobrar" click. |
---
## 3. Proposed architecture
### 3.1 High-level diagram
```
┌──────────────────────────────────────────────┐
│ Mercado de Vida Core │
│ │
┌──────────┐ │ ┌─────────┐ ┌─────────┐ ┌────────────┐ │
│ Ecommerce│─────────┼─►│Catalog │ │Pricing │ │ Inventory │ │
│ Frontend │ API │ └────┬────┘ └────┬────┘ └─────┬──────┘ │
└──────────┘ │ │ │ │ │
│ └────────────┼──────────────┘ │
┌──────────┐ │ ▼ │
│ Admin │─────────┼─► ┌──────────────────────────────┐ │
│ Panel │ API │ │ Orders (source=ecommerce/pos/admin) │
└──────────┘ │ └──────────────────────────────┘ │
│ │ │
┌──────────┐ │ ▼ │
│ POS │─────────┼─► ┌──────────────────────────────┐ │
│ (new) │ API │ │ Payments (cash / card stub) │ │
│ apps/pos │ │ └──────────────────────────────┘ │
└────┬─────┘ │ │ │
│ hardware │ ▼ │
▼ adapters │ ┌──────────────┐ │
┌──────────┐ │ │ Audit Log │ │
│ Scanner │ │ └──────────────┘ │
│ Printer │ │ │
│ Cash drw │ └──────────────────────────────────────────────┘
│ Card Trm │
└──────────┘
```
### 3.2 Backend modules
```
src/modules/
├── pos/ # NEW — POS-specific concepts
│ ├── domain/
│ │ ├── store.ts # Store aggregate
│ │ ├── terminal.ts # POS terminal
│ │ ├── cash-session.ts # Open/closed register session
│ │ ├── quick-product.ts # Configurable favorite buttons
│ │ ├── sale.ts # Re-exports Order as Sale; defines Source enum
│ │ └── errors.ts
│ ├── application/
│ │ ├── open-cash-session.ts
│ │ ├── close-cash-session.ts
│ │ ├── list-terminals.ts
│ │ ├── get-pos-config.ts # returns terminal, session, store, tax rates
│ │ ├── search-products-for-pos.ts # thin wrapper over catalog search + price
│ │ ├── create-pos-sale.ts # orchestrates pricing + reserve + create + complete
│ │ ├── refund-pos-sale.ts
│ │ ├── park-ticket.ts # cart parked in DB
│ │ └── ...
│ ├── infrastructure/
│ │ ├── pg-store-repository.ts
│ │ ├── pg-terminal-repository.ts
│ │ ├── pg-cash-session-repository.ts
│ │ ├── pg-quick-product-repository.ts
│ │ └── pg-parked-ticket-repository.ts
│ └── api/
│ └── pos.routes.ts # all /pos/* endpoints
└── (existing modules, untouched except where noted)
```
### 3.3 POS order flow (orchestration)
The POS sale path is a **slimmer** checkout: no shipping, no promo code, optional customer. We re-use `OrderService` and `InventoryService` directly rather than building a separate orchestrator.
```
[POS UI: Cart with N items]
│ POST /api/pos/sales { idempotencyKey, items, customerId?, payments, discount? }
pos.routes.ts
├─► requireRole(user, ['pos_cashier','pos_manager','admin'])
├─► requireOpenSession(terminalId) // 409 if closed
├─► for each item:
│ pricingApi.getVariantPrice(variantId) // server-authoritative
│ inventory.checkAvailability(variantId) // soft check (real check at confirm)
├─► compute totals server-side (no client trust)
├─► BEGIN TRANSACTION
│ ordersService.create({source:'pos', userId?, items, totals, idempotencyKey})
│ inventory.reserve(variantId, storeId, qty) × items
│ ordersService.transition(orderId, 'AWAITING_PAYMENT')
│ for each payment: paymentsService.registerCash/Card({orderId, amountCents, ...})
│ ordersService.transition(orderId, 'PAID')
│ for each payment: inventory.confirm(reservation) × items
│ ordersService.transition(orderId, 'COMPLETED')
│ auditLogger.log('pos.sale.completed', {orderId, terminalId, totalCents, ...})
│ COMMIT
[POS UI: Receipt rendered, print dispatched, new empty cart]
```
Key invariants:
- **Idempotency:** the same `idempotencyKey` returns the same order — no double-charge on network retry.
- **Atomicity:** everything is in one PG transaction (using a single connection / client). The existing `OrderService` and `InventoryService` already operate on a passed `pg.Client`; we wire them to a single txn.
- **Server-authoritative totals:** client sends only item ids + quantities; server computes price, tax, discount.
- **No double reservation:** the orchestration is linear and single-threaded per request.
### 3.4 Hardware abstraction
```ts
// project/src/shared/hardware/types.ts
export interface ScannerAdapter {
/** Subscribe to decoded codes. Most physical scanners are HID-keyboard;
* we wrap them in this interface so swapping to serial/BT is a one-line change. */
onCode(callback: (code: string) => void): () => void;
}
export interface PrinterAdapter {
/** Renders a structured receipt. Returns success/failure; never throws
* for "printer offline" (returns a structured failure instead). */
print(receipt: ReceiptPayload): Promise<{ ok: true; jobId: string } | { ok: false; reason: string }>;
}
export interface CashDrawerAdapter {
/** Pulses the cash-drawer kick-out. Best-effort; failures logged. */
open(): Promise<{ ok: boolean; reason?: string }>;
}
export interface PaymentTerminalAdapter {
/** Asks the datáfono for an authorisation. Returns approved/declined/error.
* No PAN handling: this delegates to a PCI-DSS-compliant provider. */
requestPayment(input: { amountCents: number; currency: 'EUR'; reference: string }): Promise<
| { ok: true; providerPaymentId: string; authCode: string }
| { ok: false; reason: 'declined' | 'timeout' | 'offline' | 'error'; message: string }
>;
}
export interface ScaleAdapter {
/** Reads weight in grams. Returns null if the scale is disconnected or settling. */
readGrams(): Promise<number | null>;
}
```
Each adapter has two implementations in Phase 7:
- **Browser** (Phase 2/3): relies on standard browser APIs. Scanner = keyboard listener on a focused input. Printer = hidden iframe to a print-friendly HTML route. Cash drawer = no-op. Payment terminal = manual entry (operator types last-4 or auth code).
- **Native bridge** (Phase 7, deferred): a thin Node addon or a sidecar process that speaks USB/serial/ESC-POS. Exposed over a localhost HTTP socket to the Next.js app.
### 3.5 Frontend app
```
apps/pos/
├── package.json
├── next.config.ts
├── tailwind.config.ts (or shared via project root)
├── src/
│ ├── app/
│ │ ├── layout.tsx
│ │ ├── globals.css
│ │ ├── (auth)/
│ │ │ └── login/page.tsx
│ │ ├── (terminal)/
│ │ │ ├── layout.tsx # minimal chrome — sidebar, connection status
│ │ │ ├── page.tsx # main register screen
│ │ │ ├── open-register/page.tsx
│ │ │ ├── close-register/page.tsx
│ │ │ ├── history/page.tsx
│ │ │ └── settings/page.tsx
│ │ └── api/
│ │ └── [...path]/route.ts # identical proxy to apps/admin
│ ├── components/
│ │ ├── pos/POSLayout.tsx
│ │ ├── pos/POSHeader.tsx
│ │ ├── pos/ProductSearch.tsx
│ │ ├── pos/CategorySelector.tsx
│ │ ├── pos/QuickProducts.tsx
│ │ ├── pos/Cart.tsx
│ │ ├── pos/CartItem.tsx
│ │ ├── pos/CartTotals.tsx
│ │ ├── pos/CustomerSelector.tsx
│ │ ├── pos/DiscountPanel.tsx
│ │ ├── pos/PaymentModal.tsx
│ │ ├── pos/CashPayment.tsx
│ │ ├── pos/CardPayment.tsx
│ │ ├── pos/NumericKeypad.tsx
│ │ ├── pos/TicketHistory.tsx
│ │ ├── pos/RegisterSession.tsx
│ │ ├── pos/POSSettings.tsx
│ │ ├── pos/ConnectionStatus.tsx
│ │ └── ui/ # shared with apps/admin via component copy (TODO: extract to packages/ui in a future cleanup)
│ ├── features/
│ │ ├── auth/
│ │ ├── cart/ # POS cart state machine
│ │ ├── register/ # cash session state
│ │ ├── catalog/ # cached product list
│ │ └── hardware/ # adapter wiring
│ ├── lib/
│ │ ├── api-client.ts # typed client (mirror of apps/admin/src/lib/api-client.ts)
│ │ ├── permissions.ts # extends admin/permissions with POS_*
│ │ ├── money.ts # cents helpers, formatting (es-ES)
│ │ ├── tax.ts # client-side tax display only
│ │ ├── idempotency.ts # UUID v4 generator
│ │ └── hardware/ # adapter factories (browser impls)
│ ├── hooks/
│ ├── types/
│ └── middleware.ts # auth guard, terminal-id cookie
└── tests/
```
The app is delivered through the existing `monolith.sh` script (Phase 2: add a `pos` service on port 3006).
### 3.6 Multi-store inventory
Two options were considered:
- **(A) Extend `inventory_stock` with `store_id`** — chosen. Migration adds the column with a default "Main Store" UUID; backfills existing rows. Foreign key to new `stores` table.
- **(B) New `inventory_stock_by_store` table** — rejected: more moving parts, harder joins, no upside.
Operations:
- `inventory.reserve(variantId, storeId, qty)` becomes the signature.
- The POS endpoint always passes the terminal's storeId.
- Reporting endpoints (`GET /admin/inventory`) accept an optional `?storeId=` filter.
### 3.7 Authentication & authorization
- **Authentication:** identical to `apps/admin`. Same cookie, same `Authenticate` port. The POS app calls the same `/api/backoffice/auth/login` endpoint.
- **Authorization:** extend `shared/auth.ts` with `requireAnyRole(user, [...roles])`. New roles: `pos_cashier`, `pos_manager`. Permission string `POS_*` for fine-grained checks (override price, refund, cancel sale, open/close register).
- **Terminal identity:** a UUID generated on first boot of the POS app, stored in a Secure cookie (`pos_terminal_id`, 1 year expiry). The cookie survives reloads; clearing it requires re-binding the terminal to a record via a one-time admin code.
- **Session token in cookie** is the same as admin's. One user can be logged into admin and POS simultaneously (different cookies? no — same cookie, different apps).
### 3.8 Connection status / offline (deferred)
Phase 15: online-only. Connection status shows "● Online" / "⚠ Sin conexión" by pinging `GET /health` every 10 s.
Phase 8 (out of scope here): offline-first with IndexedDB queue and CRDT-style stock reconciliation. Deferred until the operator explicitly requests it. Architecture leaves a `pendingMutations` slot in the cart slice so it can be wired later.
---
## 4. Data model (full in `POS_DATA_MODEL.md`)
New tables (concise; full DDL in `POS_DATA_MODEL.md`):
| Table | Purpose | Key fields |
|---|---|---|
| `pos_stores` | Physical stores | `id, name, address, tax_id, settings_jsonb` |
| `pos_terminals` | One TPV device | `id, store_id, name, status, interface_mode, settings_jsonb, last_seen_at` |
| `pos_cash_sessions` | Open/close register | `id, terminal_id, store_id, user_id, opened_at, closed_at, opening_cash_cents, closing_cash_cents, expected_cash_cents, actual_cash_cents, difference_cents, status` |
| `pos_quick_products` | Favorite buttons | `id, store_id, terminal_id?, user_id?, product_id, variant_id, position` |
| `pos_parked_tickets` | Held tickets | `id, store_id, terminal_id, user_id, customer_id?, cart_jsonb, created_at, expires_at` |
| `pos_payment_methods` | Configurable payment types | `id, store_id, code, label, kind ('cash'\|'card'\|'other'), active, sort_order` |
Modified tables:
| Table | Change |
|---|---|
| `inventory_stock` | Add `store_id uuid NOT NULL DEFAULT <main store>`, FK to `pos_stores`. Replace unique index with `(variant_id, store_id)`. |
| `orders_orders` | Add `source text NOT NULL DEFAULT 'ecommerce' CHECK (source IN ('ecommerce','pos','admin'))`. Add `terminal_id uuid NULL`, `cash_session_id uuid NULL`. Add `COMPLETED` to state CHECK. |
| `backoffice_users.role` | Extend CHECK to include `'pos_cashier'`, `'pos_manager'`. |
No new customer/user table — POS sales link to existing `identity_users` (or NULL for walk-ins).
---
## 5. API surface (full in `POS_API.md`)
Mounted under existing Fastify via `registerPosRoutes(app, deps)` in `build-app.ts`. All under `/pos/...`. Reuses `requireRole` and `requireAnyRole` from `shared/auth.ts`. Cookie auth same as the rest.
| Method | Path | Purpose | Role |
|---|---|---|---|
| GET | `/pos/stores` | List stores | admin |
| POST | `/pos/stores` | Create store | admin |
| GET | `/pos/terminals` | List terminals | admin / pos_manager |
| POST | `/pos/terminals` | Register a terminal (returns binding code) | admin |
| GET | `/pos/terminals/me` | Current terminal by cookie | any pos |
| PATCH | `/pos/terminals/:id` | Update terminal settings | admin / pos_manager |
| GET | `/pos/sessions/me` | Current open session for terminal | any pos |
| POST | `/pos/sessions` | Open register | pos_cashier / pos_manager |
| POST | `/pos/sessions/:id/close` | Close register (counted cash) | pos_cashier / pos_manager |
| GET | `/pos/config` | Returns `{ terminal, store, session, paymentMethods, taxRates, categoriesTree }` | any pos |
| GET | `/pos/products/search?q=...` | Server-side search (delegates to catalog) | any pos |
| GET | `/pos/products/by-ean/:ean` | Direct lookup by EAN (fast path for scanner) | any pos |
| GET | `/pos/products/by-sku/:sku` | Direct lookup by SKU | any pos |
| GET | `/pos/quick-products` | Quick buttons for the current (terminal, user) | any pos |
| POST | `/pos/quick-products` | Add a quick product | pos_manager |
| DELETE | `/pos/quick-products/:id` | Remove | pos_manager |
| POST | `/pos/sales` | Create sale (idempotent) | pos_cashier+ |
| GET | `/pos/sales` | List recent sales (scoped to terminal/store) | any pos |
| GET | `/pos/sales/:id` | Sale detail | any pos |
| POST | `/pos/sales/:id/refund` | Refund (full or partial) | pos_manager |
| POST | `/pos/sales/:id/print` | Reprint receipt | any pos |
| POST | `/pos/parked-tickets` | Park current cart | pos_cashier+ |
| GET | `/pos/parked-tickets` | List parked tickets for store | pos_cashier+ |
| DELETE | `/pos/parked-tickets/:id` | Discard parked ticket | pos_cashier+ |
All POST/PATCH endpoints validate with `parseJson(zodSchema, body)`. All errors are `AppError`-shaped. All write actions append to `security_audit_log` via `AuditLogger.log`.
---
## 6. Reusable components
Existing modules reused as-is, no edits:
- `Authenticate` port, `parseJson`, `AppError`, `parseJson` (validation), `AuditLogger`.
- `OrderService` (with `source` extension), `InventoryService` (with `store_id` extension).
- `PricingService`, `TaxRateRepository`, `CatalogSearch`, `BrandRepository`, `CategoryRepository`, `ProductRepository`, `VariantRepository`.
- `CustomerRepository` from `users` for ticket association.
- `StoreSettingsRepository` for ticket header/footer.
Existing frontend utilities reused:
- `lib/api-client.ts` pattern (copy + extend with `posApi`).
- `lib/permissions.ts` extended with `POS_*` permissions.
- `AuthProvider` pattern copied and adapted (no shared package yet).
- Tailwind theme tokens (the `@theme` block in globals.css is identical).
---
## 7. Risks
| Risk | Severity | Mitigation |
|---|---|---|
| **Multi-store migration is destructive** | high | Migration backfills all existing rows with a single default store; reversible via SQL script kept in `scripts/`. Operator must approve before Phase 2 starts. |
| **Inventory race between POS and ecommerce** | high | All inventory writes go through `InventoryService.reserve/confirm`; same transaction model already used by checkout. Documented invariant: reservation is the only way to consume stock. |
| **`COMPLETED` state breaks existing admin workflows** | medium | State machine is additive; existing `DELIVERED` flow is unchanged. Admin orders stay on `DELIVERED`. POS-only orders end on `COMPLETED`. |
| **Cookie sharing between admin and POS apps** | low | Same domain; both apps read the same cookie. If POS is deployed to a different domain, swap to a JWT or shared session service (Phase 8). |
| **Idempotency conflicts** | medium | `idempotency_key` is already unique. The POS UI generates a UUID per "Cobrar" click; if the user clicks twice within ~50 ms, only one wins. |
| **Hardware fragmentation** | medium | All hardware behind adapters; no vendor SDK in core. Browser impls ship first (Phase 2/3); native bridge in Phase 7 only when operator commits to a vendor. |
| **Touchscreen keyboard** | low | React `NumericKeypad` component; no system-level dependencies. |
| **Refunds for split payments** | medium | Partial refund = multiple `payment_transactions` with negative `amount_cents`. Existing `payments` module already supports this. |
---
## 8. Phased plan
The brief already defines 7 phases. The list below translates them into Orquestra-friendly tickets:
| Phase | Ticket(s) | Goal |
|---|---|---|
| 0 | **POS-001** (this) | Discovery + architecture docs. |
| 1 | POS-002 to POS-010 | **Core TPV.** Schema migrations (stores, terminals, sessions, quick-products, parked-tickets, payment-methods). Multi-store inventory migration. `OrderService.source` extension. `COMPLETED` state. New `pos` module + routes + repository + unit tests. Frontend app skeleton with `POSLayout`, header, product search, categories, quick products, cart, totals. |
| 2 | POS-011 to POS-016 | **Checkout.** Cash payment (with change calculation), card manual entry, idempotent sale creation, stock decrement, receipt rendering, optional customer association, history. |
| 3 | POS-017 to POS-022 | **Cash register.** Open/close session, expected vs actual cash, difference capture, audit trail, end-of-shift summary. |
| 4 | POS-023 to POS-028 | **Multi-terminal.** Stores + terminals admin UI, terminal binding code flow, per-terminal quick products, per-store payment methods, concurrent terminals (already supported by HTTP). |
| 5 | POS-029 to POS-033 | **Touch UI.** Larger hit targets, virtual keypad, scanner auto-focus, keyboard shortcuts (F2/F4/F6/F8/F9/F10/ESC), responsive 1024×768 → 1920×1080, no horizontal scroll. |
| 6 | POS-034 to POS-040 | **Hardware.** Adapter interfaces finalized. Browser implementations complete. ESC/POS + USB scanner bridge behind a thin Node sidecar (out-of-process HTTP) — only the spec is written here; the bridge itself is built when the operator picks a vendor. |
| 7 | POS-041+ | **Polish.** Refunds UI, returns, partial refunds, gift receipts, scale integration, multi-currency (deferred to operator demand). |
Each phase ends with **all three gates approved (reviewer, security, qa)** and `verify.sh` exit 0.
---
## 9. Open questions for the operator
These are decisions the architect cannot take unilaterally and that block Phase 2:
1.**Default store name****RESOLVED**: "Natural - Mercado de Vida" (slug `natural-mercado-de-vida`).
2.**Cookie domain****RESOLVED**: separate port `:3006` for the POS app (alongside admin `:3004`). Cookie is set with `Domain` unset (so the browser scopes it to the POS origin only). When the operator deploys to production with Traefik and subdomains, the cookie strategy will be revisited (Phase 4 cleanup): at that point `Domain=.mercadodevida.example` allows admin and POS to share the same backoffice session.
3.**Touchscreen target hardware****RESOLVED**: 10-inch tablet in landscape. Responsive design from 1024×768 to 1920×1080, with a sensible tablet portrait mode (800×1280) as a stretch goal. The touch CSS uses media query `(pointer: coarse)` to detect and switch; manual override stored in cookie.
4.**Datáfono provider****RESOLVED**: stays manual in Phase 3. Phase 7 (`POS-038`) deferred — only reopens if the operator picks SumUp, Redsys TPVO, or Verifone.
5.**Refund policy defaults****RESOLVED**: refunds allowed up to 15 days after sale. Manager approval required for refunds above 50 € (configurable in `store_settings`).
The remaining questions (printer model, scanner type, cash drawer wiring, scale brand) are documented in `POS_HARDWARE.md §10` and resolved there.
---
## 10. Definition of done (Phase 1)
- ✅ This document (`POS_ARCHITECTURE.md`) signed off by leader.
-`POS_DATA_MODEL.md`, `POS_API.md`, `POS_HARDWARE.md`, `POS_OPERATIONS.md` exist with concrete specs.
-`POS_TASKS.md` lists P0P3 prioritized tickets queued in the backlog (POS-002+).
-`verify.sh` exit 0.
-`runtime-status.json` reset to idle.
**No code is touched in this phase beyond the docs.**