Files
2026-08-21 21:55:43 +02:00

11 KiB
Raw Permalink Blame History

POS-002 — Design: Schema migrations 043046 + 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:
    export type Role = 'customer' | 'admin' | 'editor' | 'pos_cashier' | 'pos_manager';
    
  • Add requireAnyRole(user, [...roles]):
    export function requireAnyRole(user: CurrentUser, roles: ReadonlyArray<Role>): 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:

// Before
checkAvailability(variantId: string, quantity: number): Promise<Availability>
reserve(variantId: string, quantity: number): Promise<Reservation>
release(variantId: string, quantity: number): Promise<void>
confirm(variantId: string, quantity: number): Promise<void>
setStock(variantId: string, quantity: number): Promise<{ available: number }>

// After
checkAvailability(variantId: string, storeId: string, quantity: number): Promise<Availability>
reserve(variantId: string, storeId: string, quantity: number): Promise<Reservation>
release(variantId: string, storeId: string, quantity: number): Promise<void>
confirm(variantId: string, storeId: string, quantity: number): Promise<void>
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).