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

366 lines
14 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 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).