# 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 1–5: 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 P0–P3 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.**
