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

```ts
// 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).
