# 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_` (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', 'Natural - Mercado de Vida', 'natural-mercado-de-vida', 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 // After reserve(variantId: string, storeId: string, quantity: number): Promise ``` 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).