22 KiB
POS Data Model — Mercado de Vida
Companion to:
POS_ARCHITECTURE.mdStatus: 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 0with a CHECK constraint>= 0where appropriate. Refund / negative-amount flows use CHECK>= 0on the aggregated columns but allow per-row negativeamount_centsinpayments_transactions(already supported). - FK references use
ON DELETErules:RESTRICTfor terminal↔session (you can't delete a terminal with sessions),CASCADEonly 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', '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.
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):
- Admin generates a one-time code from
/admin/pos/terminals. - Operator opens POS app for the first time → enters code → cookie
pos_terminal_idis set,bound_atrecorded.
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 integerwith explicit CHECK constraints. - ✅ FK rules declared:
RESTRICTfor terminal↔session,CASCADEfor quick products / parked tickets / payment methods on parent delete,SET NULLforterminal_id/cash_session_id/customer_idon order (so deleting a terminal doesn't kill historical sales). - ✅ CHECK on
statekeeps the existing states plusCOMPLETED. - ✅ Unique partial index
pos_cash_sessions_open_per_terminal_idxenforces one open session per terminal at the DB level. - ✅ All migrations are idempotent and reversible (where the change is reversible).