# POS-002 — Design: Schema migrations 043–046 + OrderState.COMPLETED + Role extensions **Author:** architect **Date:** 2026-08-21 **Stage:** design ## Context Phase 1 (POS-001) defined the POS integration. Phase 2's first ticket (this) lays the schema foundation: 4 migrations, 2 type extensions, 1 inventory service signature change. Everything in `POS-003` through `POS-046` depends on this ticket landing cleanly. ## Decisions locked in (from POS-001 + operator answers) - Default store name: "Natural - Mercado de Vida" (`00000000-0000-0000-0000-000000000001`). - Refund window: 15 days, manager > 50 € (configurable; defaults baked into the backend; admin UI follows in POS-023). - Cookie domain: separate port for now; POS-002 doesn't touch cookies. - Hardware decisions: out of scope for this ticket. ## Design ### Migration order The 4 migrations run sequentially. Each is idempotent and reversible where possible: 1. **`043_pos_basics.js`** — create the 6 new `pos_*` tables. Seed the default store and 2 default payment methods. **Independent** of all other migrations. 2. **`044_pos_inventory_store.js`** — add `store_id` to `inventory_stock` and `inventory_movements`. Backfill all rows with the default store UUID (the one seeded in 043). Replace the unique constraint on `inventory_stock` from `(variant_id)` to `(variant_id, store_id)`. **Depends on 043** (needs the default store UUID). 3. **`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. Add indexes. **Independent** of 044 but logically before 046 because role-based features will reference the new `terminal_id`. 4. **`046_pos_backoffice_roles.js`** — extend `backoffice_users.role` CHECK to include `pos_cashier` and `pos_manager`. **Independent** but listed last so the operator sees the "front-office" roles only after the schema is in place. ### Migration 043: `pos_basics` Six tables, all `IF NOT EXISTS`. The seed runs at the end of the `up()` body (after all tables exist): - `pos_stores` — primary key on UUID. UNIQUE on `slug`. CHECK on `name` length. - `pos_terminals` — FK to `pos_stores`. UNIQUE on `binding_code` (nullable, partial). CHECK on `status`, `interface_mode`. - `pos_payment_methods` — FK to `pos_stores`. UNIQUE on `(store_id, code)`. CHECK on `kind`. - `pos_quick_products` — FK to `pos_stores`, `pos_terminals` (nullable), `backoffice_users` (nullable), `catalog_product_variants`. UNIQUE NULLS NOT DISTINCT on `(store_id, terminal_id, user_id, variant_id)`. - `pos_parked_tickets` — FK to `pos_stores`, `pos_terminals`, `backoffice_users`, `identity_users` (nullable). Default `expires_at = now() + interval '24 hours'`. - `pos_cash_sessions` — FK to `pos_terminals`, `pos_stores`, `backoffice_users`. Partial UNIQUE index `WHERE status = 'OPEN'` ensures one open session per terminal at the DB level. Seeds: - One `pos_stores` row with the well-known UUID `00000000-0000-0000-0000-000000000001`. - Two `pos_payment_methods` rows for the default store: `cash` (Efectivo) and `card` (Tarjeta). Downs: drop the 6 tables in reverse dependency order. Cascade removes dependent rows. ### Migration 044: `pos_inventory_store` Steps (in `up()`): 1. `ALTER TABLE inventory_stock ADD COLUMN IF NOT EXISTS store_id uuid;` — nullable initially so backfill doesn't break. 2. `UPDATE inventory_stock SET store_id = '00000000-0000-0000-0000-000000000001' WHERE store_id IS NULL;` — backfill. 3. `ALTER TABLE inventory_stock ALTER COLUMN store_id SET NOT NULL;` 4. `ALTER TABLE inventory_stock ADD CONSTRAINT inventory_stock_store_id_fkey FOREIGN KEY (store_id) REFERENCES pos_stores(id) ON DELETE RESTRICT;` 5. `ALTER TABLE inventory_stock DROP CONSTRAINT IF EXISTS inventory_stock_variant_id_key;` 6. `ALTER TABLE inventory_stock ADD CONSTRAINT inventory_stock_variant_store_unique UNIQUE (variant_id, store_id);` 7. Same 4-step pattern for `inventory_movements`. Downs: drop the FK + new constraint, drop the column. Loses the `store_id` data on rollback (acceptable; this is a Phase 2 migration the operator can roll back). ### Migration 045: `pos_orders_source` 1. Add columns: `source text NOT NULL DEFAULT 'ecommerce' CHECK (source IN ('ecommerce','pos','admin'))`, `terminal_id uuid`, `cash_session_id uuid`. 2. Add indexes. 3. `ALTER TABLE orders_orders ALTER COLUMN user_id DROP NOT NULL;` — enables walk-in sales. 4. Add `COMPLETED` to the state CHECK by `DROP CONSTRAINT` + `ADD CONSTRAINT`. 5. Add FKs: `terminal_id` and `cash_session_id` reference `pos_terminals` and `pos_cash_sessions` respectively, both `ON DELETE SET NULL` (we don't want deleting a terminal to delete historical sales). ### Migration 046: `pos_backoffice_roles` Drop the existing role CHECK, add a new one with `pos_cashier` and `pos_manager`. ### TypeScript type extensions #### `src/shared/auth.ts` - Add `pos_cashier` and `pos_manager` to `Role`: ```ts export type Role = 'customer' | 'admin' | 'editor' | 'pos_cashier' | 'pos_manager'; ``` - Add `requireAnyRole(user, [...roles])`: ```ts export function requireAnyRole(user: CurrentUser, roles: ReadonlyArray): void { if (!roles.includes(user.role)) { throw new AppError(403, 'FORBIDDEN', 'Access denied'); } } ``` - Keep `requireRole` unchanged (backwards compatible). - Update the existing `tests/auth.test.ts` to cover `requireAnyRole`. #### `src/modules/orders/domain/order.ts` - Add `'COMPLETED'` to `OrderState` union. - Add `COMPLETED: []` to `ALLOWED_TRANSITIONS` (terminal state). - Verify nothing else needs to change (the state machine validator uses `ALLOWED_TRANSITIONS` to gate transitions). #### `src/modules/inventory/application/inventory-service.ts` (and ports) The biggest code change. Add `storeId` parameter to every public inventory method: ```ts // Before checkAvailability(variantId: string, quantity: number): Promise reserve(variantId: string, quantity: number): Promise release(variantId: string, quantity: number): Promise confirm(variantId: string, quantity: number): Promise setStock(variantId: string, quantity: number): Promise<{ available: number }> // After checkAvailability(variantId: string, storeId: string, quantity: number): Promise reserve(variantId: string, storeId: string, quantity: number): Promise release(variantId: string, storeId: string, quantity: number): Promise confirm(variantId: string, storeId: string, quantity: number): Promise setStock(variantId: string, storeId: string, quantity: number): Promise<{ available: number }> ``` This is a **breaking change** in the inventory module. The cascading effect: - `src/modules/checkout/application/checkout-service.ts` calls `inventory.reserve(...)` and `inventory.checkAvailability(...)`. Update both calls to pass `DEFAULT_STORE_ID`. - `src/modules/inventory/api/inventory.routes.ts` accepts `?storeId=...` query param (defaulting to `DEFAULT_STORE_ID` if absent). The existing `setStock` route gains the param. - All itest tests that call inventory methods are updated. #### Default store constant A new constant in `src/modules/inventory/domain/ports.ts` (or a sibling module) — the well-known UUID `00000000-0000-0000-0000-000000000001`. Single source of truth; both the migration and the seed flow reference it. Kept as `as const` so it's typed as a literal UUID. ### Files affected (summary) | Path | Change | |---|---| | `project/migrations/043_pos_basics.js` | NEW — 6 tables + seed | | `project/migrations/044_pos_inventory_store.js` | NEW — inventory_store_id columns + backfill + new constraint | | `project/migrations/045_pos_orders_source.js` | NEW — source column, terminal/cash_session FKs, COMPLETED state | | `project/migrations/046_pos_backoffice_roles.js` | NEW — role CHECK extension | | `project/src/shared/auth.ts` | extend `Role`, add `requireAnyRole` | | `project/src/shared/tests/auth.test.ts` | cover `requireAnyRole` | | `project/src/modules/orders/domain/order.ts` | add `'COMPLETED'` to `OrderState`, `ALLOWED_TRANSITIONS` | | `project/src/modules/orders/tests/order-state-machine.test.ts` | cover `COMPLETED` transitions | | `project/src/modules/inventory/application/inventory-service.ts` | add `storeId` parameter to all 5 public methods | | `project/src/modules/inventory/domain/ports.ts` | add `storeId` to all 5 method signatures | | `project/src/modules/inventory/api/inventory.routes.ts` | accept `?storeId=` on GET endpoints; require `storeId` on PUT | | `project/src/modules/inventory/infrastructure/pg-inventory-repository.ts` | thread `store_id` into SQL | | `project/src/modules/checkout/application/checkout-service.ts` | pass `DEFAULT_STORE_ID` to inventory calls | | `project/src/modules/inventory/index.ts` | export `DEFAULT_STORE_ID` | | `project/src/app/tests/*.itest.ts` | update tests that call inventory methods | ### Tests - `project/src/shared/tests/auth.test.ts` — new test block for `requireAnyRole`. - `project/src/modules/orders/tests/order-state-machine.test.ts` — new test block for `COMPLETED` (terminal state, no outgoing transitions). - `project/src/modules/inventory/tests/*.test.ts` — update existing tests to pass `storeId`; add new tests for store-scoped queries. - Migration idempotency: each migration's `up()` is re-runnable; `down()` is symmetrical. ### Out of scope - No new endpoints. POS-004 introduces `/pos/*` routes. - No new module. `project/src/modules/pos/**` is created in POS-003. - No frontend work. POS-006 starts `apps/pos/`. - No store_settings defaults for refund window. That's POS-023 (admin UI) and a follow-up migration in POS-013. ## Acceptance criteria - AC-1. All 4 migrations apply cleanly on a fresh DB (`dropdb && createdb && npm run db:migrate`). - AC-2. All 4 migrations apply cleanly on the current dev DB. - AC-3. `SELECT COUNT(*) FROM pos_stores` = 1 (seed). - AC-4. `SELECT COUNT(*) FROM pos_payment_methods WHERE store_id = '00000000-0000-0000-0000-000000000001'` = 2 (cash + card). - AC-5. `SELECT COUNT(*) FROM inventory_stock WHERE store_id IS NULL` = 0 (backfill). - AC-6. `SELECT COUNT(*) FROM inventory_stock` matches the pre-migration count (no rows lost). - AC-7. `INSERT INTO inventory_stock (variant_id, store_id, available) VALUES ('x', '00000000-0000-0000-0000-000000000001', 0)` succeeds; second insert with same `(variant_id, store_id)` fails with unique violation. - AC-8. `INSERT INTO orders_orders (user_id, source) VALUES (NULL, 'pos')` succeeds; `INSERT INTO orders_orders (user_id, source) VALUES (NULL, 'ecommerce')` fails. - AC-9. `INSERT INTO orders_orders (state, source) VALUES ('COMPLETED', 'pos')` succeeds. - AC-10. `INSERT INTO orders_orders (state, source) VALUES ('COMPLETED', 'ecommerce')` succeeds (state is allowed for any source). - AC-11. `INSERT INTO backoffice_users (email, password_hash, role) VALUES ('a@b.c', 'x', 'pos_cashier')` succeeds. - AC-12. `INSERT INTO backoffice_users (email, password_hash, role) VALUES ('a@b.c', 'x', 'pos_owner')` fails with CHECK violation. - AC-13. `requireAnyRole({ role: 'pos_cashier' }, ['pos_cashier','pos_manager'])` does not throw. - AC-14. `requireAnyRole({ role: 'customer' }, ['pos_cashier','pos_manager'])` throws `AppError(403)`. - AC-15. `inventory.reserve('variant-uuid', 'default-store-uuid', 1)` succeeds; existing tests pass after the signature change. - AC-16. `verify.sh` exit 0. - AC-17. Re-running each migration is a no-op (`IF NOT EXISTS` clauses + WHERE filters).