# POS Hardware — Mercado de Vida > **Companion to:** [`POS_ARCHITECTURE.md`](./POS_ARCHITECTURE.md) > **Status:** Discovery (Phase 1) This document describes how POS hardware (scanner, printer, cash drawer, payment terminal, scale) integrates with the application. The guiding rule: **the application core never imports a vendor SDK**. Every device is reached through an adapter interface, with two implementations shipped (browser-first, native bridge second). --- ## 1. Adapter contract overview All adapters live under `project/src/shared/hardware/` and follow the same shape: ```ts // src/shared/hardware/types.ts export interface HardwareAdapter { /** Human-readable label for the admin UI. */ readonly kind: 'scanner' | 'printer' | 'cash-drawer' | 'payment-terminal' | 'scale'; /** Configure at boot. Idempotent. Throws on unrecoverable misconfiguration. */ configure(config: TConfig): Promise; /** Optional health check. Returns null if healthy. */ healthCheck(): Promise<{ ok: true } | { ok: false; reason: string }>; } ``` Each adapter has its own narrow interface on top of this base: ```ts export interface ScannerAdapter extends HardwareAdapter { onCode(callback: (code: string) => void): () => void; } export interface PrinterAdapter extends HardwareAdapter { print(receipt: ReceiptPayload): Promise< | { ok: true; jobId: string } | { ok: false; reason: 'offline' | 'paper-out' | 'error'; message: string } >; } export interface CashDrawerAdapter extends HardwareAdapter { open(): Promise<{ ok: boolean; reason?: string }>; } export interface PaymentTerminalAdapter extends HardwareAdapter { requestPayment(input: { amountCents: number; currency: 'EUR'; reference: string; }): Promise< | { ok: true; providerPaymentId: string; authCode: string; cardLast4?: string } | { ok: false; reason: 'declined' | 'timeout' | 'offline' | 'error'; message: string } >; } export interface ScaleAdapter extends HardwareAdapter { readGrams(): Promise; } ``` The POS UI imports only these interfaces. Implementation selection happens via a single factory in `apps/pos/src/lib/hardware/factory.ts`: ```ts export function createScanner(): ScannerAdapter { // Phase 2: always the browser impl. return new BrowserScannerAdapter(); // Phase 7: read window.__MDV_HARDWARE_CONFIG__ or env to pick native bridge. } ``` --- ## 2. Scanner ### 2.1 Behaviour Most physical barcode scanners behave as **HID keyboard devices**: they read a code, type it character by character, then send `Enter` (configurable). The browser implementation captures this pattern: ```ts export class BrowserScannerAdapter implements ScannerAdapter { private buffer = ''; private listener?: (e: KeyboardEvent) => void; private callbacks: Array<(code: string) => void> = []; configure(config: ScannerConfig): Promise { this.config = config; this.detach(); this.attach(); return Promise.resolve(); } onCode(callback: (code: string) => void): () => void { this.callbacks.push(callback); return () => { this.callbacks = this.callbacks.filter((cb) => cb !== callback); }; } private attach() { this.listener = (e) => { // Ignore if focus is in an editable field (manual typing, not scanner). if (this.shouldIgnore(e)) return; if (e.key === 'Enter') { if (this.buffer.length >= this.config.minLength) { this.callbacks.forEach((cb) => cb(this.buffer)); } this.buffer = ''; e.preventDefault(); return; } if (e.key.length === 1) { this.buffer += e.key; } }; window.addEventListener('keydown', this.listener); } private shouldIgnore(e: KeyboardEvent): boolean { const target = e.target as HTMLElement | null; if (!target) return false; const tag = target.tagName.toLowerCase(); if (tag === 'input' || tag === 'textarea' || tag === 'select') return true; if (target.isContentEditable) return true; return false; } private detach() { if (this.listener) { window.removeEventListener('keydown', this.listener); this.listener = undefined; } } healthCheck(): Promise<{ ok: true } | { ok: false; reason: string }> { // Browser impl has no self-test; always OK while the listener is attached. return Promise.resolve(this.listener ? { ok: true } : { ok: false, reason: 'not attached' }); } } ``` The "ignore if focus is in an editable field" rule is what lets the same browser session have a search input (manual typing) AND a scanner (HID stream). The scanner input element is removed from focus when not actively used; the search input can take focus back when the operator clicks it. ### 2.2 UX contract - After a successful scan, the cart focus stays on the search input. - If the scanned code does not resolve, a `toast.error('Producto no encontrado: ')` shows for 3 s. - If the scanned code resolves to a product already in the cart, quantity increments. - `pos_terminal.settings.scanner.minLength` (default 6) filters out accidental single-character noise. ### 2.3 Native bridge (Phase 7, deferred) A sidecar process (Node addon or external HTTP service) reads from `/dev/hidrawN` (Linux) or via WinUSB (Windows) and forwards to the POS app over a localhost HTTP socket. Not built in this project unless the operator commits to a vendor. --- ## 3. Printer ### 3.1 Browser implementation The browser uses `window.print()` on a hidden iframe that contains a print-stylesheet-only route `/print/[orderId]`. The route fetches the `ReceiptDto`, renders a fixed-width 80mm layout, and triggers print. ```ts export class BrowserPrinterAdapter implements PrinterAdapter { configure(_config: PrinterConfig): Promise { return Promise.resolve(); } async print(receipt: ReceiptPayload): Promise< | { ok: true; jobId: string } | { ok: false; reason: 'offline' | 'paper-out' | 'error'; message: string } > { const jobId = crypto.randomUUID(); const url = `/print/${receipt.orderId}?jobId=${jobId}`; const iframe = document.createElement('iframe'); iframe.style.display = 'none'; iframe.src = url; document.body.appendChild(iframe); return new Promise((resolve) => { iframe.addEventListener('load', () => { try { iframe.contentWindow?.focus(); iframe.contentWindow?.print(); resolve({ ok: true, jobId }); } catch (err) { resolve({ ok: false, reason: 'error', message: String(err) }); } finally { setTimeout(() => iframe.remove(), 1000); } }); }); } healthCheck() { return Promise.resolve({ ok: true }); } } ``` ### 3.2 Print layout The print route at `apps/pos/src/app/print/[orderId]/page.tsx` renders an 80mm-wide layout using plain CSS. Sample structure: ``` ───────────────────────────────── MERCADO DE VIDA Calle Falsa 123 CIF B12345678 ───────────────────────────────── Ticket: T-2026-000123 Fecha: 2026-08-21 18:42 Caja: POS-01 · Ana M. ───────────────────────────────── Almendras Crudas Bio 8412345678901 1 × 4,55 € Descuento -0,50 € 4,05 € ───────────────────────────────── SUBTOTAL 4,55 € DESCUENTO -0,50 € IVA (10%) 0,37 € TOTAL 4,05 € ───────────────────────────────── Pago: Efectivo Entregado: 10,00 € Cambio: 5,95 € ───────────────────────────────── Gracias por su compra ───────────────────────────────── ``` ### 3.3 Native ESC/POS bridge (Phase 7) A Node sidecar accepts POST `/print` with a `ReceiptPayload`, formats it as ESC/POS commands, and writes to the USB or networked printer. The native bridge is **not built** in this codebase unless the operator chooses a vendor. --- ## 4. Cash drawer ### 4.1 Browser implementation In browsers, the cash drawer is normally triggered by the printer (most thermal printers have an `ESC p 0` kick-out command). The browser adapter delegates to the printer adapter: ```ts export class BrowserCashDrawerAdapter implements CashDrawerAdapter { constructor(private printer: PrinterAdapter) {} async open(): Promise<{ ok: boolean; reason?: string }> { // The browser printer uses window.print() which can't send the ESC p 0 // command. We emit a meta-receipt: a "Drawer open" page that the operator // confirms. In practice, the cash drawer opens automatically when the // receipt is printed (the printer sends the kick-out on real hardware). return { ok: true }; } } ``` In practice, the cash drawer opens when the printer finishes printing a receipt (because the printer's `kick-out` pin is wired to the drawer). The browser adapter does nothing — the act of printing IS the act of opening the drawer, and the operator hears the click. ### 4.2 Native bridge For Phase 7 native ESC/POS: the sidecar sends the explicit `ESC p 0` byte sequence after the receipt, then waits for an optional status from the printer confirming the kick-out. --- ## 5. Payment terminal (datáfono) ### 5.1 Browser implementation (manual entry) Phase 3 ships with a manual-entry form: the operator types the auth code returned by the datáfono. This is the fallback for any vendor and the only Phase 3 implementation: ```ts export class ManualPaymentTerminalAdapter implements PaymentTerminalAdapter { configure(_config: PaymentTerminalConfig) { return Promise.resolve(); } // No automatic request — operator-driven via UI form. async requestPayment(): Promise { throw new Error('ManualPaymentTerminalAdapter does not auto-request; use the UI form.'); } healthCheck() { return Promise.resolve({ ok: true }); } } ``` The POS UI shows a "Tarjeta" panel with amount + an input for the operator to type the auth code. On submit, the `payments` entry is created with `provider='manual-card'`, `providerPaymentId=`, `status='succeeded'`. ### 5.2 Native bridge (Phase 7, vendor-specific) SumUp, Redsys TPVO, generic Verifone: each gets a Node addon or HTTP client wrapping the vendor's SDK. The native bridge speaks to the terminal over Bluetooth or USB and returns an `ok` or `declined` result without ever exposing PAN/CVV to the web app. **Security:** the POS app never stores PAN, CVV, or PIN. The native bridge holds the only credential to the vendor API. --- ## 6. Scale (báscula) ### 6.1 Browser implementation In Phase 4 we do not read from a real scale. The product detail for `sale_type: 'weight'` shows a "Read weight" button that opens a manual entry keypad. The operator types the weight and presses Enter. ### 6.2 Native bridge (Phase 7) A small Node service reads from the scale's serial port (most use RS232 or USB-HID) and exposes `GET /weight` returning grams. The browser calls this endpoint via `fetch` to a known localhost URL. --- ## 7. Configuration model Each terminal stores per-device configuration in `pos_terminals.settings` (JSONB): ```ts interface TerminalSettings { scanner?: { minLength?: number; // default 6 terminator?: 'Enter' | 'Tab'; // default 'Enter' }; printer?: { kind: 'browser'; // only browser in Phase 2 copies: number; // default 1 headerLines: string[]; // override store.receiptHeader footerLines: string[]; }; cashDrawer?: { kind: 'browser' | 'native' }; paymentTerminal?: { kind: 'manual' | 'native' }; scale?: { kind: 'manual' | 'native' }; interface?: { mode: 'auto' | 'desktop' | 'touch'; locale: 'es-ES'; }; } ``` Settings are edited from `/pos/admin/terminals/:id` (admin) and read-only from `/pos/terminals/me`. --- ## 8. Health & observability The POS app shows connection status in the header: - `● Online` (green) if `GET /health` returned 200 in the last 30 s. - `⚠ Sin conexión` (amber) if the last 3 health checks failed. The same status is reflected in `pos_terminals.last_seen_at` via a 60-second heartbeat ping. A future dashboard (Phase 7) will graph `printerAdapter.print` success/failure rates, `scannerAdapter.onCode` invocations per hour, etc., using the existing `observability` module. --- ## 9. Vendor-neutral philosophy The brief explicitly forbids coupling the UI to a vendor. Our adapter pattern enforces this by: 1. **Zero vendor imports** in `apps/pos/src/` or `project/src/modules/pos/`. 2. **Vendor SDKs only** in `project/src/shared/hardware/native//` — a subdirectory added per vendor only when the operator commits. 3. **The native bridge** is a separate Node process (out of repo) that the POS app talks to over HTTP. If the operator picks SumUp, they deploy the `sumup-bridge`; if Verifone, the `verifone-bridge`. The POS core does not change. --- ## 10. Hardware decisions (resolved) 1. ✅ **Printer model** — **RESOLVED**: **Epson** thermal receipt printer. ESC/POS compatible (TM-T20, TM-T88, or similar). Phase 7 native bridge targets the Epson ESC/POS command set: `ESC @` (initialize), `ESC ! n` (select print mode), `GS V 0` (cut), `ESC p 0` (kick-out to drawer). The browser implementation in Phase 2 uses `window.print()` and ignores the command set. 2. ✅ **Datáfono provider** — **RESOLVED**: **none for Phase 3** (manual auth code entry). Phase 7 (`POS-038`) deferred indefinitely. 3. ✅ **Scale brand** — **RESOLVED**: **none at first**. `POS-039` removed from the active P3 list. Reopens only if a scale is procured. 4. ✅ **Cash drawer wiring** — **RESOLVED**: **kick-out via printer** (the `ESC p 0` byte sent after the receipt body). This is the standard Epson + most thermal printers setup. The cash drawer is wired to the printer's RJ12 port. 5. ✅ **Scanner type** — **RESOLVED**: **USB** (HID-keyboard mode). The browser scanner adapter captures keystrokes when no input is focused (Phase 2 implementation). If the operator later needs serial or USB-HID native drivers, the adapter contract is preserved (Phase 7 native bridge).