Files
mercadodevida/docs/pos/POS_HARDWARE.md
2026-08-21 21:24:58 +02:00

14 KiB
Raw Blame History

POS Hardware — Mercado de Vida

Companion to: 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:

// 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:

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:

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:

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.

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:

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:

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):

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. Open questions for the operator

  1. Printer model for ESC/POS commands (Epson, Star, Citizen)?
  2. Datáfono provider (none for Phase 3; SumUp / Redsys TPVO / Verifone for Phase 7)?
  3. Scale brand if any (Phase 7+; not needed for Phase 2-6)?
  4. Cash drawer wiring: kick-out via printer, or via separate RJ11/RJ12 port?
  5. Scanner type: HID-only, or do we need serial/USB-HID native drivers?