11 KiB
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:
043_pos_basics.js— create the 6 newpos_*tables. Seed the default store and 2 default payment methods. Independent of all other migrations.044_pos_inventory_store.js— addstore_idtoinventory_stockandinventory_movements. Backfill all rows with the default store UUID (the one seeded in 043). Replace the unique constraint oninventory_stockfrom(variant_id)to(variant_id, store_id). Depends on 043 (needs the default store UUID).045_pos_orders_source.js— addsource,terminal_id,cash_session_idtoorders_orders. Drop NOT NULL onuser_id. AddCOMPLETEDto state CHECK. Add indexes. Independent of 044 but logically before 046 because role-based features will reference the newterminal_id.046_pos_backoffice_roles.js— extendbackoffice_users.roleCHECK to includepos_cashierandpos_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 onslug. CHECK onnamelength.pos_terminals— FK topos_stores. UNIQUE onbinding_code(nullable, partial). CHECK onstatus,interface_mode.pos_payment_methods— FK topos_stores. UNIQUE on(store_id, code). CHECK onkind.pos_quick_products— FK topos_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 topos_stores,pos_terminals,backoffice_users,identity_users(nullable). Defaultexpires_at = now() + interval '24 hours'.pos_cash_sessions— FK topos_terminals,pos_stores,backoffice_users. Partial UNIQUE indexWHERE status = 'OPEN'ensures one open session per terminal at the DB level.
Seeds:
- One
pos_storesrow with the well-known UUID00000000-0000-0000-0000-000000000001. - Two
pos_payment_methodsrows for the default store:cash(Efectivo) andcard(Tarjeta).
Downs: drop the 6 tables in reverse dependency order. Cascade removes dependent rows.
Migration 044: pos_inventory_store
Steps (in up()):
ALTER TABLE inventory_stock ADD COLUMN IF NOT EXISTS store_id uuid;— nullable initially so backfill doesn't break.UPDATE inventory_stock SET store_id = '00000000-0000-0000-0000-000000000001' WHERE store_id IS NULL;— backfill.ALTER TABLE inventory_stock ALTER COLUMN store_id SET NOT NULL;ALTER TABLE inventory_stock ADD CONSTRAINT inventory_stock_store_id_fkey FOREIGN KEY (store_id) REFERENCES pos_stores(id) ON DELETE RESTRICT;ALTER TABLE inventory_stock DROP CONSTRAINT IF EXISTS inventory_stock_variant_id_key;ALTER TABLE inventory_stock ADD CONSTRAINT inventory_stock_variant_store_unique UNIQUE (variant_id, store_id);- 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
- Add columns:
source text NOT NULL DEFAULT 'ecommerce' CHECK (source IN ('ecommerce','pos','admin')),terminal_id uuid,cash_session_id uuid. - Add indexes.
ALTER TABLE orders_orders ALTER COLUMN user_id DROP NOT NULL;— enables walk-in sales.- Add
COMPLETEDto the state CHECK byDROP CONSTRAINT+ADD CONSTRAINT. - Add FKs:
terminal_idandcash_session_idreferencepos_terminalsandpos_cash_sessionsrespectively, bothON 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_cashierandpos_managertoRole: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
requireRoleunchanged (backwards compatible). - Update the existing
tests/auth.test.tsto coverrequireAnyRole.
src/modules/orders/domain/order.ts
- Add
'COMPLETED'toOrderStateunion. - Add
COMPLETED: []toALLOWED_TRANSITIONS(terminal state). - Verify nothing else needs to change (the state machine validator uses
ALLOWED_TRANSITIONSto 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.tscallsinventory.reserve(...)andinventory.checkAvailability(...). Update both calls to passDEFAULT_STORE_ID.src/modules/inventory/api/inventory.routes.tsaccepts?storeId=...query param (defaulting toDEFAULT_STORE_IDif absent). The existingsetStockroute 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 forrequireAnyRole.project/src/modules/orders/tests/order-state-machine.test.ts— new test block forCOMPLETED(terminal state, no outgoing transitions).project/src/modules/inventory/tests/*.test.ts— update existing tests to passstoreId; 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_stockmatches 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'])throwsAppError(403). - AC-15.
inventory.reserve('variant-uuid', 'default-store-uuid', 1)succeeds; existing tests pass after the signature change. - AC-16.
verify.shexit 0. - AC-17. Re-running each migration is a no-op (
IF NOT EXISTSclauses + WHERE filters).