Files
mercadodevida/docs/pos/POS_DATA_MODEL.md
2026-08-21 21:55:43 +02:00

22 KiB

POS Data Model — Mercado de Vida

Companion to: 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.

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:

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.

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.

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).

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.

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.

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):

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

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:

// 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:

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

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:

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

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:

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

-- 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).