feat(POS-001): completed feature
This commit is contained in:
365
docs/pos/POS_HARDWARE.md
Normal file
365
docs/pos/POS_HARDWARE.md
Normal file
@@ -0,0 +1,365 @@
|
||||
# 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. 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?
|
||||
Reference in New Issue
Block a user