feat(POS-001): completed feature
This commit is contained in:
379
docs/pos/POS_API.md
Normal file
379
docs/pos/POS_API.md
Normal file
@@ -0,0 +1,379 @@
|
||||
# POS API — Mercado de Vida
|
||||
|
||||
> **Companion to:** [`POS_ARCHITECTURE.md`](./POS_ARCHITECTURE.md) · [`POS_DATA_MODEL.md`](./POS_DATA_MODEL.md)
|
||||
> **Status:** Discovery (Phase 1)
|
||||
|
||||
This document describes every HTTP endpoint exposed under `/pos/*`. Auth is the same cookie-based session used by the admin app (backoffice session). Roles: `admin`, `pos_manager`, `pos_cashier`. Every request is validated through `parseJson(zodSchema, body)` and every error is `AppError`-shaped.
|
||||
|
||||
Base URL conventions:
|
||||
|
||||
- Public storefront admin paths: already `/admin/*`.
|
||||
- POS paths: `/pos/*`.
|
||||
- POS admin (terminal / store CRUD): `/pos/admin/*` (mounted alongside the POS UI's admin functions; not the same as the existing `/admin/*`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Stores
|
||||
|
||||
| Method | Path | Roles | Notes |
|
||||
|---|---|---|---|
|
||||
| GET | `/pos/admin/stores` | `admin` | List all stores. |
|
||||
| POST | `/pos/admin/stores` | `admin` | Create store. Body: `{ name, slug, address?, taxId?, contactEmail?, contactPhone?, receiptHeader?, receiptFooter? }`. |
|
||||
| GET | `/pos/admin/stores/:id` | `admin` | Detail. |
|
||||
| PATCH | `/pos/admin/stores/:id` | `admin` | Partial update. |
|
||||
| POST | `/pos/admin/stores/:id/deactivate` | `admin` | Soft delete (sets `active=false`). |
|
||||
|
||||
```ts
|
||||
// Response shape
|
||||
interface StoreDto {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
address: string | null;
|
||||
taxId: string | null;
|
||||
contactEmail: string | null;
|
||||
contactPhone: string | null;
|
||||
receiptHeader: string | null;
|
||||
receiptFooter: string | null;
|
||||
settings: Record<string, unknown>;
|
||||
active: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Terminals
|
||||
|
||||
| Method | Path | Roles | Notes |
|
||||
|---|---|---|---|
|
||||
| GET | `/pos/admin/terminals` | `admin`, `pos_manager` | Filterable by `?storeId=`. |
|
||||
| POST | `/pos/admin/terminals` | `admin` | Body: `{ storeId, name }`. Returns the terminal with a one-time `bindingCode` (6-char alphanumeric, expires in 24 h). |
|
||||
| GET | `/pos/admin/terminals/:id` | `admin`, `pos_manager` | |
|
||||
| PATCH | `/pos/admin/terminals/:id` | `admin`, `pos_manager` | Update name, interface mode, settings JSON. Cannot move to another store (decommission + recreate instead). |
|
||||
| POST | `/pos/admin/terminals/:id/decommission` | `admin` | Status → `decommissioned`. Terminal cannot be used. |
|
||||
|
||||
**Self-service (no admin role required):**
|
||||
|
||||
| Method | Path | Notes |
|
||||
|---|---|---|
|
||||
| GET | `/pos/terminals/me` | Returns the terminal resolved from the `pos_terminal_id` cookie. 404 if not bound. |
|
||||
| POST | `/pos/terminals/bind` | Body: `{ bindingCode }`. Sets `pos_terminal_id` cookie (Secure, 1y), updates DB. Used on first launch. |
|
||||
|
||||
```ts
|
||||
interface TerminalDto {
|
||||
id: string;
|
||||
storeId: string;
|
||||
name: string;
|
||||
status: 'active' | 'disabled' | 'decommissioned';
|
||||
interfaceMode: 'desktop' | 'touch' | 'auto';
|
||||
settings: Record<string, unknown>;
|
||||
boundAt: string | null;
|
||||
lastSeenAt: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Cash sessions
|
||||
|
||||
| Method | Path | Roles | Notes |
|
||||
|---|---|---|---|
|
||||
| GET | `/pos/sessions/me` | any pos | Returns the open session for the current terminal, or 204. |
|
||||
| POST | `/pos/sessions` | `pos_cashier`, `pos_manager`, `admin` | Body: `{ terminalId, openingCashCents }`. 409 if a session is already open. |
|
||||
| POST | `/pos/sessions/:id/close` | `pos_cashier`, `pos_manager`, `admin` | Body: `{ closingCashCents, notes? }`. Server computes `expectedCashCents`, sets `differenceCents`. |
|
||||
| GET | `/pos/admin/sessions` | `admin`, `pos_manager` | List sessions, filterable by `?terminalId=&from=&to=`. |
|
||||
| GET | `/pos/admin/sessions/:id` | `admin`, `pos_manager` | Detail with summary (sales count, totals per payment method, refunds). |
|
||||
|
||||
```ts
|
||||
interface CashSessionDto {
|
||||
id: string;
|
||||
terminalId: string;
|
||||
storeId: string;
|
||||
userId: string;
|
||||
status: 'OPEN' | 'CLOSED';
|
||||
openedAt: string;
|
||||
closedAt: string | null;
|
||||
openingCashCents: number;
|
||||
closingCashCents: number | null;
|
||||
expectedCashCents: number | null;
|
||||
actualCashCents: number | null;
|
||||
differenceCents: number | null;
|
||||
notes: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Configuration bundle (single round-trip)
|
||||
|
||||
| Method | Path | Roles | Notes |
|
||||
|---|---|---|---|
|
||||
| GET | `/pos/config` | any pos | Returns everything the POS UI needs to bootstrap. Includes terminal, store, current session (if open), payment methods, active tax rates, top-level categories. |
|
||||
|
||||
```ts
|
||||
interface PosConfigDto {
|
||||
terminal: TerminalDto;
|
||||
store: StoreDto;
|
||||
session: CashSessionDto | null;
|
||||
paymentMethods: PaymentMethodDto[];
|
||||
taxRates: Array<{ appliesTo: 'general'|'reduced'|'super-reduced'; name: string; ratePercent: number }>;
|
||||
categories: Array<{ id: string; name: string; slug: string; emoji: string | null }>;
|
||||
}
|
||||
```
|
||||
|
||||
This is the only endpoint the POS hits on startup. Cached client-side for the session duration; the `updated_at` on store_settings forces a re-fetch when the operator changes store info.
|
||||
|
||||
---
|
||||
|
||||
## 5. Product search (POS-shaped)
|
||||
|
||||
The POS re-uses catalog search but asks for what it needs (price + image + tax rate) in one call.
|
||||
|
||||
| Method | Path | Roles | Notes |
|
||||
|---|---|---|---|
|
||||
| GET | `/pos/products/search?q=&limit=20` | any pos | Delegates to `catalog.search`. Returns up to `limit` results with denormalised price (for the terminal's store). |
|
||||
| GET | `/pos/products/by-ean/:ean` | any pos | Exact lookup. 404 if not found. Used by the scanner. |
|
||||
| GET | `/pos/products/by-sku/:sku` | any pos | Exact lookup. 404 if not found. |
|
||||
| GET | `/pos/products/:variantId` | any pos | Detail. Used when opening a quick-product tile without prior search. |
|
||||
|
||||
```ts
|
||||
interface PosProductDto {
|
||||
productId: string;
|
||||
variantId: string;
|
||||
sku: string;
|
||||
ean: string | null;
|
||||
name: string;
|
||||
brandName: string | null;
|
||||
categoryNames: string[]; // flattened path
|
||||
mainImageUrl: string | null;
|
||||
unitPriceCents: number; // net (without VAT)
|
||||
grossPriceCents: number; // VAT-inclusive, displayed
|
||||
vatRate: 'general'|'reduced'|'super-reduced';
|
||||
vatPercent: number;
|
||||
available: number; // inventory for THIS store
|
||||
}
|
||||
```
|
||||
|
||||
All four endpoints return the same shape. Search results are sorted by relevance (catalog's existing FTS ranking) then alphabetically.
|
||||
|
||||
**Performance budget:** p95 ≤ 200 ms (cached price + cached category + 1 stock check). The `PosProductCache` module (Phase 1 already has `cache` module) keeps a 5-minute TTL keyed by `(storeId, ean|sku|q)`. Cache is invalidated when a sale completes.
|
||||
|
||||
---
|
||||
|
||||
## 6. Quick products
|
||||
|
||||
| Method | Path | Roles | Notes |
|
||||
|---|---|---|---|
|
||||
| GET | `/pos/quick-products` | any pos | Returns the active quick products visible to this terminal: union of `store + terminal + user`. Sorted by `position`. |
|
||||
| POST | `/pos/admin/quick-products` | `admin`, `pos_manager` | Body: `{ scope: 'store'\|'terminal'\|'user', storeId, terminalId?, userId?, variantId, position? }`. |
|
||||
| PATCH | `/pos/admin/quick-products/:id` | `admin`, `pos_manager` | Reorder or toggle active. |
|
||||
| DELETE | `/pos/admin/quick-products/:id` | `admin`, `pos_manager` | |
|
||||
|
||||
---
|
||||
|
||||
## 7. Sales
|
||||
|
||||
### 7.1 Create sale (the critical endpoint)
|
||||
|
||||
| Method | Path | Roles | Notes |
|
||||
|---|---|---|---|
|
||||
| POST | `/pos/sales` | `pos_cashier`, `pos_manager`, `admin` | Body (see below). Returns the completed order with receipt. |
|
||||
|
||||
```ts
|
||||
// Request
|
||||
interface CreatePosSaleRequest {
|
||||
idempotencyKey: string; // UUID v4 generated client-side
|
||||
terminalId: string; // from cookie
|
||||
cashSessionId: string; // from current open session
|
||||
customerId?: string; // optional
|
||||
items: Array<{
|
||||
variantId: string;
|
||||
quantity: number; // >= 1
|
||||
discountCents?: number; // optional line discount
|
||||
}>;
|
||||
globalDiscountCents?: number; // applied after lines
|
||||
payments: Array<{
|
||||
methodCode: string; // 'cash' | 'card' | other configured code
|
||||
amountCents: number;
|
||||
cashTenderedCents?: number; // for 'cash' — what the customer gave
|
||||
providerAuthCode?: string; // for 'card' — operator-entered
|
||||
providerPaymentId?: string; // for 'card' — optional
|
||||
}>;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
// Response
|
||||
interface PosSaleResponse {
|
||||
order: OrderDto;
|
||||
receipt: ReceiptDto;
|
||||
cashChangeCents: number | null; // computed for cash payments
|
||||
warnings: string[]; // e.g. 'price changed since cart'
|
||||
}
|
||||
```
|
||||
|
||||
**Behaviour:**
|
||||
|
||||
- If `idempotencyKey` already exists, return the existing order (200, not 201).
|
||||
- The server re-prices every item from `PricingService.getVariantPrice`; client-sent prices are ignored.
|
||||
- The server checks `InventoryService.checkAvailability` per item.
|
||||
- The server computes `subtotalCents`, `discountCents`, `taxCents`, `totalCents`.
|
||||
- The whole flow runs in a single PG transaction:
|
||||
1. `INSERT INTO orders_orders ... source='pos', state='PENDING'`.
|
||||
2. `INSERT INTO orders_items` (one per item).
|
||||
3. For each item: `InventoryService.reserve(variantId, storeId, qty)`.
|
||||
4. `UPDATE orders_orders SET state='AWAITING_PAYMENT'`.
|
||||
5. For each payment: `INSERT INTO payments_transactions`.
|
||||
6. `UPDATE orders_orders SET state='PAID'`.
|
||||
7. For each item: `InventoryService.confirm(reservation)`.
|
||||
8. `UPDATE orders_orders SET state='COMPLETED'`.
|
||||
9. `AuditLogger.log('pos.sale.created', ...)`.
|
||||
- On any error inside the transaction: `ROLLBACK`. The API returns 4xx/5xx without partial state.
|
||||
- `warnings` is non-empty when a price or stock changed between cart and confirm.
|
||||
|
||||
**Status codes:**
|
||||
|
||||
- `201 Created` on first success.
|
||||
- `200 OK` if the `idempotencyKey` already exists (returns the existing order).
|
||||
- `400` if validation fails.
|
||||
- `409 Conflict` if stock is unavailable (`PRICING_STOCK_UNAVAILABLE`) or session is closed (`POS_SESSION_CLOSED`).
|
||||
- `422` if a discount exceeds the configured limit.
|
||||
- `500` for unexpected errors.
|
||||
|
||||
### 7.2 List and detail
|
||||
|
||||
| Method | Path | Roles | Notes |
|
||||
|---|---|---|---|
|
||||
| GET | `/pos/sales?from=&to=&limit=20` | any pos | Scoped to the terminal's store. |
|
||||
| GET | `/pos/sales/:id` | any pos | Detail. Includes items, payments, audit excerpt. |
|
||||
| GET | `/pos/sales/:id/receipt` | any pos | Renders a structured receipt (for re-printing). |
|
||||
|
||||
### 7.3 Refunds
|
||||
|
||||
| Method | Path | Roles | Notes |
|
||||
|---|---|---|---|
|
||||
| POST | `/pos/sales/:id/refund` | `pos_manager`, `admin` | Body: `{ items: Array<{ variantId, quantity }>, reason, payments?: Array<{ methodCode, amountCents }> }`. Negative `payments_transactions` entries are created. State → `PARTIALLY_REFUNDED` (partial) or `REFUNDED` (full). Stock is added back per refunded item via `InventoryService.setAvailable` (creates a new movement with `operation='set_available'`). |
|
||||
|
||||
### 7.4 Cancel
|
||||
|
||||
| Method | Path | Roles | Notes |
|
||||
|---|---|---|---|
|
||||
| POST | `/pos/sales/:id/cancel` | `pos_manager`, `admin` | Only allowed within 60 s of creation and only on `OPEN` cash sessions. Body: `{ reason }`. State → `CANCELLED`. Stock reservations released. |
|
||||
|
||||
### 7.5 Reprint
|
||||
|
||||
| Method | Path | Roles | Notes |
|
||||
|---|---|---|---|
|
||||
| POST | `/pos/sales/:id/print` | any pos | Returns a `ReceiptDto` payload; client dispatches to `PrinterAdapter.print`. Does not affect state. |
|
||||
|
||||
---
|
||||
|
||||
## 8. Parked tickets
|
||||
|
||||
| Method | Path | Roles | Notes |
|
||||
|---|---|---|---|
|
||||
| POST | `/pos/parked-tickets` | any pos | Body: `{ cart, label? }`. Returns the parked ticket id. |
|
||||
| GET | `/pos/parked-tickets` | any pos | Lists active parked tickets in the terminal's store. |
|
||||
| GET | `/pos/parked-tickets/:id` | any pos | Detail. |
|
||||
| POST | `/pos/parked-tickets/:id/recall` | any pos | Returns the cart to be loaded into the active cart. Marks `recalledAt`. |
|
||||
| DELETE | `/pos/parked-tickets/:id` | any pos | Discard. |
|
||||
|
||||
Parked tickets older than 24 hours are auto-expired by a nightly job (Phase 6+).
|
||||
|
||||
---
|
||||
|
||||
## 9. Receipt payload (for printing)
|
||||
|
||||
```ts
|
||||
interface ReceiptDto {
|
||||
receiptNumber: string; // human-readable, store-prefixed
|
||||
store: { name: string; address: string | null; taxId: string | null };
|
||||
terminal: { id: string; name: string };
|
||||
cashier: { email: string };
|
||||
sessionId: string;
|
||||
orderId: string;
|
||||
issuedAt: string; // ISO
|
||||
items: Array<{
|
||||
name: string;
|
||||
sku: string;
|
||||
ean: string | null;
|
||||
quantity: number;
|
||||
unitGrossCents: number; // with VAT
|
||||
lineDiscountCents: number;
|
||||
lineTotalCents: number;
|
||||
}>;
|
||||
subtotalCents: number;
|
||||
discountCents: number;
|
||||
taxBreakdown: Array<{ vatPercent: number; baseCents: number; taxCents: number }>;
|
||||
totalCents: number;
|
||||
payments: Array<{
|
||||
methodLabel: string;
|
||||
amountCents: number;
|
||||
tenderedCents: number | null;
|
||||
changeCents: number | null;
|
||||
authCode: string | null;
|
||||
}>;
|
||||
customer: { name: string; email: string; taxId: string | null } | null;
|
||||
receiptHeader: string | null;
|
||||
receiptFooter: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
The client renders this with the `PrinterAdapter` (browser version = `window.print()` on a hidden iframe with print-only CSS).
|
||||
|
||||
---
|
||||
|
||||
## 10. Discounts — server validation rules
|
||||
|
||||
`POST /pos/sales` validates discounts server-side:
|
||||
|
||||
- A line-level discount cannot exceed 50 % of the line's gross subtotal unless the user has `POS_OVERRIDE_PRICE`.
|
||||
- A global discount cannot exceed 20 % of the total.
|
||||
- Negative discounts are not allowed.
|
||||
- Each discount is recorded in the audit log with `actor`, `amountCents`, and (optionally) `reason`.
|
||||
|
||||
These limits are configurable in `store_settings` (`max_line_discount_percent`, `max_global_discount_percent`).
|
||||
|
||||
---
|
||||
|
||||
## 11. Connection status
|
||||
|
||||
The POS app pings `GET /health` every 10 s. The admin panel already exposes this endpoint via the `health` module; no new endpoint is required. Status is rendered as `● Online` / `⚠ Sin conexión` based on the last successful response.
|
||||
|
||||
---
|
||||
|
||||
## 12. Error envelope
|
||||
|
||||
All errors share the shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "POS_SALE_STOCK_UNAVAILABLE",
|
||||
"message": "Stock insuficiente para la variante <uuid>",
|
||||
"details": [{ "path": "items.0.quantity", "message": "exceeds available" }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Client-side, errors are mapped to user-facing Spanish messages via a single lookup table in `apps/pos/src/lib/errors.ts`.
|
||||
|
||||
---
|
||||
|
||||
## 13. Versioning
|
||||
|
||||
POS endpoints are unversioned (`/pos/sales` not `/pos/v1/sales`). Breaking changes will be communicated via a `Sunset` header and a 12-month deprecation window. The endpoint set is small enough that we expect < 2 breaking changes in the lifetime of the project.
|
||||
|
||||
---
|
||||
|
||||
## 14. Rate limits
|
||||
|
||||
| Endpoint | Limit |
|
||||
|---|---|
|
||||
| `POST /pos/sales` | 60 / min / user |
|
||||
| `POST /pos/sessions` | 6 / hour / terminal (sessions shouldn't open that often) |
|
||||
| `GET /pos/products/search` | 600 / min / user (scanner spam protection) |
|
||||
| All others | standard 600 / min / IP |
|
||||
|
||||
Enforced via the existing `security.RateLimiter`.
|
||||
486
docs/pos/POS_ARCHITECTURE.md
Normal file
486
docs/pos/POS_ARCHITECTURE.md
Normal file
@@ -0,0 +1,486 @@
|
||||
# POS Architecture — Mercado de Vida
|
||||
|
||||
> **Status:** Discovery (Phase 1)
|
||||
> **Ticket:** POS-001
|
||||
> **Author:** architect
|
||||
> **Last updated:** 2026-08-21
|
||||
|
||||
This document describes how a Point-of-Sale (TPV) application integrates into the existing **Mercado de Vida** modular monolith. The POS must reuse products, prices, stock, customers, orders, taxes, authentication, RBAC and the admin panel. **No business-logic duplication.**
|
||||
|
||||
---
|
||||
|
||||
## 0. Executive summary
|
||||
|
||||
| Aspect | Decision |
|
||||
|---|---|
|
||||
| Frontend app | New Next.js app `apps/pos` inside the existing `project/` workspace (sibling to `apps/admin`). Same Tailwind design tokens, same catch-all proxy pattern, same auth flow as the admin. |
|
||||
| Backend | New module `project/src/modules/pos/` following the existing `domain / application / infrastructure / api` layout. Reuses all existing modules via injected ports. |
|
||||
| Sales = orders | A POS sale IS an order with `source = 'pos'`. New column on `orders_orders`. The order state machine is extended with a `COMPLETED` terminal state (no shipping). |
|
||||
| Cash sessions, terminals, stores | New tables. Schema below. |
|
||||
| Inventory per store | Extended: `inventory_stock` becomes keyed by `(variant_id, store_id)`. Migration backfills the default store. |
|
||||
| Authentication | Reuses the existing `backoffice_users` table (admin/editor + new `pos_cashier`, `pos_manager` roles). Same session cookie mechanism. |
|
||||
| Hardware | Adapter interfaces (no vendor SDK in core). Browser-first implementations; real ESC/POS adapters land in Phase 7. |
|
||||
| Idempotency | Reuses `idempotency_key` on orders. POS generates a UUID per "cobrar" click. |
|
||||
|
||||
The POS is **not** a parallel system. It is a new consumer of the same core modules, plus a thin new module for the TPV-specific concepts (terminals, sessions, registers).
|
||||
|
||||
---
|
||||
|
||||
## 1. Current stack (as-found)
|
||||
|
||||
### 1.1 Repository layout
|
||||
|
||||
```
|
||||
project/
|
||||
├── apps/
|
||||
│ ├── admin/ # Next.js 14 admin (Backoffice users)
|
||||
│ ├── frontend/ # Next.js 14 customer storefront
|
||||
│ └── storefront/ # legacy customer storefront (being phased out)
|
||||
├── src/
|
||||
│ ├── app/ # Fastify composition root (build-app.ts)
|
||||
│ ├── infrastructure/# server bootstrap, config, logger
|
||||
│ ├── modules/ # 20+ domain modules (DDD + Clean Architecture)
|
||||
│ └── shared/ # cross-module helpers (auth, errors, http-input, telemetry, text)
|
||||
├── migrations/ # node-pg-migrate
|
||||
├── scripts/ # dev helpers
|
||||
├── docker-compose.yml # postgres + redis
|
||||
└── package.json # ESM, TypeScript strict, vitest
|
||||
```
|
||||
|
||||
Apps are co-located under `project/apps/` and share backend through a single Next.js catch-all proxy (`/api/[...path]/route.ts`). The `monolith.sh` script orchestrates all services on ports 3000 (backend), 3003 (frontend), 3004 (admin), 3005 (storefront).
|
||||
|
||||
### 1.2 Backend conventions
|
||||
|
||||
- **Module layout (mandatory):** `domain / application / infrastructure / api`. Public API exposed only via `index.ts`.
|
||||
- **Module isolation:** modules import ONLY public APIs of other modules; the boundary checker (`scripts/check-module-boundaries.mjs`) enforces this.
|
||||
- **Auth:** `Authenticate` port from `shared/auth.ts`; routes call `await deps.authenticate(request)` and then `requireRole(user, 'admin' | 'editor' | 'customer')`.
|
||||
- **RBAC:** role-based string check in `requireRole`. Permissions are an enum in `apps/admin/src/lib/permissions.ts` (admin only, currently).
|
||||
- **Validation:** explicit `parseJson(zodSchema, request.body)` via `shared/http-input.ts`. No magic.
|
||||
- **Errors:** `AppError(httpStatus, code, message, details?)` from `shared/errors.ts`. Single envelope.
|
||||
- **DB:** PostgreSQL 16, schema-per-module naming `<module>_<table>`. node-pg-migrate, ES module migrations.
|
||||
- **Logging:** structured pino via `app.log`.
|
||||
- **Telemetry:** `Telemetry` / `Tracer` / `Meter` ports from `observability` (no-op default).
|
||||
- **Tests:** vitest unit + itest (integration) inside `src/modules/<m>/tests/`. E2E in `src/app/tests/e2e/`.
|
||||
|
||||
### 1.3 Existing modules we will reuse
|
||||
|
||||
| Module | What POS needs from it |
|
||||
|---|---|
|
||||
| `identity` | `Authenticate` port, backoffice session lookup (cookie + role). |
|
||||
| `backoffice` | Login/logout/me endpoints and the cookie contract. Reused as-is. |
|
||||
| `catalog` | Product / variant / image / category / brand lookups + search by EAN/SKU/name (`GET /products/search`, `GET /products/suggest`). |
|
||||
| `pricing` | `GET /pricing/variants/:id`, `POST /pricing/calculate`. Server-authoritative price; never trust client totals. |
|
||||
| `inventory` | `checkAvailability`, atomic `reserve / release / confirm`. The POS sale = `confirm` of a `reserve`. |
|
||||
| `tax` | Active tax rates via `GET /admin/tax-rates`. |
|
||||
| `orders` | Order creation, state transitions, idempotency. |
|
||||
| `payments` | `PaymentProvider` interface — currently a stub. POS brings concrete `cash` and external `card` providers in Phase 3. |
|
||||
| `checkout` | The orchestrator pattern (validate → price → reserve → create order → payment intent). POS re-uses the same flow but skips shipping. |
|
||||
| `users` | Customer search by email/phone for ticket association. |
|
||||
| `store-settings` | `store_name`, `contact_address`, `footer_text`, etc. — printed on tickets. |
|
||||
| `security` | `AuditLogger` for "open register", "close register", "discount applied", etc. |
|
||||
| `notifications` | Email on sale confirmation (optional, off by default for POS). |
|
||||
| `observability` | `Tracer` / `Meter` for spans and counters. |
|
||||
|
||||
### 1.4 Existing admin app conventions (POS will mirror them)
|
||||
|
||||
- App Router under `apps/admin/src/app/(dashboard)/...`.
|
||||
- Tailwind with shared `@theme` block (greens `#2D6A4F`, `#1B4332`; secondary cream `#F5F0E8`; accent orange `#E76F51`; danger `#DC2626`).
|
||||
- `AuthProvider` context, `useAuth()` hook, `requireRole` redirect to `/login`.
|
||||
- `lib/api-client.ts` with typed namespaces (`productsApi`, `brandsApi`, `ordersApi`, …). Catch-all proxy at `app/api/[...path]/route.ts`.
|
||||
- Tanstack-Query-like manual caching via `useEffect` + `useState` (no Redux/Zustand yet).
|
||||
- `lib/permissions.ts` for client-side UI gating.
|
||||
|
||||
---
|
||||
|
||||
## 2. Gaps to address before code
|
||||
|
||||
These are real schema/concept gaps the brief asks for that **do not exist today**:
|
||||
|
||||
| Gap | Brief section | Current state | Resolution |
|
||||
|---|---|---|---|
|
||||
| **Multi-store** | §17 | `inventory_stock` keyed by `variant_id` only. **No** `store` table. | Add `stores` table; key `inventory_stock` by `(variant_id, store_id)`. Migration backfills default store. |
|
||||
| **Terminals** | §17 | None. | New `pos_terminals` table. |
|
||||
| **Cash sessions** | §18 | None. | New `pos_cash_sessions` table. |
|
||||
| **Order source** | §16 | No `source` column on `orders_orders`. | Add `source` enum (`'ecommerce' \| 'pos' \| 'admin'`) with default `'ecommerce'`. Backfill existing rows. |
|
||||
| **Order `COMPLETED` state** | §16, §39 | State machine tops out at `DELIVERED`. POS sales never ship. | Add `COMPLETED` terminal state. POS sales go `PENDING → AWAITING_PAYMENT → PAID → COMPLETED` (skipping `SHIPPED`/`DELIVERED`). |
|
||||
| **POS roles** | §27 | Roles are `customer / admin / editor`. | Extend `backoffice_users.role` CHECK to include `'pos_cashier'` and `'pos_manager'`. |
|
||||
| **POS-only permissions** | §27 | Permissions enum is admin-only. | Extend `permissions.ts` with `POS_*` permissions; server-side `requireRole` is too coarse — add `requireAnyRole` and `requirePermission` to `shared/auth.ts`. |
|
||||
| **Anonymous sale** | §14 | `orders_orders.user_id NOT NULL`. | Either create a "walk-in customer" placeholder or make `user_id` nullable when `source = 'pos'`. Decision: **nullable** with a CHECK that requires `user_id IS NULL OR source = 'pos'` if guest, otherwise required. |
|
||||
| **Hardware adapters** | §25 | None. | New interfaces under `shared/hardware/`: `ScannerAdapter`, `PrinterAdapter`, `CashDrawerAdapter`, `PaymentTerminalAdapter`, `ScaleAdapter`. Browser implementation in Phase 2/3; ESC/POS in Phase 7. |
|
||||
| **Idempotency per sale** | §32 | Already supported via `idempotency_key` UNIQUE. | Reused as-is. POS client generates a UUID per "Cobrar" click. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Proposed architecture
|
||||
|
||||
### 3.1 High-level diagram
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ Mercado de Vida Core │
|
||||
│ │
|
||||
┌──────────┐ │ ┌─────────┐ ┌─────────┐ ┌────────────┐ │
|
||||
│ Ecommerce│─────────┼─►│Catalog │ │Pricing │ │ Inventory │ │
|
||||
│ Frontend │ API │ └────┬────┘ └────┬────┘ └─────┬──────┘ │
|
||||
└──────────┘ │ │ │ │ │
|
||||
│ └────────────┼──────────────┘ │
|
||||
┌──────────┐ │ ▼ │
|
||||
│ Admin │─────────┼─► ┌──────────────────────────────┐ │
|
||||
│ Panel │ API │ │ Orders (source=ecommerce/pos/admin) │
|
||||
└──────────┘ │ └──────────────────────────────┘ │
|
||||
│ │ │
|
||||
┌──────────┐ │ ▼ │
|
||||
│ POS │─────────┼─► ┌──────────────────────────────┐ │
|
||||
│ (new) │ API │ │ Payments (cash / card stub) │ │
|
||||
│ apps/pos │ │ └──────────────────────────────┘ │
|
||||
└────┬─────┘ │ │ │
|
||||
│ hardware │ ▼ │
|
||||
▼ adapters │ ┌──────────────┐ │
|
||||
┌──────────┐ │ │ Audit Log │ │
|
||||
│ Scanner │ │ └──────────────┘ │
|
||||
│ Printer │ │ │
|
||||
│ Cash drw │ └──────────────────────────────────────────────┘
|
||||
│ Card Trm │
|
||||
└──────────┘
|
||||
```
|
||||
|
||||
### 3.2 Backend modules
|
||||
|
||||
```
|
||||
src/modules/
|
||||
├── pos/ # NEW — POS-specific concepts
|
||||
│ ├── domain/
|
||||
│ │ ├── store.ts # Store aggregate
|
||||
│ │ ├── terminal.ts # POS terminal
|
||||
│ │ ├── cash-session.ts # Open/closed register session
|
||||
│ │ ├── quick-product.ts # Configurable favorite buttons
|
||||
│ │ ├── sale.ts # Re-exports Order as Sale; defines Source enum
|
||||
│ │ └── errors.ts
|
||||
│ ├── application/
|
||||
│ │ ├── open-cash-session.ts
|
||||
│ │ ├── close-cash-session.ts
|
||||
│ │ ├── list-terminals.ts
|
||||
│ │ ├── get-pos-config.ts # returns terminal, session, store, tax rates
|
||||
│ │ ├── search-products-for-pos.ts # thin wrapper over catalog search + price
|
||||
│ │ ├── create-pos-sale.ts # orchestrates pricing + reserve + create + complete
|
||||
│ │ ├── refund-pos-sale.ts
|
||||
│ │ ├── park-ticket.ts # cart parked in DB
|
||||
│ │ └── ...
|
||||
│ ├── infrastructure/
|
||||
│ │ ├── pg-store-repository.ts
|
||||
│ │ ├── pg-terminal-repository.ts
|
||||
│ │ ├── pg-cash-session-repository.ts
|
||||
│ │ ├── pg-quick-product-repository.ts
|
||||
│ │ └── pg-parked-ticket-repository.ts
|
||||
│ └── api/
|
||||
│ └── pos.routes.ts # all /pos/* endpoints
|
||||
└── (existing modules, untouched except where noted)
|
||||
```
|
||||
|
||||
### 3.3 POS order flow (orchestration)
|
||||
|
||||
The POS sale path is a **slimmer** checkout: no shipping, no promo code, optional customer. We re-use `OrderService` and `InventoryService` directly rather than building a separate orchestrator.
|
||||
|
||||
```
|
||||
[POS UI: Cart with N items]
|
||||
│
|
||||
│ POST /api/pos/sales { idempotencyKey, items, customerId?, payments, discount? }
|
||||
│
|
||||
▼
|
||||
pos.routes.ts
|
||||
│
|
||||
├─► requireRole(user, ['pos_cashier','pos_manager','admin'])
|
||||
├─► requireOpenSession(terminalId) // 409 if closed
|
||||
├─► for each item:
|
||||
│ pricingApi.getVariantPrice(variantId) // server-authoritative
|
||||
│ inventory.checkAvailability(variantId) // soft check (real check at confirm)
|
||||
├─► compute totals server-side (no client trust)
|
||||
├─► BEGIN TRANSACTION
|
||||
│ ordersService.create({source:'pos', userId?, items, totals, idempotencyKey})
|
||||
│ inventory.reserve(variantId, storeId, qty) × items
|
||||
│ ordersService.transition(orderId, 'AWAITING_PAYMENT')
|
||||
│ for each payment: paymentsService.registerCash/Card({orderId, amountCents, ...})
|
||||
│ ordersService.transition(orderId, 'PAID')
|
||||
│ for each payment: inventory.confirm(reservation) × items
|
||||
│ ordersService.transition(orderId, 'COMPLETED')
|
||||
│ auditLogger.log('pos.sale.completed', {orderId, terminalId, totalCents, ...})
|
||||
│ COMMIT
|
||||
│
|
||||
▼
|
||||
[POS UI: Receipt rendered, print dispatched, new empty cart]
|
||||
```
|
||||
|
||||
Key invariants:
|
||||
|
||||
- **Idempotency:** the same `idempotencyKey` returns the same order — no double-charge on network retry.
|
||||
- **Atomicity:** everything is in one PG transaction (using a single connection / client). The existing `OrderService` and `InventoryService` already operate on a passed `pg.Client`; we wire them to a single txn.
|
||||
- **Server-authoritative totals:** client sends only item ids + quantities; server computes price, tax, discount.
|
||||
- **No double reservation:** the orchestration is linear and single-threaded per request.
|
||||
|
||||
### 3.4 Hardware abstraction
|
||||
|
||||
```ts
|
||||
// project/src/shared/hardware/types.ts
|
||||
|
||||
export interface ScannerAdapter {
|
||||
/** Subscribe to decoded codes. Most physical scanners are HID-keyboard;
|
||||
* we wrap them in this interface so swapping to serial/BT is a one-line change. */
|
||||
onCode(callback: (code: string) => void): () => void;
|
||||
}
|
||||
|
||||
export interface PrinterAdapter {
|
||||
/** Renders a structured receipt. Returns success/failure; never throws
|
||||
* for "printer offline" (returns a structured failure instead). */
|
||||
print(receipt: ReceiptPayload): Promise<{ ok: true; jobId: string } | { ok: false; reason: string }>;
|
||||
}
|
||||
|
||||
export interface CashDrawerAdapter {
|
||||
/** Pulses the cash-drawer kick-out. Best-effort; failures logged. */
|
||||
open(): Promise<{ ok: boolean; reason?: string }>;
|
||||
}
|
||||
|
||||
export interface PaymentTerminalAdapter {
|
||||
/** Asks the datáfono for an authorisation. Returns approved/declined/error.
|
||||
* No PAN handling: this delegates to a PCI-DSS-compliant provider. */
|
||||
requestPayment(input: { amountCents: number; currency: 'EUR'; reference: string }): Promise<
|
||||
| { ok: true; providerPaymentId: string; authCode: string }
|
||||
| { ok: false; reason: 'declined' | 'timeout' | 'offline' | 'error'; message: string }
|
||||
>;
|
||||
}
|
||||
|
||||
export interface ScaleAdapter {
|
||||
/** Reads weight in grams. Returns null if the scale is disconnected or settling. */
|
||||
readGrams(): Promise<number | null>;
|
||||
}
|
||||
```
|
||||
|
||||
Each adapter has two implementations in Phase 7:
|
||||
|
||||
- **Browser** (Phase 2/3): relies on standard browser APIs. Scanner = keyboard listener on a focused input. Printer = hidden iframe to a print-friendly HTML route. Cash drawer = no-op. Payment terminal = manual entry (operator types last-4 or auth code).
|
||||
- **Native bridge** (Phase 7, deferred): a thin Node addon or a sidecar process that speaks USB/serial/ESC-POS. Exposed over a localhost HTTP socket to the Next.js app.
|
||||
|
||||
### 3.5 Frontend app
|
||||
|
||||
```
|
||||
apps/pos/
|
||||
├── package.json
|
||||
├── next.config.ts
|
||||
├── tailwind.config.ts (or shared via project root)
|
||||
├── src/
|
||||
│ ├── app/
|
||||
│ │ ├── layout.tsx
|
||||
│ │ ├── globals.css
|
||||
│ │ ├── (auth)/
|
||||
│ │ │ └── login/page.tsx
|
||||
│ │ ├── (terminal)/
|
||||
│ │ │ ├── layout.tsx # minimal chrome — sidebar, connection status
|
||||
│ │ │ ├── page.tsx # main register screen
|
||||
│ │ │ ├── open-register/page.tsx
|
||||
│ │ │ ├── close-register/page.tsx
|
||||
│ │ │ ├── history/page.tsx
|
||||
│ │ │ └── settings/page.tsx
|
||||
│ │ └── api/
|
||||
│ │ └── [...path]/route.ts # identical proxy to apps/admin
|
||||
│ ├── components/
|
||||
│ │ ├── pos/POSLayout.tsx
|
||||
│ │ ├── pos/POSHeader.tsx
|
||||
│ │ ├── pos/ProductSearch.tsx
|
||||
│ │ ├── pos/CategorySelector.tsx
|
||||
│ │ ├── pos/QuickProducts.tsx
|
||||
│ │ ├── pos/Cart.tsx
|
||||
│ │ ├── pos/CartItem.tsx
|
||||
│ │ ├── pos/CartTotals.tsx
|
||||
│ │ ├── pos/CustomerSelector.tsx
|
||||
│ │ ├── pos/DiscountPanel.tsx
|
||||
│ │ ├── pos/PaymentModal.tsx
|
||||
│ │ ├── pos/CashPayment.tsx
|
||||
│ │ ├── pos/CardPayment.tsx
|
||||
│ │ ├── pos/NumericKeypad.tsx
|
||||
│ │ ├── pos/TicketHistory.tsx
|
||||
│ │ ├── pos/RegisterSession.tsx
|
||||
│ │ ├── pos/POSSettings.tsx
|
||||
│ │ ├── pos/ConnectionStatus.tsx
|
||||
│ │ └── ui/ # shared with apps/admin via component copy (TODO: extract to packages/ui in a future cleanup)
|
||||
│ ├── features/
|
||||
│ │ ├── auth/
|
||||
│ │ ├── cart/ # POS cart state machine
|
||||
│ │ ├── register/ # cash session state
|
||||
│ │ ├── catalog/ # cached product list
|
||||
│ │ └── hardware/ # adapter wiring
|
||||
│ ├── lib/
|
||||
│ │ ├── api-client.ts # typed client (mirror of apps/admin/src/lib/api-client.ts)
|
||||
│ │ ├── permissions.ts # extends admin/permissions with POS_*
|
||||
│ │ ├── money.ts # cents helpers, formatting (es-ES)
|
||||
│ │ ├── tax.ts # client-side tax display only
|
||||
│ │ ├── idempotency.ts # UUID v4 generator
|
||||
│ │ └── hardware/ # adapter factories (browser impls)
|
||||
│ ├── hooks/
|
||||
│ ├── types/
|
||||
│ └── middleware.ts # auth guard, terminal-id cookie
|
||||
└── tests/
|
||||
```
|
||||
|
||||
The app is delivered through the existing `monolith.sh` script (Phase 2: add a `pos` service on port 3006).
|
||||
|
||||
### 3.6 Multi-store inventory
|
||||
|
||||
Two options were considered:
|
||||
|
||||
- **(A) Extend `inventory_stock` with `store_id`** — chosen. Migration adds the column with a default "Main Store" UUID; backfills existing rows. Foreign key to new `stores` table.
|
||||
- **(B) New `inventory_stock_by_store` table** — rejected: more moving parts, harder joins, no upside.
|
||||
|
||||
Operations:
|
||||
|
||||
- `inventory.reserve(variantId, storeId, qty)` becomes the signature.
|
||||
- The POS endpoint always passes the terminal's storeId.
|
||||
- Reporting endpoints (`GET /admin/inventory`) accept an optional `?storeId=` filter.
|
||||
|
||||
### 3.7 Authentication & authorization
|
||||
|
||||
- **Authentication:** identical to `apps/admin`. Same cookie, same `Authenticate` port. The POS app calls the same `/api/backoffice/auth/login` endpoint.
|
||||
- **Authorization:** extend `shared/auth.ts` with `requireAnyRole(user, [...roles])`. New roles: `pos_cashier`, `pos_manager`. Permission string `POS_*` for fine-grained checks (override price, refund, cancel sale, open/close register).
|
||||
- **Terminal identity:** a UUID generated on first boot of the POS app, stored in a Secure cookie (`pos_terminal_id`, 1 year expiry). The cookie survives reloads; clearing it requires re-binding the terminal to a record via a one-time admin code.
|
||||
- **Session token in cookie** is the same as admin's. One user can be logged into admin and POS simultaneously (different cookies? no — same cookie, different apps).
|
||||
|
||||
### 3.8 Connection status / offline (deferred)
|
||||
|
||||
Phase 1–5: online-only. Connection status shows "● Online" / "⚠ Sin conexión" by pinging `GET /health` every 10 s.
|
||||
|
||||
Phase 8 (out of scope here): offline-first with IndexedDB queue and CRDT-style stock reconciliation. Deferred until the operator explicitly requests it. Architecture leaves a `pendingMutations` slot in the cart slice so it can be wired later.
|
||||
|
||||
---
|
||||
|
||||
## 4. Data model (full in `POS_DATA_MODEL.md`)
|
||||
|
||||
New tables (concise; full DDL in `POS_DATA_MODEL.md`):
|
||||
|
||||
| Table | Purpose | Key fields |
|
||||
|---|---|---|
|
||||
| `pos_stores` | Physical stores | `id, name, address, tax_id, settings_jsonb` |
|
||||
| `pos_terminals` | One TPV device | `id, store_id, name, status, interface_mode, settings_jsonb, last_seen_at` |
|
||||
| `pos_cash_sessions` | Open/close register | `id, terminal_id, store_id, user_id, opened_at, closed_at, opening_cash_cents, closing_cash_cents, expected_cash_cents, actual_cash_cents, difference_cents, status` |
|
||||
| `pos_quick_products` | Favorite buttons | `id, store_id, terminal_id?, user_id?, product_id, variant_id, position` |
|
||||
| `pos_parked_tickets` | Held tickets | `id, store_id, terminal_id, user_id, customer_id?, cart_jsonb, created_at, expires_at` |
|
||||
| `pos_payment_methods` | Configurable payment types | `id, store_id, code, label, kind ('cash'\|'card'\|'other'), active, sort_order` |
|
||||
|
||||
Modified tables:
|
||||
|
||||
| Table | Change |
|
||||
|---|---|
|
||||
| `inventory_stock` | Add `store_id uuid NOT NULL DEFAULT <main store>`, FK to `pos_stores`. Replace unique index with `(variant_id, store_id)`. |
|
||||
| `orders_orders` | Add `source text NOT NULL DEFAULT 'ecommerce' CHECK (source IN ('ecommerce','pos','admin'))`. Add `terminal_id uuid NULL`, `cash_session_id uuid NULL`. Add `COMPLETED` to state CHECK. |
|
||||
| `backoffice_users.role` | Extend CHECK to include `'pos_cashier'`, `'pos_manager'`. |
|
||||
|
||||
No new customer/user table — POS sales link to existing `identity_users` (or NULL for walk-ins).
|
||||
|
||||
---
|
||||
|
||||
## 5. API surface (full in `POS_API.md`)
|
||||
|
||||
Mounted under existing Fastify via `registerPosRoutes(app, deps)` in `build-app.ts`. All under `/pos/...`. Reuses `requireRole` and `requireAnyRole` from `shared/auth.ts`. Cookie auth same as the rest.
|
||||
|
||||
| Method | Path | Purpose | Role |
|
||||
|---|---|---|---|
|
||||
| GET | `/pos/stores` | List stores | admin |
|
||||
| POST | `/pos/stores` | Create store | admin |
|
||||
| GET | `/pos/terminals` | List terminals | admin / pos_manager |
|
||||
| POST | `/pos/terminals` | Register a terminal (returns binding code) | admin |
|
||||
| GET | `/pos/terminals/me` | Current terminal by cookie | any pos |
|
||||
| PATCH | `/pos/terminals/:id` | Update terminal settings | admin / pos_manager |
|
||||
| GET | `/pos/sessions/me` | Current open session for terminal | any pos |
|
||||
| POST | `/pos/sessions` | Open register | pos_cashier / pos_manager |
|
||||
| POST | `/pos/sessions/:id/close` | Close register (counted cash) | pos_cashier / pos_manager |
|
||||
| GET | `/pos/config` | Returns `{ terminal, store, session, paymentMethods, taxRates, categoriesTree }` | any pos |
|
||||
| GET | `/pos/products/search?q=...` | Server-side search (delegates to catalog) | any pos |
|
||||
| GET | `/pos/products/by-ean/:ean` | Direct lookup by EAN (fast path for scanner) | any pos |
|
||||
| GET | `/pos/products/by-sku/:sku` | Direct lookup by SKU | any pos |
|
||||
| GET | `/pos/quick-products` | Quick buttons for the current (terminal, user) | any pos |
|
||||
| POST | `/pos/quick-products` | Add a quick product | pos_manager |
|
||||
| DELETE | `/pos/quick-products/:id` | Remove | pos_manager |
|
||||
| POST | `/pos/sales` | Create sale (idempotent) | pos_cashier+ |
|
||||
| GET | `/pos/sales` | List recent sales (scoped to terminal/store) | any pos |
|
||||
| GET | `/pos/sales/:id` | Sale detail | any pos |
|
||||
| POST | `/pos/sales/:id/refund` | Refund (full or partial) | pos_manager |
|
||||
| POST | `/pos/sales/:id/print` | Reprint receipt | any pos |
|
||||
| POST | `/pos/parked-tickets` | Park current cart | pos_cashier+ |
|
||||
| GET | `/pos/parked-tickets` | List parked tickets for store | pos_cashier+ |
|
||||
| DELETE | `/pos/parked-tickets/:id` | Discard parked ticket | pos_cashier+ |
|
||||
|
||||
All POST/PATCH endpoints validate with `parseJson(zodSchema, body)`. All errors are `AppError`-shaped. All write actions append to `security_audit_log` via `AuditLogger.log`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Reusable components
|
||||
|
||||
Existing modules reused as-is, no edits:
|
||||
|
||||
- `Authenticate` port, `parseJson`, `AppError`, `parseJson` (validation), `AuditLogger`.
|
||||
- `OrderService` (with `source` extension), `InventoryService` (with `store_id` extension).
|
||||
- `PricingService`, `TaxRateRepository`, `CatalogSearch`, `BrandRepository`, `CategoryRepository`, `ProductRepository`, `VariantRepository`.
|
||||
- `CustomerRepository` from `users` for ticket association.
|
||||
- `StoreSettingsRepository` for ticket header/footer.
|
||||
|
||||
Existing frontend utilities reused:
|
||||
|
||||
- `lib/api-client.ts` pattern (copy + extend with `posApi`).
|
||||
- `lib/permissions.ts` extended with `POS_*` permissions.
|
||||
- `AuthProvider` pattern copied and adapted (no shared package yet).
|
||||
- Tailwind theme tokens (the `@theme` block in globals.css is identical).
|
||||
|
||||
---
|
||||
|
||||
## 7. Risks
|
||||
|
||||
| Risk | Severity | Mitigation |
|
||||
|---|---|---|
|
||||
| **Multi-store migration is destructive** | high | Migration backfills all existing rows with a single default store; reversible via SQL script kept in `scripts/`. Operator must approve before Phase 2 starts. |
|
||||
| **Inventory race between POS and ecommerce** | high | All inventory writes go through `InventoryService.reserve/confirm`; same transaction model already used by checkout. Documented invariant: reservation is the only way to consume stock. |
|
||||
| **`COMPLETED` state breaks existing admin workflows** | medium | State machine is additive; existing `DELIVERED` flow is unchanged. Admin orders stay on `DELIVERED`. POS-only orders end on `COMPLETED`. |
|
||||
| **Cookie sharing between admin and POS apps** | low | Same domain; both apps read the same cookie. If POS is deployed to a different domain, swap to a JWT or shared session service (Phase 8). |
|
||||
| **Idempotency conflicts** | medium | `idempotency_key` is already unique. The POS UI generates a UUID per "Cobrar" click; if the user clicks twice within ~50 ms, only one wins. |
|
||||
| **Hardware fragmentation** | medium | All hardware behind adapters; no vendor SDK in core. Browser impls ship first (Phase 2/3); native bridge in Phase 7 only when operator commits to a vendor. |
|
||||
| **Touchscreen keyboard** | low | React `NumericKeypad` component; no system-level dependencies. |
|
||||
| **Refunds for split payments** | medium | Partial refund = multiple `payment_transactions` with negative `amount_cents`. Existing `payments` module already supports this. |
|
||||
|
||||
---
|
||||
|
||||
## 8. Phased plan
|
||||
|
||||
The brief already defines 7 phases. The list below translates them into Orquestra-friendly tickets:
|
||||
|
||||
| Phase | Ticket(s) | Goal |
|
||||
|---|---|---|
|
||||
| 0 | **POS-001** (this) | Discovery + architecture docs. |
|
||||
| 1 | POS-002 to POS-010 | **Core TPV.** Schema migrations (stores, terminals, sessions, quick-products, parked-tickets, payment-methods). Multi-store inventory migration. `OrderService.source` extension. `COMPLETED` state. New `pos` module + routes + repository + unit tests. Frontend app skeleton with `POSLayout`, header, product search, categories, quick products, cart, totals. |
|
||||
| 2 | POS-011 to POS-016 | **Checkout.** Cash payment (with change calculation), card manual entry, idempotent sale creation, stock decrement, receipt rendering, optional customer association, history. |
|
||||
| 3 | POS-017 to POS-022 | **Cash register.** Open/close session, expected vs actual cash, difference capture, audit trail, end-of-shift summary. |
|
||||
| 4 | POS-023 to POS-028 | **Multi-terminal.** Stores + terminals admin UI, terminal binding code flow, per-terminal quick products, per-store payment methods, concurrent terminals (already supported by HTTP). |
|
||||
| 5 | POS-029 to POS-033 | **Touch UI.** Larger hit targets, virtual keypad, scanner auto-focus, keyboard shortcuts (F2/F4/F6/F8/F9/F10/ESC), responsive 1024×768 → 1920×1080, no horizontal scroll. |
|
||||
| 6 | POS-034 to POS-040 | **Hardware.** Adapter interfaces finalized. Browser implementations complete. ESC/POS + USB scanner bridge behind a thin Node sidecar (out-of-process HTTP) — only the spec is written here; the bridge itself is built when the operator picks a vendor. |
|
||||
| 7 | POS-041+ | **Polish.** Refunds UI, returns, partial refunds, gift receipts, scale integration, multi-currency (deferred to operator demand). |
|
||||
|
||||
Each phase ends with **all three gates approved (reviewer, security, qa)** and `verify.sh` exit 0.
|
||||
|
||||
---
|
||||
|
||||
## 9. Open questions for the operator
|
||||
|
||||
These are decisions the architect cannot take unilaterally and that block Phase 2:
|
||||
|
||||
1. **Default store name** for the backfill migration ("Mercado de Vida — Tienda única" until renamed via admin)?
|
||||
2. **Cookie domain**: deploy POS on the same origin as admin (port 3006, subdomain, or sibling port)? Affects cookie sharing.
|
||||
3. **Touchscreen minimum target hardware** (model + size) so the touch UI can be sized correctly.
|
||||
4. **Datáfono provider preference** (none, SumUp, Redsys TPVO, generic) — informs whether Phase 7 needs a real adapter or stays browser-only.
|
||||
5. **Refund policy defaults** (within X days, require manager approval above Y €).
|
||||
|
||||
---
|
||||
|
||||
## 10. Definition of done (Phase 1)
|
||||
|
||||
- ✅ This document (`POS_ARCHITECTURE.md`) signed off by leader.
|
||||
- ✅ `POS_DATA_MODEL.md`, `POS_API.md`, `POS_HARDWARE.md`, `POS_OPERATIONS.md` exist with concrete specs.
|
||||
- ✅ `POS_TASKS.md` lists P0–P3 prioritized tickets queued in the backlog (POS-002+).
|
||||
- ✅ `verify.sh` exit 0.
|
||||
- ✅ `runtime-status.json` reset to idle.
|
||||
|
||||
**No code is touched in this phase beyond the docs.**
|
||||
480
docs/pos/POS_DATA_MODEL.md
Normal file
480
docs/pos/POS_DATA_MODEL.md
Normal file
@@ -0,0 +1,480 @@
|
||||
# POS Data Model — Mercado de Vida
|
||||
|
||||
> **Companion to:** [`POS_ARCHITECTURE.md`](./POS_ARCHITECTURE.md)
|
||||
> **Status:** Discovery (Phase 1)
|
||||
|
||||
This document details every schema change the POS requires: new tables, new columns, new enum values, and the migrations that apply them. All migrations live under `project/migrations/` with a sequential numeric prefix (`NNN_pos_*.js`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Naming conventions
|
||||
|
||||
- Table names: `pos_<table>` (module-owned namespace).
|
||||
- Column names: `snake_case`.
|
||||
- Primary keys: `id uuid NOT NULL DEFAULT gen_random_uuid()`.
|
||||
- Audit columns: `created_at timestamptz NOT NULL DEFAULT now()`, `updated_at timestamptz NOT NULL DEFAULT now()`.
|
||||
- Monetary values: `*_cents integer NOT NULL DEFAULT 0` with a CHECK constraint `>= 0` where appropriate. Refund / negative-amount flows use CHECK `>= 0` on the *aggregated* columns but allow per-row negative `amount_cents` in `payments_transactions` (already supported).
|
||||
- FK references use `ON DELETE` rules: `RESTRICT` for terminal↔session (you can't delete a terminal with sessions), `CASCADE` only for child rows that have no value without the parent.
|
||||
|
||||
---
|
||||
|
||||
## 2. New tables
|
||||
|
||||
### 2.1 `pos_stores`
|
||||
|
||||
A physical storefront. Required for multi-store inventory and per-store configuration.
|
||||
|
||||
```sql
|
||||
CREATE TABLE pos_stores (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name text NOT NULL CHECK (length(name) BETWEEN 1 AND 200),
|
||||
slug text NOT NULL UNIQUE CHECK (slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$'),
|
||||
address text,
|
||||
tax_id text, -- CIF/NIF
|
||||
contact_email text,
|
||||
contact_phone text,
|
||||
receipt_header text, -- printed on ticket top
|
||||
receipt_footer text, -- printed on ticket bottom
|
||||
settings jsonb NOT NULL DEFAULT '{}'::jsonb, -- free-form per-store overrides
|
||||
active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
**Backfill:** one row inserted by migration `043_pos_basics.js`:
|
||||
|
||||
```sql
|
||||
INSERT INTO pos_stores (id, name, slug, active)
|
||||
VALUES ('00000000-0000-0000-0000-000000000001', 'Mercado de Vida — Tienda principal', 'tienda-principal', true);
|
||||
```
|
||||
|
||||
This UUID is referenced from the `inventory_stock` migration as the default store.
|
||||
|
||||
### 2.2 `pos_terminals`
|
||||
|
||||
A TPV device (tablet, all-in-one, desktop). Has a persistent UUID generated on first browser visit.
|
||||
|
||||
```sql
|
||||
CREATE TABLE pos_terminals (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
store_id uuid NOT NULL REFERENCES pos_stores(id) ON DELETE RESTRICT,
|
||||
name text NOT NULL CHECK (length(name) BETWEEN 1 AND 100),
|
||||
binding_code text UNIQUE, -- one-time admin-supplied code
|
||||
bound_at timestamptz,
|
||||
status text NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('active','disabled','decommissioned')),
|
||||
interface_mode text NOT NULL DEFAULT 'auto'
|
||||
CHECK (interface_mode IN ('desktop','touch','auto')),
|
||||
settings jsonb NOT NULL DEFAULT '{}'::jsonb, -- printer, scanner, etc.
|
||||
last_seen_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX pos_terminals_store_idx ON pos_terminals(store_id);
|
||||
CREATE INDEX pos_terminals_binding_code_idx ON pos_terminals(binding_code) WHERE binding_code IS NOT NULL;
|
||||
```
|
||||
|
||||
**Binding flow** (Phase 4):
|
||||
|
||||
1. Admin generates a one-time code from `/admin/pos/terminals`.
|
||||
2. Operator opens POS app for the first time → enters code → cookie `pos_terminal_id` is set, `bound_at` recorded.
|
||||
|
||||
### 2.3 `pos_cash_sessions`
|
||||
|
||||
One row per open or closed register shift.
|
||||
|
||||
```sql
|
||||
CREATE TABLE pos_cash_sessions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
terminal_id uuid NOT NULL REFERENCES pos_terminals(id) ON DELETE RESTRICT,
|
||||
store_id uuid NOT NULL REFERENCES pos_stores(id) ON DELETE RESTRICT,
|
||||
user_id uuid NOT NULL REFERENCES backoffice_users(id) ON DELETE RESTRICT,
|
||||
status text NOT NULL DEFAULT 'OPEN'
|
||||
CHECK (status IN ('OPEN','CLOSED')),
|
||||
opened_at timestamptz NOT NULL DEFAULT now(),
|
||||
closed_at timestamptz,
|
||||
opening_cash_cents integer NOT NULL DEFAULT 0 CHECK (opening_cash_cents >= 0),
|
||||
closing_cash_cents integer, -- null while OPEN
|
||||
expected_cash_cents integer, -- computed on close
|
||||
actual_cash_cents integer, -- operator-counted on close
|
||||
difference_cents integer, -- actual - expected; can be negative
|
||||
notes text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Only one OPEN session per terminal at a time.
|
||||
CREATE UNIQUE INDEX pos_cash_sessions_open_per_terminal_idx
|
||||
ON pos_cash_sessions(terminal_id)
|
||||
WHERE status = 'OPEN';
|
||||
|
||||
CREATE INDEX pos_cash_sessions_store_idx ON pos_cash_sessions(store_id);
|
||||
CREATE INDEX pos_cash_sessions_user_idx ON pos_cash_sessions(user_id);
|
||||
```
|
||||
|
||||
`expected_cash_cents` is computed by summing all `cash` payments and refunds within the session, then adding `opening_cash_cents` and subtracting `cash` refunds.
|
||||
|
||||
### 2.4 `pos_quick_products`
|
||||
|
||||
Configurable favorite-product grid per scope. Scope precedence: `(terminal, user) > (terminal) > (store) > global`. Phase 1 implementation only supports the `store` and `terminal` scopes (admin UI for per-user scope is deferred).
|
||||
|
||||
```sql
|
||||
CREATE TABLE pos_quick_products (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
store_id uuid NOT NULL REFERENCES pos_stores(id) ON DELETE CASCADE,
|
||||
terminal_id uuid REFERENCES pos_terminals(id) ON DELETE CASCADE,
|
||||
user_id uuid REFERENCES backoffice_users(id) ON DELETE CASCADE,
|
||||
variant_id uuid NOT NULL REFERENCES catalog_product_variants(id) ON DELETE CASCADE,
|
||||
position integer NOT NULL DEFAULT 0,
|
||||
active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
-- At least one of (terminal_id, user_id) may be NULL; store_id is always set.
|
||||
-- Prevent duplicates within the same scope.
|
||||
CONSTRAINT pos_quick_products_unique_scope UNIQUE NULLS NOT DISTINCT
|
||||
(store_id, terminal_id, user_id, variant_id)
|
||||
);
|
||||
```
|
||||
|
||||
### 2.5 `pos_parked_tickets`
|
||||
|
||||
Held tickets recoverable from any terminal in the same store.
|
||||
|
||||
```sql
|
||||
CREATE TABLE pos_parked_tickets (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
store_id uuid NOT NULL REFERENCES pos_stores(id) ON DELETE CASCADE,
|
||||
terminal_id uuid NOT NULL REFERENCES pos_terminals(id) ON DELETE RESTRICT,
|
||||
user_id uuid NOT NULL REFERENCES backoffice_users(id) ON DELETE RESTRICT,
|
||||
customer_id uuid REFERENCES identity_users(id) ON DELETE SET NULL,
|
||||
label text, -- operator-supplied hint
|
||||
cart jsonb NOT NULL, -- serialised cart (see §6)
|
||||
expires_at timestamptz NOT NULL DEFAULT (now() + interval '24 hours'),
|
||||
recalled_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX pos_parked_tickets_store_active_idx
|
||||
ON pos_parked_tickets(store_id, created_at DESC)
|
||||
WHERE recalled_at IS NULL;
|
||||
```
|
||||
|
||||
### 2.6 `pos_payment_methods`
|
||||
|
||||
Configurable payment methods per store.
|
||||
|
||||
```sql
|
||||
CREATE TABLE pos_payment_methods (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
store_id uuid NOT NULL REFERENCES pos_stores(id) ON DELETE CASCADE,
|
||||
code text NOT NULL CHECK (length(code) BETWEEN 1 AND 32),
|
||||
label text NOT NULL CHECK (length(label) BETWEEN 1 AND 64),
|
||||
kind text NOT NULL CHECK (kind IN ('cash','card','other')),
|
||||
active boolean NOT NULL DEFAULT true,
|
||||
sort_order integer NOT NULL DEFAULT 0,
|
||||
config jsonb NOT NULL DEFAULT '{}'::jsonb, -- e.g. card terminal id, cash change default
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
UNIQUE (store_id, code)
|
||||
);
|
||||
|
||||
CREATE INDEX pos_payment_methods_store_active_idx
|
||||
ON pos_payment_methods(store_id, sort_order)
|
||||
WHERE active;
|
||||
```
|
||||
|
||||
Seed (in the same migration):
|
||||
|
||||
```sql
|
||||
INSERT INTO pos_payment_methods (store_id, code, label, kind, sort_order)
|
||||
VALUES
|
||||
('00000000-0000-0000-0000-000000000001', 'cash', 'Efectivo', 'cash', 0),
|
||||
('00000000-0000-0000-0000-000000000001', 'card', 'Tarjeta', 'card', 1);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Modified tables
|
||||
|
||||
### 3.1 `inventory_stock` — add `store_id`
|
||||
|
||||
```sql
|
||||
ALTER TABLE inventory_stock
|
||||
ADD COLUMN store_id uuid REFERENCES pos_stores(id) ON DELETE RESTRICT;
|
||||
|
||||
-- Backfill to the default store.
|
||||
UPDATE inventory_stock SET store_id = '00000000-0000-0000-0000-000000000001'
|
||||
WHERE store_id IS NULL;
|
||||
|
||||
ALTER TABLE inventory_stock
|
||||
ALTER COLUMN store_id SET NOT NULL;
|
||||
|
||||
-- Replace single-variant unique constraint with composite.
|
||||
ALTER TABLE inventory_stock DROP CONSTRAINT inventory_stock_variant_id_key;
|
||||
ALTER TABLE inventory_stock
|
||||
ADD CONSTRAINT inventory_stock_variant_store_unique UNIQUE (variant_id, store_id);
|
||||
```
|
||||
|
||||
`InventoryService` API changes:
|
||||
|
||||
```ts
|
||||
// Before
|
||||
reserve(variantId: string, quantity: number): Promise<Reservation>
|
||||
|
||||
// After
|
||||
reserve(variantId: string, storeId: string, quantity: number): Promise<Reservation>
|
||||
```
|
||||
|
||||
This is a **breaking change** in the inventory module. The existing checkout flow (ecommerce) is updated to pass the default store; the POS passes the terminal's store.
|
||||
|
||||
### 3.2 `inventory_movements` — add `store_id`
|
||||
|
||||
The movement log is enriched with the store. Migration:
|
||||
|
||||
```sql
|
||||
ALTER TABLE inventory_movements
|
||||
ADD COLUMN store_id uuid REFERENCES pos_stores(id) ON DELETE RESTRICT;
|
||||
|
||||
UPDATE inventory_movements SET store_id = '00000000-0000-0000-0000-000000000001'
|
||||
WHERE store_id IS NULL;
|
||||
|
||||
ALTER TABLE inventory_movements
|
||||
ALTER COLUMN store_id SET NOT NULL;
|
||||
```
|
||||
|
||||
### 3.3 `orders_orders` — add `source`, `terminal_id`, `cash_session_id`; add `COMPLETED` state
|
||||
|
||||
```sql
|
||||
ALTER TABLE orders_orders
|
||||
ADD COLUMN source text NOT NULL DEFAULT 'ecommerce'
|
||||
CHECK (source IN ('ecommerce','pos','admin')),
|
||||
ADD COLUMN terminal_id uuid REFERENCES pos_terminals(id) ON DELETE SET NULL,
|
||||
ADD COLUMN cash_session_id uuid REFERENCES pos_cash_sessions(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX orders_orders_source_idx ON orders_orders(source);
|
||||
CREATE INDEX orders_orders_terminal_idx ON orders_orders(terminal_id) WHERE terminal_id IS NOT NULL;
|
||||
CREATE INDEX orders_orders_cash_session_idx ON orders_orders(cash_session_id) WHERE cash_session_id IS NOT NULL;
|
||||
|
||||
-- Allow null user_id for walk-in sales (POS only).
|
||||
ALTER TABLE orders_orders ALTER COLUMN user_id DROP NOT NULL;
|
||||
ALTER TABLE orders_orders
|
||||
ADD CONSTRAINT orders_orders_user_required_when_not_pos
|
||||
CHECK (source <> 'pos' OR user_id IS NOT NULL OR TRUE); -- walk-ins allowed; tighten per business rule
|
||||
|
||||
-- Add COMPLETED to state machine.
|
||||
ALTER TABLE orders_orders DROP CONSTRAINT orders_orders_state_check;
|
||||
ALTER TABLE orders_orders
|
||||
ADD CONSTRAINT orders_orders_state_check
|
||||
CHECK (state IN ('PENDING','AWAITING_PAYMENT','PAID','PROCESSING','SHIPPED','DELIVERED','COMPLETED','CANCELLED','REFUNDED','PARTIALLY_REFUNDED'));
|
||||
```
|
||||
|
||||
Update `OrderState` type in `src/modules/orders/domain/order.ts` and `ALLOWED_TRANSITIONS` in the same file:
|
||||
|
||||
```ts
|
||||
COMPLETED: [], // terminal state for POS sales
|
||||
```
|
||||
|
||||
POS sales flow: `PENDING → AWAITING_PAYMENT → PAID → COMPLETED`. The `COMPLETED` state has no outgoing transitions and is treated as terminal. Admin views show it like `DELIVERED` (i.e., closed).
|
||||
|
||||
### 3.4 `backoffice_users.role` — extend CHECK
|
||||
|
||||
```sql
|
||||
ALTER TABLE backoffice_users DROP CONSTRAINT backoffice_users_role_check;
|
||||
ALTER TABLE backoffice_users
|
||||
ADD CONSTRAINT backoffice_users_role_check
|
||||
CHECK (role IN ('admin','editor','pos_cashier','pos_manager'));
|
||||
```
|
||||
|
||||
`shared/auth.ts` `Role` type updated accordingly.
|
||||
|
||||
### 3.5 `orders_items` — no changes
|
||||
|
||||
Already has `discount_cents` per line (good for POS line-level discounts).
|
||||
|
||||
### 3.6 `payments_transactions` — no changes
|
||||
|
||||
Already supports negative `amount_cents` for partial refunds (CHECK only enforces `>= 0` on insert, but the existing `payments` module allows negative amounts through the refund flow).
|
||||
|
||||
---
|
||||
|
||||
## 4. Entity-relationship diagram
|
||||
|
||||
```
|
||||
┌──────────────┐ ┌──────────────────┐ ┌─────────────────────┐
|
||||
│ pos_stores │◄────────│ pos_terminals │◄────────│ pos_cash_sessions │
|
||||
│ │ 1 N │ │ 1 N │ │
|
||||
│ id (PK) │ │ id (PK) │ │ id (PK) │
|
||||
│ name │ │ store_id (FK) │ │ terminal_id (FK) │
|
||||
│ slug │ │ name │ │ store_id (FK) │
|
||||
│ settings │ │ binding_code │ │ user_id (FK) │
|
||||
│ ... │ │ status │ │ status OPEN/CLOSED │
|
||||
└──────┬───────┘ │ interface_mode │ │ opening_cash_cents │
|
||||
│ │ settings │ │ closing_cash_cents │
|
||||
│ 1 │ ... │ │ expected_cash_cents │
|
||||
│ └──────┬───────────┘ │ actual_cash_cents │
|
||||
│ │ │ difference_cents │
|
||||
│ │ └─────────┬───────────┘
|
||||
│ │ │
|
||||
│ │ │ N
|
||||
│ │ │
|
||||
│ ▼ 1 │
|
||||
│ ┌─────────────────────┐ │
|
||||
│ │ orders_orders │◄──────────────────┘
|
||||
│ │ │
|
||||
│ │ id (PK) │
|
||||
│ │ user_id (nullable) │
|
||||
│ │ source ∈ │
|
||||
│ │ {ecommerce,pos, │
|
||||
│ │ admin} │
|
||||
│ │ terminal_id (FK) │
|
||||
│ │ cash_session_id (FK)│
|
||||
│ │ state + COMPLETED │
|
||||
│ └─────────┬───────────┘
|
||||
│ │ 1
|
||||
│ │
|
||||
│ │ N
|
||||
│ ┌─────────▼───────────┐
|
||||
│ │ orders_items │
|
||||
│ │ variant_id (FK) │
|
||||
│ │ sku, ean, name │
|
||||
│ │ unit_price_cents │
|
||||
│ │ discount_cents │
|
||||
│ │ tax_cents │
|
||||
│ │ quantity │
|
||||
│ └─────────────────────┘
|
||||
│
|
||||
│ 1
|
||||
▼ N
|
||||
┌──────────────────┐ ┌────────────────────┐
|
||||
│ inventory_stock │ │ inventory_movements│
|
||||
│ variant_id (FK) │ │ variant_id (FK) │
|
||||
│ store_id (FK) ───┼────────►│ store_id (FK) │
|
||||
│ available │ │ operation │
|
||||
│ reserved │ │ quantity │
|
||||
│ sold │ └────────────────────┘
|
||||
│ incoming │
|
||||
│ UNIQUE(variant, │
|
||||
│ store) │
|
||||
└──────────────────┘
|
||||
|
||||
┌──────────────────────┐ ┌─────────────────────────┐
|
||||
│ pos_quick_products │ │ pos_parked_tickets │
|
||||
│ store_id (FK) │ │ store_id (FK) │
|
||||
│ terminal_id (FK,?) │ │ terminal_id (FK) │
|
||||
│ user_id (FK,?) │ │ user_id (FK) │
|
||||
│ variant_id (FK) │ │ customer_id (FK,?) │
|
||||
│ position │ │ cart (jsonb) │
|
||||
└──────────────────────┘ └─────────────────────────┘
|
||||
|
||||
┌──────────────────────┐
|
||||
│ pos_payment_methods │
|
||||
│ store_id (FK) │
|
||||
│ code, label, kind │
|
||||
│ active, sort_order │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Migration plan
|
||||
|
||||
| # | Filename | Purpose |
|
||||
|---|---|---|
|
||||
| 043 | `043_pos_basics.js` | Create `pos_stores`, `pos_terminals`, `pos_payment_methods`, `pos_quick_products`, `pos_parked_tickets`, `pos_cash_sessions`. Seed default store and seed payment methods. |
|
||||
| 044 | `044_pos_inventory_store.js` | Add `store_id` to `inventory_stock` and `inventory_movements`. Backfill. Update unique constraint. |
|
||||
| 045 | `045_pos_orders_source.js` | Add `source`, `terminal_id`, `cash_session_id` to `orders_orders`. Drop NOT NULL on `user_id`. Add `COMPLETED` to state CHECK. |
|
||||
| 046 | `046_pos_backoffice_roles.js` | Extend `backoffice_users.role` CHECK with `pos_cashier` and `pos_manager`. |
|
||||
| 047 | `047_pos_seed_default_store.js` | (Already part of 043; placeholder for future operator-specific seeding.) |
|
||||
|
||||
These run in numeric order. Each is idempotent and reversible.
|
||||
|
||||
---
|
||||
|
||||
## 6. Cart shape (for parked tickets JSON)
|
||||
|
||||
The `cart` column in `pos_parked_tickets` stores:
|
||||
|
||||
```ts
|
||||
interface ParkedCart {
|
||||
version: 1;
|
||||
items: Array<{
|
||||
productId: string;
|
||||
variantId: string;
|
||||
sku: string;
|
||||
ean: string | null;
|
||||
name: string;
|
||||
unitPriceCents: number; // captured at park time
|
||||
discountCents: number; // line discount
|
||||
taxCents: number;
|
||||
quantity: number;
|
||||
}>;
|
||||
customerId: string | null;
|
||||
globalDiscountCents: number;
|
||||
notes?: string;
|
||||
}
|
||||
```
|
||||
|
||||
Prices are stored at park time as a snapshot, but on recall the cart re-prices server-side from current prices (with a warning if they changed).
|
||||
|
||||
---
|
||||
|
||||
## 7. Audit
|
||||
|
||||
Every state-changing operation appends to `security_audit_log` via `AuditLogger.log` with a stable `action` key:
|
||||
|
||||
| Action | Target |
|
||||
|---|---|
|
||||
| `pos.store.created` | `store:{id}` |
|
||||
| `pos.store.updated` | `store:{id}` |
|
||||
| `pos.terminal.bound` | `terminal:{id}` |
|
||||
| `pos.session.opened` | `session:{id}` |
|
||||
| `pos.session.closed` | `session:{id}` |
|
||||
| `pos.sale.created` | `order:{id}` |
|
||||
| `pos.sale.refunded` | `order:{id}` |
|
||||
| `pos.sale.discount.line` | `order:{id}:item:{idx}` |
|
||||
| `pos.sale.discount.global` | `order:{id}` |
|
||||
| `pos.sale.price.overridden` | `order:{id}:item:{idx}` |
|
||||
| `pos.sale.cancelled` | `order:{id}` |
|
||||
| `pos.ticket.parked` | `ticket:{id}` |
|
||||
| `pos.ticket.recalled` | `ticket:{id}` |
|
||||
|
||||
The actor is the authenticated user; metadata includes `terminalId`, `storeId`, and the relevant cents amount.
|
||||
|
||||
---
|
||||
|
||||
## 8. Indexes summary
|
||||
|
||||
```sql
|
||||
-- New
|
||||
CREATE INDEX pos_terminals_store_idx ON pos_terminals(store_id);
|
||||
CREATE INDEX pos_terminals_binding_code_idx ON pos_terminals(binding_code) WHERE binding_code IS NOT NULL;
|
||||
CREATE UNIQUE INDEX pos_cash_sessions_open_per_terminal_idx ON pos_cash_sessions(terminal_id) WHERE status = 'OPEN';
|
||||
CREATE INDEX pos_cash_sessions_store_idx ON pos_cash_sessions(store_id);
|
||||
CREATE INDEX pos_cash_sessions_user_idx ON pos_cash_sessions(user_id);
|
||||
CREATE INDEX pos_quick_products_store_idx ON pos_quick_products(store_id);
|
||||
CREATE INDEX pos_parked_tickets_store_active_idx ON pos_parked_tickets(store_id, created_at DESC) WHERE recalled_at IS NULL;
|
||||
CREATE INDEX pos_payment_methods_store_active_idx ON pos_payment_methods(store_id, sort_order) WHERE active;
|
||||
CREATE INDEX orders_orders_source_idx ON orders_orders(source);
|
||||
CREATE INDEX orders_orders_terminal_idx ON orders_orders(terminal_id) WHERE terminal_id IS NOT NULL;
|
||||
CREATE INDEX orders_orders_cash_session_idx ON orders_orders(cash_session_id) WHERE cash_session_id IS NOT NULL;
|
||||
|
||||
-- Modified
|
||||
ALTER TABLE inventory_stock ADD CONSTRAINT inventory_stock_variant_store_unique UNIQUE (variant_id, store_id);
|
||||
DROP INDEX inventory_stock_variant_id_idx; -- if exists
|
||||
CREATE INDEX inventory_stock_variant_store_idx ON inventory_stock(variant_id, store_id);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Constraints checklist
|
||||
|
||||
- ✅ Every monetary amount uses `*_cents integer` with explicit CHECK constraints.
|
||||
- ✅ FK rules declared: `RESTRICT` for terminal↔session, `CASCADE` for quick products / parked tickets / payment methods on parent delete, `SET NULL` for `terminal_id` / `cash_session_id` / `customer_id` on order (so deleting a terminal doesn't kill historical sales).
|
||||
- ✅ CHECK on `state` keeps the existing states plus `COMPLETED`.
|
||||
- ✅ Unique partial index `pos_cash_sessions_open_per_terminal_idx` enforces one open session per terminal at the DB level.
|
||||
- ✅ All migrations are idempotent and reversible (where the change is reversible).
|
||||
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?
|
||||
369
docs/pos/POS_OPERATIONS.md
Normal file
369
docs/pos/POS_OPERATIONS.md
Normal file
@@ -0,0 +1,369 @@
|
||||
# POS Operations — Mercado de Vida
|
||||
|
||||
> **Companion to:** [`POS_ARCHITECTURE.md`](./POS_ARCHITECTURE.md)
|
||||
> **Status:** Discovery (Phase 1)
|
||||
|
||||
Operational playbook: how to install, configure, run, monitor, troubleshoot, and roll back the POS. Intended for the operator (who deploys and runs the system in stores) and the engineer on call.
|
||||
|
||||
---
|
||||
|
||||
## 1. Deployment
|
||||
|
||||
### 1.1 Topology
|
||||
|
||||
```
|
||||
[ Operator's LAN ]
|
||||
│
|
||||
├── Backend host (existing monolith)
|
||||
│ └── :3000 Fastify backend + PostgreSQL + Redis
|
||||
│
|
||||
├── Admin host (existing admin app)
|
||||
│ └── :3004 Next.js admin
|
||||
│
|
||||
└── POS terminals (new — one per TPV device)
|
||||
└── :3006 Next.js POS app
|
||||
(each terminal needs a unique terminal id, stored in
|
||||
a Secure cookie; the binding code is generated by admin)
|
||||
```
|
||||
|
||||
The POS app is delivered through the existing `monolith.sh` script as a new service `pos` on port 3006 (configurable via `POS_PORT` env var). It shares the backend at `:3000` via the same Next.js catch-all proxy pattern as the admin app.
|
||||
|
||||
### 1.2 First-time setup (per store)
|
||||
|
||||
1. **Run migrations** on the backend host:
|
||||
|
||||
```bash
|
||||
cd project
|
||||
npm run build
|
||||
npm run db:status # confirm migrations are tracked
|
||||
```
|
||||
|
||||
The POS migrations (`043`–`046`) run automatically with the next `npm run db:migrate`. They are additive and non-destructive.
|
||||
|
||||
2. **Create the default store** via the admin panel at `/admin/pos/stores` (after Phase 4 ships the admin UI) — or seed manually:
|
||||
|
||||
```sql
|
||||
INSERT INTO pos_stores (name, slug) VALUES ('Mercado de Vida — Tienda principal', 'tienda-principal');
|
||||
```
|
||||
|
||||
3. **Generate binding codes** for each TPV device from `/admin/pos/terminals`.
|
||||
|
||||
4. **On each TPV device**, open the POS app and enter the binding code on first launch. The browser stores `pos_terminal_id` in a Secure cookie for one year.
|
||||
|
||||
5. **Configure POS users**: assign `pos_cashier` or `pos_manager` to the existing backoffice users who will operate the register.
|
||||
|
||||
### 1.3 POS app deployment
|
||||
|
||||
```bash
|
||||
# Build the POS app
|
||||
cd project/apps/pos
|
||||
npm run build
|
||||
|
||||
# Or use the monolith script
|
||||
./scripts/monolith.sh prod restart
|
||||
```
|
||||
|
||||
The monolith script handles each app independently; a single `restart` rebuilds and respawns all services.
|
||||
|
||||
---
|
||||
|
||||
## 2. Daily operations
|
||||
|
||||
### 2.1 Opening a register
|
||||
|
||||
The first action of the cashier's shift:
|
||||
|
||||
1. Login at `https://<host>:3006/login`.
|
||||
2. The terminal shows "Caja cerrada" with an "Abrir caja" button.
|
||||
3. Click "Abrir caja" → enter the opening cash amount (e.g., 100,00 € for the standard float) → confirm.
|
||||
4. The session is created with `status='OPEN'`, `openingCashCents=10000`. The UI now shows the main sale screen.
|
||||
|
||||
Audit: `pos.session.opened` is written to `security_audit_log` with `actorId`, `terminalId`, `openingCashCents`.
|
||||
|
||||
### 2.2 Making a sale
|
||||
|
||||
Standard flow (online):
|
||||
|
||||
1. Cashier scans a product (or types in the search box).
|
||||
2. Product is added to the cart.
|
||||
3. Repeat for additional products.
|
||||
4. Optional: associate a customer (search by email/phone).
|
||||
5. Optional: apply a line or global discount (within server-enforced limits).
|
||||
6. Click "Cobrar".
|
||||
7. Choose payment method (Efectivo / Tarjeta / Otro).
|
||||
8. For cash: enter amount tendered, see the change. Click "Confirmar".
|
||||
9. For card: enter the auth code from the datáfono. Click "Confirmar".
|
||||
10. Server creates the order + reservation + payment + COMPLETED state in one transaction.
|
||||
11. Receipt is printed automatically.
|
||||
12. Cart clears for the next sale.
|
||||
|
||||
Audit: `pos.sale.created` with all relevant fields.
|
||||
|
||||
### 2.3 Closing a register
|
||||
|
||||
End of shift:
|
||||
|
||||
1. Click "Cerrar caja" in the header.
|
||||
2. The system shows:
|
||||
- Ventas en efectivo: count + total
|
||||
- Ventas con tarjeta: count + total
|
||||
- Otros pagos: count + total
|
||||
- Devoluciones: count + total
|
||||
- Efectivo esperado: server-computed (opening + cash sales − cash refunds)
|
||||
3. Cashier counts the cash in the drawer and enters the actual amount.
|
||||
4. Server computes `differenceCents = actualCashCents − expectedCashCents`.
|
||||
5. Session is closed (`status='CLOSED'`, `closedAt=now()`).
|
||||
6. Audit: `pos.session.closed` with all totals and the difference.
|
||||
|
||||
### 2.4 Held / parked tickets
|
||||
|
||||
If a customer walks away mid-sale:
|
||||
|
||||
1. Click "Aparcar" → enter an optional label ("Juan — vuelve en 10 min").
|
||||
2. The cart is persisted in `pos_parked_tickets` with a 24-hour expiry.
|
||||
3. To recall: go to "Tickets aparcados", click the entry → cart is restored (with a re-price warning if prices changed).
|
||||
|
||||
Parked tickets can be recalled from **any terminal in the same store**.
|
||||
|
||||
---
|
||||
|
||||
## 3. Roles & permissions
|
||||
|
||||
| Role | Can open/close | Can sell | Can override price | Can apply discount | Can refund |
|
||||
|---|---|---|---|---|---|
|
||||
| `pos_cashier` | ✅ | ✅ | ❌ | up to configured max | ❌ |
|
||||
| `pos_manager` | ✅ | ✅ | ✅ | ✅ | ✅ (any amount) |
|
||||
| `admin` | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
|
||||
Roles are stored on `backoffice_users.role`. Permission constants are checked server-side via `requireRole` / `requireAnyRole`.
|
||||
|
||||
Discount limits:
|
||||
|
||||
- Line discount: default 50 % of the line gross (configurable in `store_settings`).
|
||||
- Global discount: default 20 % of the total.
|
||||
- Overrides: require `POS_OVERRIDE_PRICE` permission.
|
||||
|
||||
All discounts are logged in the audit with the actor's identity, the discount amount, and the affected line or order.
|
||||
|
||||
---
|
||||
|
||||
## 4. Monitoring
|
||||
|
||||
### 4.1 Health endpoint
|
||||
|
||||
The backend already exposes `GET /health` via the `health` module. The POS app polls this every 10 s and renders `● Online` / `⚠ Sin conexión` in the header.
|
||||
|
||||
### 4.2 Active sessions
|
||||
|
||||
The admin UI at `/admin/pos/sessions` (Phase 4) shows all open sessions in real time. Useful for the operator to see "which cashiers are logged in where".
|
||||
|
||||
### 4.3 Audit log
|
||||
|
||||
All sensitive actions land in `security_audit_log` with the `action` namespace `pos.*`:
|
||||
|
||||
```sql
|
||||
SELECT created_at, actor_id, action, target, metadata
|
||||
FROM security_audit_log
|
||||
WHERE action LIKE 'pos.%'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100;
|
||||
```
|
||||
|
||||
### 4.4 Sales metrics
|
||||
|
||||
The POS-specific metrics (Phase 6) use the existing `observability` module:
|
||||
|
||||
- `pos.sale.count` (counter)
|
||||
- `pos.sale.amount_cents_total` (counter)
|
||||
- `pos.sale.duration_ms` (histogram)
|
||||
- `pos.scanner.scans_count` (counter)
|
||||
- `pos.scanner.unknown_ean_count` (counter)
|
||||
- `pos.printer.print_failures` (counter)
|
||||
|
||||
These are exposed at `/metrics` in Prometheus format (existing endpoint) and can be scraped by the operator's monitoring stack.
|
||||
|
||||
---
|
||||
|
||||
## 5. Failure modes & troubleshooting
|
||||
|
||||
### 5.1 Network down mid-sale
|
||||
|
||||
- The "Cobrar" request fails.
|
||||
- The UI shows `Error de red. Reintenta.` with a retry button.
|
||||
- The cart is preserved.
|
||||
- Once online, retry succeeds; if the operator retries twice with the same cart, the server's `idempotencyKey` ensures only one order is created (the second call returns the existing order).
|
||||
|
||||
### 5.2 Stock conflict (oversell)
|
||||
|
||||
- Two terminals both scan the last unit.
|
||||
- The first "Cobrar" succeeds; the second gets `409 POS_SALE_STOCK_UNAVAILABLE`.
|
||||
- The UI shows `Stock insuficiente para «<producto>». Ajusta el carrito o cancela la venta.`
|
||||
- The cashier removes the offending item and retries.
|
||||
|
||||
### 5.3 Printer offline
|
||||
|
||||
- The receipt print fails; `printerAdapter.print` returns `{ ok: false, reason: 'offline' }`.
|
||||
- The UI shows a banner: `Impresora no disponible. Pulsa para reintentar.` with a "Reimprimir" button.
|
||||
- The sale is still valid in the system; the receipt can be reprinted from `/pos/sales/:id`.
|
||||
|
||||
### 5.4 Cash drawer doesn't open
|
||||
|
||||
- In Phase 2 (browser): the cash drawer opens via the printer's `kick-out`. If the printer is offline, the drawer does not open; the cashier uses the manual key.
|
||||
|
||||
### 5.5 Datáfono declined
|
||||
|
||||
- The POS UI shows `Pago con tarjeta rechazado. Introduce otro método o cobra en efectivo.`
|
||||
- The cart is preserved; the cashier retries with cash or another method.
|
||||
|
||||
### 5.6 Session expired / lost
|
||||
|
||||
- The session cookie (HTTP-only, 30 days) expires.
|
||||
- The next request returns `401 UNAUTHORIZED`.
|
||||
- The POS UI redirects to `/login`. The cart is preserved client-side.
|
||||
|
||||
### 5.7 Discount over limit
|
||||
|
||||
- The server returns `422 DISCOUNT_LIMIT_EXCEEDED`.
|
||||
- The UI highlights the discount field and explains: "Descuento máximo por línea: 50 %".
|
||||
|
||||
### 5.8 Parked ticket expires
|
||||
|
||||
- A ticket parked >24 h is auto-archived (Phase 6+).
|
||||
- A warning shows in the UI when recalling: "Este ticket fue guardado hace más de 24 horas."
|
||||
|
||||
---
|
||||
|
||||
## 6. Rollback plan
|
||||
|
||||
### 6.1 Phase 2 schema rollback
|
||||
|
||||
If the operator wants to revert Phase 2 after deployment:
|
||||
|
||||
```bash
|
||||
cd project
|
||||
node --env-file-if-exists=.env node_modules/node-pg-migrate/bin/node-pg-migrate.js down --migrations-dir migrations --to 042
|
||||
```
|
||||
|
||||
This reverts migrations 043–046. Side effects:
|
||||
|
||||
- `pos_*` tables are dropped.
|
||||
- `inventory_stock.store_id` column is dropped (data not deleted; the column is removed; rollback of 044 is destructive if rows have been modified to use store-scoped stock).
|
||||
- `orders_orders.source` column is dropped — existing rows have `source='ecommerce'` after rollback, so they're restored to their pre-POS state.
|
||||
- `COMPLETED` order state is rejected by the original CHECK constraint; any open POS orders must be moved to `DELIVERED` first or the rollback will fail.
|
||||
|
||||
**Recommended:** export data before rollback:
|
||||
|
||||
```bash
|
||||
pg_dump --data-only --table='pos_*' --table='orders_orders' mercadodevida > pos-backup-$(date +%F).sql
|
||||
```
|
||||
|
||||
### 6.2 Code rollback
|
||||
|
||||
The POS app is independent of the admin and frontend. To roll back the code:
|
||||
|
||||
```bash
|
||||
cd project
|
||||
./scripts/monolith.sh prod restart --skip-pos # if supported in monolith script
|
||||
# Or manually:
|
||||
cd apps/pos
|
||||
git checkout <previous-commit>
|
||||
npm run build
|
||||
# Restart the pos service.
|
||||
```
|
||||
|
||||
### 6.3 Zero-data-loss guarantee
|
||||
|
||||
All POS operations are append-only against `orders_orders` and `pos_cash_sessions`. Refunds and cancellations create new rows rather than mutating history. Reverting the schema preserves the original `orders_orders` rows; only the `source`, `terminal_id`, and `cash_session_id` columns are dropped, which can be reconstructed from the audit log if needed.
|
||||
|
||||
---
|
||||
|
||||
## 7. Security notes
|
||||
|
||||
- **No PAN/CVV storage.** Card auth codes are stored in `payments_transactions.raw` (JSONB) as `provider_auth_code` (string). They are the vendor-issued reference, not the card data.
|
||||
- **Cookies are Secure + HttpOnly + SameSite=Lax.** The catch-all proxy strips `Secure` for `localhost`; production deployments must terminate TLS at the proxy and set `Secure` on the cookie.
|
||||
- **Audit retention:** the existing `security_audit_log` has no retention policy in the project; the operator is responsible for archival (a follow-up ticket).
|
||||
- **Rate limits:** see `POS_API.md` §14.
|
||||
- **CSRF:** the same-site cookies + the existing `credentials: include` pattern in `api-client.ts` protect against CSRF for state-changing endpoints. The POS app does not introduce new attack surfaces here.
|
||||
|
||||
---
|
||||
|
||||
## 8. Backup & disaster recovery
|
||||
|
||||
- The PostgreSQL backup story is unchanged: the operator's existing cron-driven `pg_dump` covers all `pos_*` tables.
|
||||
- The `pos_terminal.settings` JSONB stores per-device configuration; backups restore it automatically.
|
||||
- Parked tickets live in `pos_parked_tickets`; backups restore them.
|
||||
|
||||
---
|
||||
|
||||
## 9. Smoke test (post-deploy)
|
||||
|
||||
After every POS deployment, run this 5-minute manual smoke test on at least one terminal:
|
||||
|
||||
1. Open the register with 100 € float.
|
||||
2. Scan a known product (e.g. `8412345678901`) — should appear in the cart.
|
||||
3. Type a name in the search box — should suggest products.
|
||||
4. Click a quick-product button — should add to the cart.
|
||||
5. Click "Cobrar" → "Efectivo" → enter 50 € → change should display.
|
||||
6. Confirm sale — receipt should print (or browser print dialog opens).
|
||||
7. Verify in admin `/admin/orders` that the order exists with `source='pos'`, state `COMPLETED`, total correct.
|
||||
8. Verify in admin `/admin/inventory` that stock decreased for the variant.
|
||||
9. Open "Tickets aparcados" — park current cart.
|
||||
10. Reload page — recall ticket — cart restored.
|
||||
11. Close the register — count cash — verify the difference matches expectation.
|
||||
12. Sign out — verify session is destroyed and redirect to login.
|
||||
|
||||
---
|
||||
|
||||
## 10. Runbooks
|
||||
|
||||
### 10.1 "POS cannot reach backend"
|
||||
|
||||
```bash
|
||||
# On the POS host
|
||||
curl -s http://<backend-host>:3000/health
|
||||
# If timeout: check backend host
|
||||
ssh backend-host 'systemctl status mercadodevida'
|
||||
# If backend is up but POS can't reach it: check firewall / LAN
|
||||
```
|
||||
|
||||
### 10.2 "I refunded a sale by mistake"
|
||||
|
||||
Refunds create new `payments_transactions` rows (negative amounts). To undo:
|
||||
|
||||
```sql
|
||||
-- 1. Find the refund
|
||||
SELECT id, order_id, amount_cents, raw
|
||||
FROM payments_transactions
|
||||
WHERE order_id = '<order-id>'
|
||||
AND amount_cents < 0
|
||||
ORDER BY created_at DESC LIMIT 1;
|
||||
|
||||
-- 2. Cancel the refund (Phase 5 admin UI handles this; below is the SQL fallback)
|
||||
DELETE FROM payments_transactions WHERE id = '<refund-id>';
|
||||
|
||||
-- 3. Update order state back to COMPLETED
|
||||
UPDATE orders_orders SET state = 'COMPLETED' WHERE id = '<order-id>';
|
||||
|
||||
-- 4. Reduce stock again (Phase 5 admin UI does this automatically)
|
||||
UPDATE inventory_stock SET available = available - <qty>, sold = sold + <qty>
|
||||
WHERE variant_id = '<variant-id>' AND store_id = '<store-id>';
|
||||
```
|
||||
|
||||
This is a manual intervention; the operator should document it in the audit log.
|
||||
|
||||
### 10.3 "Two cashiers opened the same terminal"
|
||||
|
||||
The DB-level unique index `pos_cash_sessions_open_per_terminal_idx` prevents this. The second `POST /pos/sessions` returns `409 POS_SESSION_ALREADY_OPEN`. The cashier sees the error and the existing session id.
|
||||
|
||||
---
|
||||
|
||||
## 11. Operator checklist (Phase 1 → Phase 2)
|
||||
|
||||
- [ ] Approve POS_ARCHITECTURE.md, POS_DATA_MODEL.md, POS_API.md, POS_HARDWARE.md, POS_OPERATIONS.md.
|
||||
- [ ] Confirm default store name for migration 043.
|
||||
- [ ] Confirm cookie domain for the POS app (same origin as admin? subdomain?).
|
||||
- [ ] Provide list of TPV devices and binding strategy.
|
||||
- [ ] Schedule a downtime window for migration 044 (multi-store inventory).
|
||||
- [ ] Schedule training for cashiers on the new register flow.
|
||||
|
||||
Once all boxes are checked, the leader promotes Phase 2 (`POS-002` …) into execution.
|
||||
451
docs/pos/POS_TASKS.md
Normal file
451
docs/pos/POS_TASKS.md
Normal file
@@ -0,0 +1,451 @@
|
||||
# POS Tasks — Mercado de Vida
|
||||
|
||||
> **Companion to:** [`POS_ARCHITECTURE.md`](./POS_ARCHITECTURE.md) and the other `docs/pos/*.md`
|
||||
> **Status:** Discovery (Phase 1)
|
||||
|
||||
The POS implementation is split into 7 phases (per the brief) and each phase is broken into small, single-purpose tickets. Tickets follow the existing Orquestra pattern (`scripts/new_ticket.py --id POS-NNN`) and are sequenced in `backlog/features.json`.
|
||||
|
||||
Priority tiers:
|
||||
|
||||
- **P0** — imprescindible. Without these, no sale can happen.
|
||||
- **P1** — importante. Daily operations break without them.
|
||||
- **P2** — mejora. Polish + efficiency.
|
||||
- **P3** — futuro. Operator-confirmed future work (offline, multi-currency, native hardware).
|
||||
|
||||
Each ticket follows the same shape:
|
||||
|
||||
```
|
||||
POS-NNN [P?] [Phase] Title
|
||||
Why: one-sentence rationale
|
||||
Scope IN: bullet list
|
||||
Scope OUT: bullet list
|
||||
Touches: files / modules / migrations affected
|
||||
Acceptance: 3-7 testable criteria
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Discovery
|
||||
|
||||
### `POS-001` — POS Discovery & Architecture *(in progress)*
|
||||
|
||||
- **Why:** Establish shared understanding before any code is written. Identify reusable components and gaps.
|
||||
- **Status:** this phase (the 5 docs under `docs/pos/` + this file).
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Core TPV
|
||||
|
||||
### `POS-002` [P0] [Phase 1] Schema migrations 043–046 (POS basics)
|
||||
|
||||
- **Why:** All other code depends on the new tables and extended columns.
|
||||
- **Scope IN:** migrations `043_pos_basics.js` (stores, terminals, payment_methods, quick_products, parked_tickets, cash_sessions), `044_pos_inventory_store.js` (add `store_id` to `inventory_stock` and `inventory_movements`, backfill, swap unique constraint), `045_pos_orders_source.js` (add `source`, `terminal_id`, `cash_session_id`, drop NOT NULL on `user_id`, add `COMPLETED` to state CHECK), `046_pos_backoffice_roles.js` (extend role CHECK).
|
||||
- **Scope OUT:** No backend modules yet. No frontend.
|
||||
- **Touches:** `project/migrations/`, `project/src/modules/orders/domain/order.ts` (add `COMPLETED` to `OrderState` and `ALLOWED_TRANSITIONS`), `project/src/shared/auth.ts` (extend `Role` type).
|
||||
- **Acceptance:** all 4 migrations apply on a fresh DB and on the current dev DB; existing data is preserved; `verify.sh` exits 0; smoke test queries match the expected counts.
|
||||
|
||||
### `POS-003` [P0] [Phase 1] `pos` module skeleton (domain + repos + service)
|
||||
|
||||
- **Why:** Provide the application layer for stores, terminals, sessions, payment methods.
|
||||
- **Scope IN:** `project/src/modules/pos/{domain,application,infrastructure}/`. Files: `domain/{store,terminal,cash-session,errors}.ts`, `infrastructure/pg-store-repository.ts`, `infrastructure/pg-terminal-repository.ts`, `infrastructure/pg-payment-method-repository.ts`, `application/{list-stores,list-terminals,get-pos-config,open-cash-session,close-cash-session}.ts`.
|
||||
- **Scope OUT:** No routes yet (covered by POS-004).
|
||||
- **Touches:** `project/src/modules/pos/**` (new), `project/src/app/build-app.ts` (wire the repos in deps).
|
||||
- **Acceptance:** unit tests cover each use case; the `pos` module is registered in `build-app.ts` without breaking the existing composition; `verify.sh` exits 0.
|
||||
|
||||
### `POS-004` [P0] [Phase 1] POS API — config, stores, terminals, sessions
|
||||
|
||||
- **Why:** First batch of endpoints; allows the frontend to bootstrap and the admin to manage terminals.
|
||||
- **Scope IN:** `project/src/modules/pos/api/pos.routes.ts` with endpoints: `GET /pos/admin/stores`, `POST /pos/admin/stores`, `GET /pos/admin/terminals`, `POST /pos/admin/terminals` (returns `bindingCode`), `GET /pos/terminals/me`, `POST /pos/terminals/bind`, `POST /pos/sessions`, `POST /pos/sessions/:id/close`, `GET /pos/sessions/me`, `GET /pos/config`. Zod schemas for each request body. Swagger summaries.
|
||||
- **Scope OUT:** Sales, refunds, quick products admin — handled by POS-005/006/008.
|
||||
- **Touches:** `project/src/modules/pos/api/pos.routes.ts`, `project/src/shared/auth.ts` (add `requireAnyRole` helper).
|
||||
- **Acceptance:** integration tests in `project/src/modules/pos/tests/` cover happy path + 401/403/409; swagger renders; rate limits applied.
|
||||
|
||||
### `POS-005` [P0] [Phase 1] POS API — product search + payment methods
|
||||
|
||||
- **Why:** Scanner + cart need a fast, denormalised product lookup; payment methods must be configurable per store.
|
||||
- **Scope IN:** `GET /pos/products/search`, `GET /pos/products/by-ean/:ean`, `GET /pos/products/by-sku/:sku`, `GET /pos/products/:variantId`, `GET /pos/admin/payment-methods`, `POST /pos/admin/payment-methods`, `PATCH /pos/admin/payment-methods/:id`. The product search joins `catalog_products`, `catalog_product_variants`, `brands_brands`, `categories_categories`, `catalog_product_images`, `pricing_variant_prices`, `inventory_stock` (for `storeId`).
|
||||
- **Scope OUT:** Sales endpoint (POS-008).
|
||||
- **Touches:** `project/src/modules/pos/api/pos.routes.ts`, `project/src/modules/pos/application/search-products-for-pos.ts`.
|
||||
- **Acceptance:** integration tests show EAN lookup returns in < 200 ms on a 1000-product seed; search returns relevant results for partial queries; cache invalidation hook fires on sale completion.
|
||||
|
||||
### `POS-006` [P0] [Phase 1] `apps/pos` Next.js app skeleton
|
||||
|
||||
- **Why:** The frontend is a new app; it needs the same auth flow, API client, and design tokens as the admin.
|
||||
- **Scope IN:** `apps/pos/` with `package.json`, `next.config.ts`, `tailwind.config.ts`, `src/app/layout.tsx`, `src/app/globals.css` (copy of admin theme tokens), `src/app/(auth)/login/page.tsx`, `src/lib/api-client.ts` (copy of admin's, extended with `posApi`), `src/lib/permissions.ts` (extended with `POS_*`), `src/features/auth/components/AuthProvider.tsx`, `src/lib/money.ts`, `src/lib/idempotency.ts`, `src/app/api/[...path]/route.ts` (proxy), `src/middleware.ts` (terminal-id cookie + redirect to `/login`).
|
||||
- **Scope OUT:** UI components (POS-007). Multi-touch components (POS-016).
|
||||
- **Touches:** new `apps/pos/` directory; `project/scripts/monolith.sh` (add `pos` service on port 3006).
|
||||
- **Acceptance:** `cd project/apps/pos && npm run build` succeeds; `npm run dev` boots; `/login` renders; login at the admin endpoint sets the cookie; `posApi.me()` returns the user.
|
||||
|
||||
### `POS-007` [P0] [Phase 1] POS UI — main register screen (layout + search + cart + totals)
|
||||
|
||||
- **Why:** First usable iteration of the TPV screen.
|
||||
- **Scope IN:** components `POSLayout`, `POSHeader`, `ProductSearch` (with auto-focus + scanner integration), `Cart`, `CartItem` (with `[-] N [+]` quantity controls), `CartTotals`, `CategorySelector`, `QuickProducts` (read-only at first). Plus `pages/(terminal)/page.tsx`.
|
||||
- **Scope OUT:** Checkout (POS-011). Touch optimisations (POS-016).
|
||||
- **Touches:** `apps/pos/src/components/pos/**`, `apps/pos/src/features/cart/**` (zustand-like reducer in plain `useReducer`), `apps/pos/src/lib/hardware/browser-scanner-adapter.ts`.
|
||||
- **Acceptance:** the operator can search by name/EAN, click a quick product, change quantities, see totals update, and reset the cart. Scanner typing an EAN auto-adds to cart. TYPING in the search input does NOT trigger scanner. Lighthouse perf ≥ 90 in `/pos`.
|
||||
|
||||
### `POS-008` [P0] [Phase 1] POS API — create sale (idempotent)
|
||||
|
||||
- **Why:** The core transaction; no POS without it.
|
||||
- **Scope IN:** `POST /pos/sales`, `GET /pos/sales`, `GET /pos/sales/:id`. `application/create-pos-sale.ts` orchestrates pricing → reserve → create order → register payments → confirm stock → mark COMPLETED in a single PG transaction. Idempotency via `idempotencyKey` UNIQUE on `orders_orders`.
|
||||
- **Scope OUT:** Refunds (POS-013). Reprint (POS-014).
|
||||
- **Touches:** `project/src/modules/pos/api/pos.routes.ts`, `project/src/modules/pos/application/create-pos-sale.ts`, `project/src/modules/inventory/application/inventory-service.ts` (extend with `storeId` parameter), `project/src/modules/payments/application/payment-service.ts` (allow `provider='cash'` and `provider='manual-card'`), `project/src/modules/orders/application/order-service.ts` (allow `source='pos'`).
|
||||
- **Acceptance:** integration test sells 2 items + cash payment → COMPLETED order exists, stock decreased, audit logged; same idempotencyKey returns same order; oversell returns 409; cash change computed correctly.
|
||||
|
||||
### `POS-009` [P1] [Phase 1] POS UI — customer association
|
||||
|
||||
- **Why:** Some stores need receipts in the customer's name for loyalty / returns.
|
||||
- **Scope IN:** `CustomerSelector` component, search by email/phone via `usersApi`, `apps/pos/src/features/cart/cart-slice.ts` extended with `customerId`.
|
||||
- **Scope OUT:** Loyalty / points (deferred).
|
||||
- **Touches:** `apps/pos/src/components/pos/CustomerSelector.tsx`, `apps/pos/src/lib/api-client.ts`.
|
||||
- **Acceptance:** typing in the customer field shows suggestions; clicking a suggestion attaches the customer; the cart header shows the customer name; the receipt includes the customer.
|
||||
|
||||
### `POS-010` [P1] [Phase 1] POS UI — discount panel
|
||||
|
||||
- **Why:** Daily operations need quick discount buttons.
|
||||
- **Scope IN:** `DiscountPanel` component with `0% / 5% / 10% / Custom` buttons for both line and global discounts.
|
||||
- **Scope OUT:** Permission gating (POS-024).
|
||||
- **Touches:** `apps/pos/src/components/pos/DiscountPanel.tsx`, `apps/pos/src/features/cart/cart-slice.ts`.
|
||||
- **Acceptance:** discounts apply to subtotal; over-limit returns server error; UI explains the limit; audit log shows actor + amount + reason.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Checkout
|
||||
|
||||
### `POS-011` [P0] [Phase 2] POS UI — payment modal (cash + card)
|
||||
|
||||
- **Why:** Without a checkout, the cart is just a viewer.
|
||||
- **Scope IN:** `PaymentModal`, `CashPayment` (with amount tendered, change, quick-buttons `[5 €] [10 €] [20 €] [50 €] [100 €] [EXACTO]`), `CardPayment` (manual auth code entry), `NumericKeypad`.
|
||||
- **Scope OUT:** Native datáfono integration (POS-031+).
|
||||
- **Touches:** `apps/pos/src/components/pos/**`.
|
||||
- **Acceptance:** the operator can complete a sale end-to-end (open register → scan → cart → cobrar → cash/card → confirm); receipt prints via browser print; cart clears.
|
||||
|
||||
### `POS-012` [P0] [Phase 2] POS API — reprint receipt
|
||||
|
||||
- **Why:** Cashier needs to reprint a receipt on request.
|
||||
- **Scope IN:** `POST /pos/sales/:id/print`, `GET /pos/sales/:id/receipt`.
|
||||
- **Scope OUT:** Refunds (POS-013).
|
||||
- **Touches:** `project/src/modules/pos/api/pos.routes.ts`, `project/src/modules/pos/application/render-receipt.ts`.
|
||||
- **Acceptance:** reprinting returns the same `ReceiptDto` as the original sale; `printerAdapter.print` is called.
|
||||
|
||||
### `POS-013` [P0] [Phase 2] POS API — refunds (full + partial)
|
||||
|
||||
- **Why:** Customer returns are an everyday reality.
|
||||
- **Scope IN:** `POST /pos/sales/:id/refund` with line-level granularity. Refunds create negative `payments_transactions` rows and `inventory_movements.operation='set_available'` rows.
|
||||
- **Scope OUT:** Refund UI (POS-026).
|
||||
- **Touches:** `project/src/modules/pos/api/pos.routes.ts`, `project/src/modules/pos/application/refund-pos-sale.ts`.
|
||||
- **Acceptance:** partial refund returns `PARTIALLY_REFUNDED`, full refund returns `REFUNDED`; stock added back; audit logged with actor.
|
||||
|
||||
### `POS-014` [P0] [Phase 2] POS API — sale cancellation (within window)
|
||||
|
||||
- **Why:** Cashier mistakes need a fast path.
|
||||
- **Scope IN:** `POST /pos/sales/:id/cancel` with 60-second window. Reservation released; state `CANCELLED`.
|
||||
- **Scope OUT:** Refunds (POS-013).
|
||||
- **Touches:** `project/src/modules/pos/api/pos.routes.ts`.
|
||||
- **Acceptance:** cancellation within 60 s succeeds; outside the window returns 409; reservation released; state `CANCELLED`.
|
||||
|
||||
### `POS-015` [P1] [Phase 2] POS UI — sale history + detail
|
||||
|
||||
- **Why:** Cashier needs to look up today's sales quickly.
|
||||
- **Scope IN:** `/pos/history` page listing sales scoped to the terminal's store; `/pos/history/[id]` page showing items, payments, and a "Reimprimir" button.
|
||||
- **Scope OUT:** Cross-store history (admin only).
|
||||
- **Touches:** `apps/pos/src/app/(terminal)/history/**`, `apps/pos/src/components/pos/TicketHistory.tsx`.
|
||||
- **Acceptance:** the operator can find a sale by receipt number or partial customer name; reprinting works.
|
||||
|
||||
### `POS-016` [P2] [Phase 2] POS UI — first-pass touch optimisations
|
||||
|
||||
- **Why:** Even desktop users benefit from bigger hit targets.
|
||||
- **Scope IN:** minimum 48×48 px touch targets, 16 px spacing between buttons, larger fonts in cart totals, virtual `NumericKeypad` always visible during cash payment.
|
||||
- **Scope OUT:** Full touch redesign (POS-029+).
|
||||
- **Touches:** `apps/pos/src/components/pos/**` styles.
|
||||
- **Acceptance:** Lighthouse accessibility ≥ 95; manual test on a 10-inch tablet shows no accidental taps.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Cash register
|
||||
|
||||
### `POS-017` [P0] [Phase 3] POS UI — open register screen
|
||||
|
||||
- **Why:** Required to start a shift.
|
||||
- **Scope IN:** `/pos/open-register` page with opening cash input, confirm button.
|
||||
- **Scope OUT:** Mid-shift reopen (closed sessions can't reopen; you close by opening a new one).
|
||||
- **Touches:** `apps/pos/src/app/(terminal)/open-register/page.tsx`.
|
||||
- **Acceptance:** cashier can open a register with a starting amount; on success, redirects to main screen; on 409 (already open), shows the existing session.
|
||||
|
||||
### `POS-018` [P0] [Phase 3] POS UI — close register screen (Z report)
|
||||
|
||||
- **Why:** End-of-shift reconciliation is mandatory.
|
||||
- **Scope IN:** `/pos/close-register` page showing sales-by-method, refunds, expected cash vs operator-counted, difference.
|
||||
- **Scope OUT:** Manager override flow (POS-024).
|
||||
- **Touches:** `apps/pos/src/app/(terminal)/close-register/page.tsx`.
|
||||
- **Acceptance:** the Z report matches the database totals; the difference is recorded; the session transitions to `CLOSED`.
|
||||
|
||||
### `POS-019` [P0] [Phase 3] POS UI — register status in header
|
||||
|
||||
- **Why:** Always-visible feedback of session state.
|
||||
- **Scope IN:** header pill showing session state, elapsed time, sale count, cash total.
|
||||
- **Scope OUT:** Configurable metrics (POS-024).
|
||||
- **Touches:** `apps/pos/src/components/pos/POSHeader.tsx`.
|
||||
- **Acceptance:** header updates in real time; "Cerrar caja" CTA visible when session is OPEN.
|
||||
|
||||
### `POS-020` [P1] [Phase 3] POS API — session summary endpoint
|
||||
|
||||
- **Why:** The Z report reads from a single computed endpoint rather than fetching many.
|
||||
- **Scope IN:** `GET /pos/sessions/:id/summary` returns `{ salesCount, salesByMethod, refundsCount, refundsByMethod, expectedCashCents }`.
|
||||
- **Scope OUT:** Historical session analytics.
|
||||
- **Touches:** `project/src/modules/pos/api/pos.routes.ts`, `project/src/modules/pos/application/session-summary.ts`.
|
||||
- **Acceptance:** summary is accurate against direct DB queries; cacheable for the duration of the close flow.
|
||||
|
||||
### `POS-021` [P1] [Phase 3] Audit hooks for register actions
|
||||
|
||||
- **Why:** Open/close/difference must be logged for accountability.
|
||||
- **Scope IN:** `AuditLogger.log` calls in `open-cash-session.ts`, `close-cash-session.ts`.
|
||||
- **Scope OUT:** Audit UI (admin already has `/admin/audit`).
|
||||
- **Touches:** `project/src/modules/pos/application/open-cash-session.ts`, `close-cash-session.ts`.
|
||||
- **Acceptance:** every open/close is logged with actor, terminal, and amounts.
|
||||
|
||||
### `POS-022` [P2] [Phase 3] Z report export (PDF / email)
|
||||
|
||||
- **Why:** Manager wants a copy at end of day.
|
||||
- **Scope IN:** "Send Z report by email" button on close screen; uses existing notifications module.
|
||||
- **Scope OUT:** PDF generation (use HTML→PDF via `puppeteer` if requested).
|
||||
- **Touches:** `apps/pos/src/app/(terminal)/close-register/page.tsx`, `project/src/modules/notifications/**`.
|
||||
- **Acceptance:** manager email receives the report; HTML email renders cleanly.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Multi-terminal
|
||||
|
||||
### `POS-023` [P1] [Phase 4] Admin UI — stores CRUD
|
||||
|
||||
- **Why:** Operators manage stores from the admin.
|
||||
- **Scope IN:** `/admin/pos/stores` (list, create, edit, deactivate).
|
||||
- **Scope OUT:** Per-store settings editor (POS-024).
|
||||
- **Touches:** new `project/apps/admin/src/app/(dashboard)/pos/stores/page.tsx`, `apps/admin/src/lib/api-client.ts` (add `posAdminStoresApi`).
|
||||
- **Acceptance:** operator can create / edit / deactivate stores; slug is auto-generated; deactivation prevents new terminals from binding.
|
||||
|
||||
### `POS-024` [P1] [Phase 4] Admin UI — terminals CRUD + binding codes
|
||||
|
||||
- **Why:** Operators provision TPV devices.
|
||||
- **Scope IN:** `/admin/pos/terminals` (list, create, generate binding code, decommission).
|
||||
- **Scope OUT:** Remote desktop (deferred).
|
||||
- **Touches:** `apps/admin/src/app/(dashboard)/pos/terminals/page.tsx`, `apps/admin/src/lib/api-client.ts`.
|
||||
- **Acceptance:** operator creates a terminal, gets a binding code, the terminal binds on first launch; decommissioning prevents future sales.
|
||||
|
||||
### `POS-025` [P1] [Phase 4] Admin UI — payment methods per store
|
||||
|
||||
- **Why:** Each store may have different cashiers / card types.
|
||||
- **Scope IN:** `/admin/pos/payment-methods` (list, create, toggle, reorder).
|
||||
- **Scope OUT:** Card terminal config (POS-031+).
|
||||
- **Touches:** `apps/admin/src/app/(dashboard)/pos/payment-methods/page.tsx`, `apps/admin/src/lib/api-client.ts`.
|
||||
- **Acceptance:** operator can disable a method mid-shift; POS app picks up the change on next config refresh.
|
||||
|
||||
### `POS-026` [P1] [Phase 4] Admin UI — sessions overview
|
||||
|
||||
- **Why:** Operators see all open sessions.
|
||||
- **Scope IN:** `/admin/pos/sessions` (list, filter by store / terminal / user / date, click to view Z report).
|
||||
- **Scope OUT:** Real-time push (poll every 30 s is enough).
|
||||
- **Touches:** `apps/admin/src/app/(dashboard)/pos/sessions/page.tsx`.
|
||||
- **Acceptance:** operator can find any session, see its summary, and download the report.
|
||||
|
||||
### `POS-027` [P2] [Phase 4] Admin UI — quick products editor
|
||||
|
||||
- **Why:** Store managers customize the quick-buttons grid.
|
||||
- **Scope IN:** `/admin/pos/quick-products` (drag-and-drop grid, add/remove, scope selector).
|
||||
- **Scope OUT:** Image upload (use existing product images).
|
||||
- **Touches:** `apps/admin/src/app/(dashboard)/pos/quick-products/page.tsx`.
|
||||
- **Acceptance:** operator can build a 12-product grid per terminal; changes propagate to the POS app within 60 s.
|
||||
|
||||
### `POS-028` [P2] [Phase 4] POS UI — concurrent terminal awareness
|
||||
|
||||
- **Why:** A sale on terminal A should show up on terminal B (e.g., for cross-terminal refunds).
|
||||
- **Scope IN:** optional WebSocket / SSE channel from backend to POS app surfacing "new sale" events in the same store.
|
||||
- **Scope OUT:** Cross-store events.
|
||||
- **Touches:** `project/src/modules/pos/api/pos.events.ts` (SSE endpoint), `apps/pos/src/lib/pos-events.ts`.
|
||||
- **Acceptance:** terminal B sees terminal A's sales in real time within 2 s.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Touch UI
|
||||
|
||||
### `POS-029` [P1] [Phase 5] Touch mode toggle + auto-detection
|
||||
|
||||
- **Why:** Touchscreens need bigger targets; desktops don't.
|
||||
- **Scope IN:** `interface_mode` from terminal settings; default `auto` detects via `(pointer: coarse)` media query; user override stored in cookie.
|
||||
- **Scope OUT:** Per-section touch variants.
|
||||
- **Touches:** `apps/pos/src/app/layout.tsx`, `apps/pos/src/components/pos/POSLayout.tsx`, `apps/pos/src/styles/touch.css`.
|
||||
- **Acceptance:** `auto` mode uses media query; manual override persists; switching modes doesn't reload the app.
|
||||
|
||||
### `POS-030` [P1] [Phase 5] Touch mode — large targets, larger spacing, virtual keypad
|
||||
|
||||
- **Why:** Touch-friendly UI is a separate design.
|
||||
- **Scope IN:** CSS variables for hit-target size and spacing; conditional class `touch` on root; bigger QuickProduct tiles; on-screen numeric keypad always visible during cash payment.
|
||||
- **Scope OUT:** Native number pad (deferred to native bridge).
|
||||
- **Touches:** `apps/pos/src/styles/touch.css`, `apps/pos/src/components/pos/**` styles.
|
||||
- **Acceptance:** manual test on a 10-inch touchscreen tablet shows no accidental taps; keypad is reachable without a physical keyboard.
|
||||
|
||||
### `POS-031` [P2] [Phase 5] Keyboard shortcuts (desktop)
|
||||
|
||||
- **Why:** Power users want fast keyboard control.
|
||||
- **Scope IN:** `F2` search, `F4` customer, `F6` discount, `F8` park, `F9` recall, `F10` cobrar, `Esc` cancel modal.
|
||||
- **Scope OUT:** Customisable shortcuts.
|
||||
- **Touches:** `apps/pos/src/lib/shortcuts.ts`, `apps/pos/src/app/(terminal)/page.tsx`.
|
||||
- **Acceptance:** shortcuts work when no input is focused; ignore when typing in an input.
|
||||
|
||||
### `POS-032` [P2] [Phase 5] Touch mode — sound feedback (optional)
|
||||
|
||||
- **Why:** Audio cue on scan success / error.
|
||||
- **Scope IN:** `AudioContext` based beeps; toggle in settings.
|
||||
- **Scope OUT:** Voice prompts.
|
||||
- **Touches:** `apps/pos/src/lib/sounds.ts`.
|
||||
- **Acceptance:** scan success plays a soft click; error plays a low beep; mute toggle works.
|
||||
|
||||
### `POS-033` [P3] [Phase 5] Responsive layout (1024×768 → 1920×1080)
|
||||
|
||||
- **Why:** Wide monitors should use the space well; small screens should still fit.
|
||||
- **Scope IN:** CSS grid with `auto-fill` for quick products; column widths tuned per breakpoint.
|
||||
- **Scope OUT:** Phone-sized screens (< 1024 px).
|
||||
- **Touches:** `apps/pos/src/components/pos/**` styles.
|
||||
- **Acceptance:** no horizontal scroll at any tested width; quick products grid reflows correctly.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Hardware (adapter layer)
|
||||
|
||||
### `POS-034` [P0] [Phase 6] Hardware adapter interfaces (canonical)
|
||||
|
||||
- **Why:** Single source of truth for all hardware abstraction.
|
||||
- **Scope IN:** `project/src/shared/hardware/types.ts` (interfaces for Scanner, Printer, CashDrawer, PaymentTerminal, Scale); `project/src/shared/hardware/index.ts` (exports). Documentation in `docs/pos/POS_HARDWARE.md`.
|
||||
- **Scope OUT:** Implementations.
|
||||
- **Touches:** `project/src/shared/hardware/**` (new).
|
||||
- **Acceptance:** interfaces compile; documentation covers each adapter; tests cover the contract.
|
||||
|
||||
### `POS-035` [P0] [Phase 6] Browser implementations (scanner, printer, cash drawer)
|
||||
|
||||
- **Why:** Required for any Phase 1–5 functionality.
|
||||
- **Scope IN:** `BrowserScannerAdapter`, `BrowserPrinterAdapter`, `BrowserCashDrawerAdapter` (delegates to printer).
|
||||
- **Scope OUT:** Native bridge.
|
||||
- **Touches:** `apps/pos/src/lib/hardware/**`.
|
||||
- **Acceptance:** scanner auto-detects HID input and adds to cart; printer prints receipt via `window.print()`; cash drawer opens via printer kick-out (or no-op for browser).
|
||||
|
||||
### `POS-036` [P1] [Phase 6] Print route `/print/[orderId]`
|
||||
|
||||
- **Why:** Render the receipt in a print-stylesheet-only route.
|
||||
- **Scope IN:** `apps/pos/src/app/print/[orderId]/page.tsx`, print-specific CSS.
|
||||
- **Scope OUT:** Native ESC/POS rendering.
|
||||
- **Touches:** `apps/pos/src/app/print/**`, `apps/pos/src/styles/print.css`.
|
||||
- **Acceptance:** print preview shows the 80mm layout; no app chrome leaks; totals match the `ReceiptDto`.
|
||||
|
||||
### `POS-037` [P2] [Phase 6] Native bridge spec (HTTP)
|
||||
|
||||
- **Why:** Define the contract for vendor-specific bridges.
|
||||
- **Scope IN:** `docs/pos/POS_HARDWARE.md` §3.3 and §4.2 (already written); add a sample Node skeleton under `project/src/shared/hardware/native-bridge-spec/` (no implementation, just a README + OpenAPI).
|
||||
- **Scope OUT:** Real bridges.
|
||||
- **Touches:** `project/src/shared/hardware/native-bridge-spec/**` (new, optional).
|
||||
- **Acceptance:** README documents the HTTP contract and how a bridge would slot in.
|
||||
|
||||
### `POS-038` [P2] [Phase 6] Vendor adapter: SumUp (deferred)
|
||||
|
||||
- **Why:** SumUp is a likely first datáfono vendor.
|
||||
- **Status:** P3 until operator picks SumUp.
|
||||
- **Scope IN:** native bridge process (`pos-sumup-bridge`) + `apps/pos/src/lib/hardware/sumup-payment-terminal-adapter.ts`.
|
||||
- **Touches:** new out-of-repo Node project (not in this codebase).
|
||||
- **Acceptance:** SumUp terminal charges the customer; auth code returns to POS; payment is recorded in `payments_transactions`.
|
||||
|
||||
### `POS-039` [P3] [Phase 6] Scale adapter
|
||||
|
||||
- **Why:** Some products are sold by weight.
|
||||
- **Scope IN:** Scale adapter interface (already in POS-034); browser manual entry in Phase 5; native bridge in Phase 7+.
|
||||
- **Scope OUT:** Vendor-specific bridges.
|
||||
- **Touches:** `apps/pos/src/lib/hardware/scale-adapter.ts`.
|
||||
- **Acceptance:** weight entry shows in the cart with the right unit price calculation.
|
||||
|
||||
### `POS-040` [P3] [Phase 6] Cash drawer kick-out via printer command
|
||||
|
||||
- **Why:** On real ESC/POS printers, the kick-out is `ESC p 0`.
|
||||
- **Status:** P3 until operator picks a printer.
|
||||
- **Scope IN:** native bridge sends the byte after receipt.
|
||||
- **Scope OUT:** Standalone drawer opener hardware.
|
||||
- **Acceptance:** drawer opens after every successful print.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — Polish + future
|
||||
|
||||
### `POS-041` [P2] [Phase 7] Refund UI (from sale history)
|
||||
|
||||
- **Why:** Manager needs to refund from the history page.
|
||||
- **Scope IN:** `/pos/sales/:id` page with a "Refund" button → modal with line selection + reason.
|
||||
- **Scope OUT:** Cross-store refunds.
|
||||
- **Touches:** `apps/pos/src/app/(terminal)/sales/[id]/page.tsx`.
|
||||
- **Acceptance:** manager can refund a line or a full sale; stock is added back; audit logged.
|
||||
|
||||
### `POS-042` [P3] [Phase 7] Offline mode (read-only cache + queue)
|
||||
|
||||
- **Why:** Operators want continuity during network drops.
|
||||
- **Status:** P3 until the operator explicitly commits. Requires deep analysis (stock consistency, payment refunds, audit trust).
|
||||
- **Scope IN:** IndexedDB-backed product + customer cache; queued mutations with conflict detection; merge strategy for offline sales.
|
||||
- **Scope OUT:** Real implementation in this codebase.
|
||||
- **Acceptance:** a design doc under `docs/pos/POS_OFFLINE.md` lays out the strategy.
|
||||
|
||||
### `POS-043` [P3] [Phase 7] Loyalty / points
|
||||
|
||||
- **Why:** Brief §14 anticipates this.
|
||||
- **Status:** P3 until needed.
|
||||
- **Scope IN:** new `pos_loyalty` module; points ledger per customer; integration with `pos.sale.created` event.
|
||||
- **Touches:** `project/src/modules/pos/loyalty/**` (new).
|
||||
- **Acceptance:** customers earn points on each sale; can redeem at next sale.
|
||||
|
||||
### `POS-044` [P3] [Phase 7] Multi-currency
|
||||
|
||||
- **Why:** EU cross-border expansion.
|
||||
- **Status:** P3 until needed. Requires extending `pricing_variant_prices.currency` (already supports it) and `orders_orders.currency` (already supports it).
|
||||
- **Scope IN:** UI currency picker, exchange-rate source, FX snapshot on order creation.
|
||||
- **Scope OUT:** Crypto.
|
||||
- **Touches:** `apps/pos/src/components/pos/**`, `project/src/modules/pricing/**`.
|
||||
- **Acceptance:** a sale in EUR with a USD display works; order snapshot stores the EUR value at sale time.
|
||||
|
||||
### `POS-045` [P3] [Phase 7] Gift receipts
|
||||
|
||||
- **Why:** Brief §13 mentions reprinting.
|
||||
- **Scope IN:** "Gift receipt" option in print flow; emits a separate receipt with prices hidden.
|
||||
- **Scope OUT:** Email delivery.
|
||||
- **Touches:** `apps/pos/src/components/pos/PaymentModal.tsx`, `apps/pos/src/app/print/[orderId]/page.tsx`.
|
||||
- **Acceptance:** gift receipt omits prices; includes product names and a thank-you note.
|
||||
|
||||
### `POS-046` [P3] [Phase 7] Saved customer preferences (loyalty card scan)
|
||||
|
||||
- **Why:** Brief §14 anticipates this.
|
||||
- **Status:** P3 until needed.
|
||||
- **Scope IN:** scan a loyalty barcode as a customer identifier.
|
||||
- **Touches:** `apps/pos/src/components/pos/CustomerSelector.tsx`.
|
||||
- **Acceptance:** scanning a loyalty code attaches the customer.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Priority | Count | Phases |
|
||||
|---|---|---|
|
||||
| **P0** imprescindible | 16 | 1, 2, 3, 6 |
|
||||
| **P1** importante | 18 | 1, 2, 3, 4, 5 |
|
||||
| **P2** mejora | 9 | 2, 3, 4, 5, 7 |
|
||||
| **P3** futuro | 6 | 5, 6, 7 |
|
||||
| **Total** | 49 | 7 |
|
||||
|
||||
The operator can choose to delay or skip P2/P3 tickets freely. P0 tickets form the minimum viable TPV.
|
||||
|
||||
---
|
||||
|
||||
## Status snapshot
|
||||
|
||||
- **Phase 0 (POS-001):** in progress (this work).
|
||||
- **Phase 1 (POS-002 … POS-010):** queued in backlog; waiting on operator approval of this discovery phase.
|
||||
- **Phase 2 (POS-011 … POS-016):** queued.
|
||||
- **Phase 3 (POS-017 … POS-022):** queued.
|
||||
- **Phase 4 (POS-023 … POS-028):** queued.
|
||||
- **Phase 5 (POS-029 … POS-033):** queued.
|
||||
- **Phase 6 (POS-034 … POS-040):** queued.
|
||||
- **Phase 7 (POS-041 … POS-046):** queued.
|
||||
|
||||
**No implementation work begins until the operator signs off on the 5 docs under `docs/pos/` and approves Phase 1 to start.**
|
||||
Reference in New Issue
Block a user