181 lines
11 KiB
Markdown
181 lines
11 KiB
Markdown
# 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).
|