# 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<TConfig = unknown> {
  /** 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<void>;

  /** 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<ScannerConfig> {
  onCode(callback: (code: string) => void): () => void;
}

export interface PrinterAdapter extends HardwareAdapter<PrinterConfig> {
  print(receipt: ReceiptPayload): Promise<
    | { ok: true; jobId: string }
    | { ok: false; reason: 'offline' | 'paper-out' | 'error'; message: string }
  >;
}

export interface CashDrawerAdapter extends HardwareAdapter<CashDrawerConfig> {
  open(): Promise<{ ok: boolean; reason?: string }>;
}

export interface PaymentTerminalAdapter extends HardwareAdapter<PaymentTerminalConfig> {
  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<ScaleConfig> {
  readGrams(): Promise<number | null>;
}
```

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<void> {
    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: <code>')` 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<void> { 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<never> {
    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=<operator input>`, `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/<vendor>/` — 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).
