31 KiB
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 viaindex.ts. - Module isolation: modules import ONLY public APIs of other modules; the boundary checker (
scripts/check-module-boundaries.mjs) enforces this. - Auth:
Authenticateport fromshared/auth.ts; routes callawait deps.authenticate(request)and thenrequireRole(user, 'admin' | 'editor' | 'customer'). - RBAC: role-based string check in
requireRole. Permissions are an enum inapps/admin/src/lib/permissions.ts(admin only, currently). - Validation: explicit
parseJson(zodSchema, request.body)viashared/http-input.ts. No magic. - Errors:
AppError(httpStatus, code, message, details?)fromshared/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/Meterports fromobservability(no-op default). - Tests: vitest unit + itest (integration) inside
src/modules/<m>/tests/. E2E insrc/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
@themeblock (greens#2D6A4F,#1B4332; secondary cream#F5F0E8; accent orange#E76F51; danger#DC2626). AuthProvidercontext,useAuth()hook,requireRoleredirect to/login.lib/api-client.tswith typed namespaces (productsApi,brandsApi,ordersApi, …). Catch-all proxy atapp/api/[...path]/route.ts.- Tanstack-Query-like manual caching via
useEffect+useState(no Redux/Zustand yet). lib/permissions.tsfor 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
idempotencyKeyreturns the same order — no double-charge on network retry. - Atomicity: everything is in one PG transaction (using a single connection / client). The existing
OrderServiceandInventoryServicealready operate on a passedpg.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
// 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_stockwithstore_id— chosen. Migration adds the column with a default "Main Store" UUID; backfills existing rows. Foreign key to newstorestable. - (B) New
inventory_stock_by_storetable — 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, sameAuthenticateport. The POS app calls the same/api/backoffice/auth/loginendpoint. - Authorization: extend
shared/auth.tswithrequireAnyRole(user, [...roles]). New roles:pos_cashier,pos_manager. Permission stringPOS_*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:
Authenticateport,parseJson,AppError,parseJson(validation),AuditLogger.OrderService(withsourceextension),InventoryService(withstore_idextension).PricingService,TaxRateRepository,CatalogSearch,BrandRepository,CategoryRepository,ProductRepository,VariantRepository.CustomerRepositoryfromusersfor ticket association.StoreSettingsRepositoryfor ticket header/footer.
Existing frontend utilities reused:
lib/api-client.tspattern (copy + extend withposApi).lib/permissions.tsextended withPOS_*permissions.AuthProviderpattern copied and adapted (no shared package yet).- Tailwind theme tokens (the
@themeblock 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:
- ✅ Default store name — RESOLVED: "Natural - Mercado de Vida" (slug
natural-mercado-de-vida). - ✅ Cookie domain — RESOLVED: separate port
:3006for the POS app (alongside admin:3004). Cookie is set withDomainunset (so the browser scopes it to the POS origin only). When the operator deploys to production with Traefik and subdomains, the cookie strategy will be revisited (Phase 4 cleanup): at that pointDomain=.mercadodevida.exampleallows admin and POS to share the same backoffice session. - ✅ Touchscreen target hardware — RESOLVED: 10-inch tablet in landscape. Responsive design from 1024×768 to 1920×1080, with a sensible tablet portrait mode (800×1280) as a stretch goal. The touch CSS uses media query
(pointer: coarse)to detect and switch; manual override stored in cookie. - ✅ Datáfono provider — RESOLVED: stays manual in Phase 3. Phase 7 (
POS-038) deferred — only reopens if the operator picks SumUp, Redsys TPVO, or Verifone. - ✅ Refund policy defaults — RESOLVED: refunds allowed up to 15 days after sale. Manager approval required for refunds above 50 € (configurable in
store_settings).
The remaining questions (printer model, scanner type, cash drawer wiring, scale brand) are documented in POS_HARDWARE.md §10 and resolved there.
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.mdexist with concrete specs. - ✅
POS_TASKS.mdlists P0–P3 prioritized tickets queued in the backlog (POS-002+). - ✅
verify.shexit 0. - ✅
runtime-status.jsonreset to idle.
No code is touched in this phase beyond the docs.