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