14 KiB
POS Hardware — Mercado de Vida
Companion to:
POS_ARCHITECTURE.mdStatus: 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) ifGET /healthreturned 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:
- Zero vendor imports in
apps/pos/src/orproject/src/modules/pos/. - Vendor SDKs only in
project/src/shared/hardware/native/<vendor>/— a subdirectory added per vendor only when the operator commits. - 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, theverifone-bridge. The POS core does not change.
10. Hardware decisions (resolved)
- ✅ 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 useswindow.print()and ignores the command set. - ✅ Datáfono provider — RESOLVED: none for Phase 3 (manual auth code entry). Phase 7 (
POS-038) deferred indefinitely. - ✅ Scale brand — RESOLVED: none at first.
POS-039removed from the active P3 list. Reopens only if a scale is procured. - ✅ Cash drawer wiring — RESOLVED: kick-out via printer (the
ESC p 0byte 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. - ✅ 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).