diff --git a/backlog/features.json b/backlog/features.json index cc16ba9..1e670c1 100644 --- a/backlog/features.json +++ b/backlog/features.json @@ -5543,13 +5543,15 @@ "description": "See docs/pos/POS_TASKS.md POS-002 for full description. Triage and scoping happens at leader intake.", "priority": "high", "risk": "med", - "status": "pending", + "status": "done", "created_at": "2026-08-21", "gates": { - "reviewer": false, - "security": false, - "qa": false - } + "reviewer": true, + "security": true, + "qa": true, + "close": true + }, + "completed_at": "2026-08-21T19:55:43Z" }, { "id": "POS-003", diff --git a/docs/pos/POS_ARCHITECTURE.md b/docs/pos/POS_ARCHITECTURE.md index 4b0c354..bc2b938 100644 --- a/docs/pos/POS_ARCHITECTURE.md +++ b/docs/pos/POS_ARCHITECTURE.md @@ -467,11 +467,13 @@ Each phase ends with **all three gates approved (reviewer, security, qa)** and ` These are decisions the architect cannot take unilaterally and that block Phase 2: -1. **Default store name** for the backfill migration ("Mercado de Vida — Tienda única" until renamed via admin)? -2. **Cookie domain**: deploy POS on the same origin as admin (port 3006, subdomain, or sibling port)? Affects cookie sharing. -3. **Touchscreen minimum target hardware** (model + size) so the touch UI can be sized correctly. -4. **Datáfono provider preference** (none, SumUp, Redsys TPVO, generic) — informs whether Phase 7 needs a real adapter or stays browser-only. -5. **Refund policy defaults** (within X days, require manager approval above Y €). +1. ✅ **Default store name** — **RESOLVED**: "Natural - Mercado de Vida" (slug `natural-mercado-de-vida`). +2. ✅ **Cookie domain** — **RESOLVED**: separate port `:3006` for the POS app (alongside admin `:3004`). Cookie is set with `Domain` unset (so the browser scopes it to the POS origin only). When the operator deploys to production with Traefik and subdomains, the cookie strategy will be revisited (Phase 4 cleanup): at that point `Domain=.mercadodevida.example` allows admin and POS to share the same backoffice session. +3. ✅ **Touchscreen target hardware** — **RESOLVED**: 10-inch tablet in landscape. Responsive design from 1024×768 to 1920×1080, with a sensible tablet portrait mode (800×1280) as a stretch goal. The touch CSS uses media query `(pointer: coarse)` to detect and switch; manual override stored in cookie. +4. ✅ **Datáfono provider** — **RESOLVED**: stays manual in Phase 3. Phase 7 (`POS-038`) deferred — only reopens if the operator picks SumUp, Redsys TPVO, or Verifone. +5. ✅ **Refund policy defaults** — **RESOLVED**: refunds allowed up to 15 days after sale. Manager approval required for refunds above 50 € (configurable in `store_settings`). + +The remaining questions (printer model, scanner type, cash drawer wiring, scale brand) are documented in `POS_HARDWARE.md §10` and resolved there. --- diff --git a/docs/pos/POS_DATA_MODEL.md b/docs/pos/POS_DATA_MODEL.md index 3acc286..def1992 100644 --- a/docs/pos/POS_DATA_MODEL.md +++ b/docs/pos/POS_DATA_MODEL.md @@ -46,7 +46,7 @@ CREATE TABLE pos_stores ( ```sql INSERT INTO pos_stores (id, name, slug, active) -VALUES ('00000000-0000-0000-0000-000000000001', 'Mercado de Vida — Tienda principal', 'tienda-principal', true); +VALUES ('00000000-0000-0000-0000-000000000001', 'Natural - Mercado de Vida', 'natural-mercado-de-vida', true); ``` This UUID is referenced from the `inventory_stock` migration as the default store. diff --git a/docs/pos/POS_HARDWARE.md b/docs/pos/POS_HARDWARE.md index 22a53f5..992c391 100644 --- a/docs/pos/POS_HARDWARE.md +++ b/docs/pos/POS_HARDWARE.md @@ -356,10 +356,10 @@ The brief explicitly forbids coupling the UI to a vendor. Our adapter pattern en --- -## 10. Open questions for the operator +## 10. Hardware decisions (resolved) -1. **Printer model** for ESC/POS commands (Epson, Star, Citizen)? -2. **Datáfono provider** (none for Phase 3; SumUp / Redsys TPVO / Verifone for Phase 7)? -3. **Scale brand** if any (Phase 7+; not needed for Phase 2-6)? -4. **Cash drawer wiring**: kick-out via printer, or via separate RJ11/RJ12 port? -5. **Scanner type**: HID-only, or do we need serial/USB-HID native drivers? +1. ✅ **Printer model** — **RESOLVED**: **Epson** thermal receipt printer. ESC/POS compatible (TM-T20, TM-T88, or similar). Phase 7 native bridge targets the Epson ESC/POS command set: `ESC @` (initialize), `ESC ! n` (select print mode), `GS V 0` (cut), `ESC p 0` (kick-out to drawer). The browser implementation in Phase 2 uses `window.print()` and ignores the command set. +2. ✅ **Datáfono provider** — **RESOLVED**: **none for Phase 3** (manual auth code entry). Phase 7 (`POS-038`) deferred indefinitely. +3. ✅ **Scale brand** — **RESOLVED**: **none at first**. `POS-039` removed from the active P3 list. Reopens only if a scale is procured. +4. ✅ **Cash drawer wiring** — **RESOLVED**: **kick-out via printer** (the `ESC p 0` byte sent after the receipt body). This is the standard Epson + most thermal printers setup. The cash drawer is wired to the printer's RJ12 port. +5. ✅ **Scanner type** — **RESOLVED**: **USB** (HID-keyboard mode). The browser scanner adapter captures keystrokes when no input is focused (Phase 2 implementation). If the operator later needs serial or USB-HID native drivers, the adapter contract is preserved (Phase 7 native bridge). diff --git a/docs/pos/POS_OPERATIONS.md b/docs/pos/POS_OPERATIONS.md index c03efba..d4c843d 100644 --- a/docs/pos/POS_OPERATIONS.md +++ b/docs/pos/POS_OPERATIONS.md @@ -359,11 +359,13 @@ The DB-level unique index `pos_cash_sessions_open_per_terminal_idx` prevents thi ## 11. Operator checklist (Phase 1 → Phase 2) -- [ ] Approve POS_ARCHITECTURE.md, POS_DATA_MODEL.md, POS_API.md, POS_HARDWARE.md, POS_OPERATIONS.md. -- [ ] Confirm default store name for migration 043. -- [ ] Confirm cookie domain for the POS app (same origin as admin? subdomain?). +- [x] Approve POS_ARCHITECTURE.md, POS_DATA_MODEL.md, POS_API.md, POS_HARDWARE.md, POS_OPERATIONS.md. +- [x] Confirm default store name for migration 043 — **"Natural - Mercado de Vida"**. +- [x] Confirm cookie domain for the POS app — **separate port `:3006` for now; subdomains via Traefik in production**. - [ ] Provide list of TPV devices and binding strategy. - [ ] Schedule a downtime window for migration 044 (multi-store inventory). - [ ] Schedule training for cashiers on the new register flow. +- [ ] Confirm refund window in `store_settings` (15 days, manager approval > 50 €) once admin UI exposes the field. +- [ ] Procure Epson thermal receipt printer + USB HID scanner per terminal. -Once all boxes are checked, the leader promotes Phase 2 (`POS-002` …) into execution. +Once the unchecked items are confirmed, the leader promotes Phase 2 (`POS-002` …) into execution. diff --git a/docs/pos/POS_TASKS.md b/docs/pos/POS_TASKS.md index 9255aee..e394ccb 100644 --- a/docs/pos/POS_TASKS.md +++ b/docs/pos/POS_TASKS.md @@ -132,9 +132,13 @@ POS-NNN [P?] [Phase] Title - **Why:** Customer returns are an everyday reality. - **Scope IN:** `POST /pos/sales/:id/refund` with line-level granularity. Refunds create negative `payments_transactions` rows and `inventory_movements.operation='set_available'` rows. +- **Refund policy (per operator decision 2026-08-21):** + - Refunds allowed up to **15 days** after sale. Server returns 409 if the sale is older. + - Refunds > **50 €** require `pos_manager` or `admin` role; smaller refunds accept `pos_cashier`. + - Limits are configurable in `store_settings` (`refund_window_days`, `refund_manager_threshold_cents`). - **Scope OUT:** Refund UI (POS-026). - **Touches:** `project/src/modules/pos/api/pos.routes.ts`, `project/src/modules/pos/application/refund-pos-sale.ts`. -- **Acceptance:** partial refund returns `PARTIALLY_REFUNDED`, full refund returns `REFUNDED`; stock added back; audit logged with actor. +- **Acceptance:** partial refund returns `PARTIALLY_REFUNDED`, full refund returns `REFUNDED`; stock added back; audit logged with actor; refund window respected; manager threshold enforced. ### `POS-014` [P0] [Phase 2] POS API — sale cancellation (within window) @@ -280,9 +284,10 @@ POS-NNN [P?] [Phase] Title - **Why:** Touch-friendly UI is a separate design. - **Scope IN:** CSS variables for hit-target size and spacing; conditional class `touch` on root; bigger QuickProduct tiles; on-screen numeric keypad always visible during cash payment. +- **Hardware target (per operator decision 2026-08-21):** 10-inch tablet in landscape, with `(pointer: coarse)` media query as the detection signal. Minimum hit target 48×48 px (CSS variable `--touch-target-min`); preferred 56×56 px. Cash payment screen reserves ≥ 60% of viewport for the keypad. - **Scope OUT:** Native number pad (deferred to native bridge). - **Touches:** `apps/pos/src/styles/touch.css`, `apps/pos/src/components/pos/**` styles. -- **Acceptance:** manual test on a 10-inch touchscreen tablet shows no accidental taps; keypad is reachable without a physical keyboard. +- **Acceptance:** manual test on a 10-inch touchscreen tablet (and on a 10-inch tablet portrait) shows no accidental taps; keypad is reachable without a physical keyboard. ### `POS-031` [P2] [Phase 5] Keyboard shortcuts (desktop) @@ -344,17 +349,18 @@ POS-NNN [P?] [Phase] Title - **Touches:** `project/src/shared/hardware/native-bridge-spec/**` (new, optional). - **Acceptance:** README documents the HTTP contract and how a bridge would slot in. -### `POS-038` [P2] [Phase 6] Vendor adapter: SumUp (deferred) +### `POS-038` [P3] [Phase 6] Vendor adapter: SumUp (DEFERRED) - **Why:** SumUp is a likely first datáfono vendor. -- **Status:** P3 until operator picks SumUp. +- **Status:** **DEFERRED indefinitely** (operator decision 2026-08-21: stays manual). This ticket remains in the backlog as a placeholder; reopen only if the operator commits to SumUp. - **Scope IN:** native bridge process (`pos-sumup-bridge`) + `apps/pos/src/lib/hardware/sumup-payment-terminal-adapter.ts`. - **Touches:** new out-of-repo Node project (not in this codebase). - **Acceptance:** SumUp terminal charges the customer; auth code returns to POS; payment is recorded in `payments_transactions`. -### `POS-039` [P3] [Phase 6] Scale adapter +### `POS-039` [P3] [Phase 6] Scale adapter (DEFERRED) - **Why:** Some products are sold by weight. +- **Status:** **DEFERRED indefinitely** (operator decision 2026-08-21: no scale at first). Ticket remains as a placeholder; reopen only if a scale is procured. - **Scope IN:** Scale adapter interface (already in POS-034); browser manual entry in Phase 5; native bridge in Phase 7+. - **Scope OUT:** Vendor-specific bridges. - **Touches:** `apps/pos/src/lib/hardware/scale-adapter.ts`. diff --git a/project/apps/admin/next.config.ts b/project/apps/admin/next.config.ts index 2c1a130..df0ceb6 100644 --- a/project/apps/admin/next.config.ts +++ b/project/apps/admin/next.config.ts @@ -1,6 +1,12 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { + // Keep Turbopack rooted at this app. The repository also contains the + // legacy project/frontend/package-lock.json; without an explicit root, + // Next.js 16 may infer the wrong workspace during production builds. + turbopack: { + root: __dirname, + }, outputFileTracingRoot: __dirname, images: { remotePatterns: [ diff --git a/project/migrations/043_pos_basics.js b/project/migrations/043_pos_basics.js new file mode 100644 index 0000000..8cf83fc --- /dev/null +++ b/project/migrations/043_pos_basics.js @@ -0,0 +1,188 @@ +/** + * F-136 / POS-002 — POS basics: 6 new tables + seed. + * + * Creates the POS-specific tables. Idempotent (`IF NOT EXISTS` everywhere). + * + * pos_stores — physical storefronts (multi-store support) + * pos_terminals — TPV devices with persistent binding + * pos_payment_methods — configurable payment types per store + * pos_quick_products — favorite product grid per scope + * pos_parked_tickets — held tickets recoverable from any terminal in the store + * pos_cash_sessions — register shift lifecycle (OPEN/CLOSED) + * + * Seed at the end (after all tables exist): + * - One pos_stores row with the well-known UUID used by the default-store + * backfill in migration 044 and by the checkout flow's DEFAULT_STORE_ID. + * - Two pos_payment_methods rows (cash, card) for the default store. + * + * @param {import('node-pg-migrate').MigrationBuilder} pgm + */ + +/** Well-known UUID for the default store. Kept here as the source of truth. */ +const DEFAULT_STORE_ID = '00000000-0000-0000-0000-000000000001'; + +export const up = (pgm) => { + // ── pos_stores ──────────────────────────────────────────────────────────── + pgm.createTable('pos_stores', { + id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') }, + name: { type: 'text', notNull: true, check: "length(name) BETWEEN 1 AND 200" }, + slug: { type: 'text', notNull: true, unique: true, check: "slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$'" }, + address: { type: 'text' }, + tax_id: { type: 'text' }, + contact_email: { type: 'text' }, + contact_phone: { type: 'text' }, + receipt_header: { type: 'text' }, + receipt_footer: { type: 'text' }, + settings: { type: 'jsonb', notNull: true, default: pgm.func("'{}'::jsonb") }, + active: { type: 'boolean', notNull: true, default: true }, + created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') }, + updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') }, + }); + + // ── pos_terminals ───────────────────────────────────────────────────────── + pgm.createTable('pos_terminals', { + id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') }, + store_id: { type: 'uuid', notNull: true, references: 'pos_stores(id)', onDelete: 'RESTRICT' }, + name: { type: 'text', notNull: true, check: "length(name) BETWEEN 1 AND 100" }, + binding_code: { type: 'text', unique: true }, + bound_at: { type: 'timestamptz' }, + status: { + type: 'text', + notNull: true, + default: 'active', + check: "status IN ('active','disabled','decommissioned')", + }, + interface_mode: { + type: 'text', + notNull: true, + default: 'auto', + check: "interface_mode IN ('desktop','touch','auto')", + }, + settings: { type: 'jsonb', notNull: true, default: pgm.func("'{}'::jsonb") }, + last_seen_at: { type: 'timestamptz' }, + created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') }, + updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') }, + }); + pgm.createIndex('pos_terminals', 'store_id', { name: 'pos_terminals_store_idx' }); + pgm.createIndex('pos_terminals', 'binding_code', { + name: 'pos_terminals_binding_code_idx', + where: 'binding_code IS NOT NULL', + }); + + // ── pos_payment_methods ─────────────────────────────────────────────────── + pgm.createTable('pos_payment_methods', { + id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') }, + store_id: { type: 'uuid', notNull: true, references: 'pos_stores(id)', onDelete: 'CASCADE' }, + code: { type: 'text', notNull: true, check: "length(code) BETWEEN 1 AND 32" }, + label: { type: 'text', notNull: true, check: "length(label) BETWEEN 1 AND 64" }, + kind: { type: 'text', notNull: true, check: "kind IN ('cash','card','other')" }, + active: { type: 'boolean', notNull: true, default: true }, + sort_order: { type: 'integer', notNull: true, default: 0 }, + config: { type: 'jsonb', notNull: true, default: pgm.func("'{}'::jsonb") }, + created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') }, + updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') }, + }, { constraints: { unique: ['store_id', 'code'] } }); + pgm.createIndex('pos_payment_methods', ['store_id', 'sort_order'], { + name: 'pos_payment_methods_store_active_idx', + where: 'active', + }); + + // ── pos_quick_products ──────────────────────────────────────────────────── + pgm.createTable('pos_quick_products', { + id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') }, + store_id: { type: 'uuid', notNull: true, references: 'pos_stores(id)', onDelete: 'CASCADE' }, + terminal_id: { type: 'uuid', references: 'pos_terminals(id)', onDelete: 'CASCADE' }, + user_id: { type: 'uuid', references: 'backoffice_users(id)', onDelete: 'CASCADE' }, + variant_id: { type: 'uuid', notNull: true, references: 'catalog_product_variants(id)', onDelete: 'CASCADE' }, + position: { type: 'integer', notNull: true, default: 0 }, + active: { type: 'boolean', notNull: true, default: true }, + created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') }, + updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') }, + }); + + // ── pos_parked_tickets ─────────────────────────────────────────────────── + pgm.createTable('pos_parked_tickets', { + id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') }, + store_id: { type: 'uuid', notNull: true, references: 'pos_stores(id)', onDelete: 'CASCADE' }, + terminal_id: { type: 'uuid', notNull: true, references: 'pos_terminals(id)', onDelete: 'RESTRICT' }, + user_id: { type: 'uuid', notNull: true, references: 'backoffice_users(id)', onDelete: 'RESTRICT' }, + customer_id: { type: 'uuid', references: 'identity_users(id)', onDelete: 'SET NULL' }, + label: { type: 'text' }, + cart: { type: 'jsonb', notNull: true }, + expires_at: { + type: 'timestamptz', + notNull: true, + default: pgm.func("(now() + interval '24 hours')"), + }, + recalled_at: { type: 'timestamptz' }, + created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') }, + updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') }, + }); + pgm.createIndex('pos_parked_tickets', ['store_id', { name: 'created_at', sort: 'DESC' }], { + name: 'pos_parked_tickets_store_active_idx', + where: 'recalled_at IS NULL', + }); + + // ── pos_cash_sessions ──────────────────────────────────────────────────── + pgm.createTable('pos_cash_sessions', { + id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') }, + terminal_id: { type: 'uuid', notNull: true, references: 'pos_terminals(id)', onDelete: 'RESTRICT' }, + store_id: { type: 'uuid', notNull: true, references: 'pos_stores(id)', onDelete: 'RESTRICT' }, + user_id: { type: 'uuid', notNull: true, references: 'backoffice_users(id)', onDelete: 'RESTRICT' }, + status: { type: 'text', notNull: true, default: 'OPEN', check: "status IN ('OPEN','CLOSED')" }, + opened_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') }, + closed_at: { type: 'timestamptz' }, + opening_cash_cents: { type: 'integer', notNull: true, default: 0, check: 'opening_cash_cents >= 0' }, + closing_cash_cents: { type: 'integer' }, + expected_cash_cents: { type: 'integer' }, + actual_cash_cents: { type: 'integer' }, + difference_cents: { type: 'integer' }, + notes: { type: 'text' }, + created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') }, + updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') }, + }); + // Only one OPEN session per terminal at a time. + pgm.createIndex('pos_cash_sessions', 'terminal_id', { + name: 'pos_cash_sessions_open_per_terminal_idx', + unique: true, + where: "status = 'OPEN'", + }); + pgm.createIndex('pos_cash_sessions', 'store_id', { name: 'pos_cash_sessions_store_idx' }); + pgm.createIndex('pos_cash_sessions', 'user_id', { name: 'pos_cash_sessions_user_idx' }); + + // ── Seeds ──────────────────────────────────────────────────────────────── + // The default store; this UUID is also referenced as DEFAULT_STORE_ID from + // the inventory module (see 044_pos_inventory_store.js and + // project/src/modules/inventory/domain/ports.ts). + pgm.sql(` + INSERT INTO pos_stores (id, name, slug, active, receipt_header, receipt_footer) + VALUES ( + '${DEFAULT_STORE_ID}', + 'Natural - Mercado de Vida', + 'natural-mercado-de-vida', + true, + 'Natural - Mercado de Vida', + 'Gracias por su compra' + ) + ON CONFLICT (id) DO NOTHING + `); + + pgm.sql(` + INSERT INTO pos_payment_methods (store_id, code, label, kind, sort_order) + VALUES + ('${DEFAULT_STORE_ID}', 'cash', 'Efectivo', 'cash', 0), + ('${DEFAULT_STORE_ID}', 'card', 'Tarjeta', 'card', 1) + ON CONFLICT (store_id, code) DO NOTHING + `); +}; + +export const down = (pgm) => { + // Drop in reverse dependency order. The CASCADE on FKs cleans up dependent + // rows automatically. + pgm.dropTable('pos_cash_sessions'); + pgm.dropTable('pos_parked_tickets'); + pgm.dropTable('pos_quick_products'); + pgm.dropTable('pos_payment_methods'); + pgm.dropTable('pos_terminals'); + pgm.dropTable('pos_stores'); +}; diff --git a/project/migrations/044_pos_inventory_store.js b/project/migrations/044_pos_inventory_store.js new file mode 100644 index 0000000..ea0856d --- /dev/null +++ b/project/migrations/044_pos_inventory_store.js @@ -0,0 +1,112 @@ +/** + * POS-002 — Add `store_id` to `inventory_stock` and `inventory_movements`. + * + * Steps in `up()`: + * 1. Add `store_id` as a NULLABLE column (so the backfill doesn't fail). + * 2. Backfill every existing row with the well-known default store UUID. + * 3. Make `store_id` NOT NULL and add the FK to `pos_stores`. + * 4. Drop the old single-column unique constraint on `inventory_stock.variant_id`. + * 5. Add the new composite unique constraint `(variant_id, store_id)`. + * 6. Mirror the same 5-step pattern for `inventory_movements` (without the + * unique constraint, since movements don't have a UNIQUE invariant). + * + * Idempotent: every ALTER uses `IF NOT EXISTS` / `IF EXISTS` / `DO` blocks + * so re-running is a no-op. + * + * @param {import('node-pg-migrate').MigrationBuilder} pgm + */ + +const DEFAULT_STORE_ID = '00000000-0000-0000-0000-000000000001'; + +export const up = async (pgm) => { + // ── inventory_stock ─────────────────────────────────────────────────────── + pgm.sql(` + ALTER TABLE inventory_stock + ADD COLUMN IF NOT EXISTS store_id uuid + `); + pgm.sql(` + UPDATE inventory_stock + SET store_id = '${DEFAULT_STORE_ID}' + WHERE store_id IS NULL + `); + pgm.sql(` + ALTER TABLE inventory_stock + ALTER COLUMN store_id SET NOT NULL + `); + pgm.sql(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'inventory_stock_store_id_fkey' + ) THEN + ALTER TABLE inventory_stock + ADD CONSTRAINT inventory_stock_store_id_fkey + FOREIGN KEY (store_id) REFERENCES pos_stores(id) ON DELETE RESTRICT; + END IF; + END $$ + `); + pgm.sql(`ALTER TABLE inventory_stock DROP CONSTRAINT IF EXISTS inventory_stock_variant_id_key`); + pgm.sql(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'inventory_stock_variant_store_unique' + ) THEN + ALTER TABLE inventory_stock + ADD CONSTRAINT inventory_stock_variant_store_unique + UNIQUE (variant_id, store_id); + END IF; + END $$ + `); + + // ── inventory_movements ────────────────────────────────────────────────── + pgm.sql(` + ALTER TABLE inventory_movements + ADD COLUMN IF NOT EXISTS store_id uuid + `); + pgm.sql(` + UPDATE inventory_movements + SET store_id = '${DEFAULT_STORE_ID}' + WHERE store_id IS NULL + `); + pgm.sql(` + ALTER TABLE inventory_movements + ALTER COLUMN store_id SET NOT NULL + `); + pgm.sql(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'inventory_movements_store_id_fkey' + ) THEN + ALTER TABLE inventory_movements + ADD CONSTRAINT inventory_movements_store_id_fkey + FOREIGN KEY (store_id) REFERENCES pos_stores(id) ON DELETE RESTRICT; + END IF; + END $$ + `); +}; + +export const down = (pgm) => { + pgm.sql(`ALTER TABLE inventory_movements DROP CONSTRAINT IF EXISTS inventory_movements_store_id_fkey`); + pgm.sql(`ALTER TABLE inventory_movements DROP COLUMN IF EXISTS store_id`); + + pgm.sql(`ALTER TABLE inventory_stock DROP CONSTRAINT IF EXISTS inventory_stock_variant_store_unique`); + pgm.sql(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'inventory_stock_variant_id_key' + ) THEN + ALTER TABLE inventory_stock + ADD CONSTRAINT inventory_stock_variant_id_key UNIQUE (variant_id); + END IF; + END $$ + `); + pgm.sql(`ALTER TABLE inventory_stock DROP CONSTRAINT IF EXISTS inventory_stock_store_id_fkey`); + pgm.sql(`ALTER TABLE inventory_stock DROP COLUMN IF EXISTS store_id`); +}; diff --git a/project/migrations/045_pos_orders_source.js b/project/migrations/045_pos_orders_source.js new file mode 100644 index 0000000..804ed0a --- /dev/null +++ b/project/migrations/045_pos_orders_source.js @@ -0,0 +1,149 @@ +/** + * POS-002 — Add `source`, `terminal_id`, `cash_session_id` to `orders_orders`; + * drop NOT NULL on `user_id`; add `COMPLETED` to the state CHECK. + * + * The `source` column distinguishes ecommerce / pos / admin sales. POS sales + * may have `user_id IS NULL` (walk-in customer); ecommerce and admin sales + * must have a non-null `user_id` (enforced by the application layer; this + * migration only relaxes the column). + * + * The `terminal_id` and `cash_session_id` FKs use `ON DELETE SET NULL` so + * decommissioning a terminal or closing its session does not delete + * historical sales. + * + * The state CHECK is rewritten with the new `COMPLETED` value. POS sales + * transition PENDING → AWAITING_PAYMENT → PAID → COMPLETED, skipping + * SHIPPED / DELIVERED. + * + * @param {import('node-pg-migrate').MigrationBuilder} pgm + */ + +export const up = (pgm) => { + // ── Add columns ────────────────────────────────────────────────────────── + pgm.sql(` + ALTER TABLE orders_orders + ADD COLUMN IF NOT EXISTS source text NOT NULL DEFAULT 'ecommerce' + `); + pgm.sql(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'orders_orders_source_check' + ) THEN + ALTER TABLE orders_orders + ADD CONSTRAINT orders_orders_source_check + CHECK (source IN ('ecommerce','pos','admin')); + END IF; + END $$ + `); + + pgm.sql(`ALTER TABLE orders_orders ADD COLUMN IF NOT EXISTS terminal_id uuid`); + pgm.sql(`ALTER TABLE orders_orders ADD COLUMN IF NOT EXISTS cash_session_id uuid`); + + pgm.sql(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'orders_orders_terminal_id_fkey' + ) THEN + ALTER TABLE orders_orders + ADD CONSTRAINT orders_orders_terminal_id_fkey + FOREIGN KEY (terminal_id) REFERENCES pos_terminals(id) ON DELETE SET NULL; + END IF; + END $$ + `); + pgm.sql(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'orders_orders_cash_session_id_fkey' + ) THEN + ALTER TABLE orders_orders + ADD CONSTRAINT orders_orders_cash_session_id_fkey + FOREIGN KEY (cash_session_id) REFERENCES pos_cash_sessions(id) ON DELETE SET NULL; + END IF; + END $$ + `); + + // ── Indexes ────────────────────────────────────────────────────────────── + pgm.createIndex('orders_orders', 'source', { name: 'orders_orders_source_idx' }); + pgm.createIndex('orders_orders', 'terminal_id', { + name: 'orders_orders_terminal_idx', + where: 'terminal_id IS NOT NULL', + }); + pgm.createIndex('orders_orders', 'cash_session_id', { + name: 'orders_orders_cash_session_idx', + where: 'cash_session_id IS NOT NULL', + }); + + // ── Walk-in customers (POS only) ───────────────────────────────────────── + pgm.sql(`ALTER TABLE orders_orders ALTER COLUMN user_id DROP NOT NULL`); + + // CHECK: walk-in customers (user_id NULL) are allowed only for POS sales. + // Ecommerce and admin sales must have a non-null user_id. DB-level invariant + // enforced in addition to the application-level check. + pgm.sql(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'orders_orders_user_required_when_not_pos' + ) THEN + ALTER TABLE orders_orders + ADD CONSTRAINT orders_orders_user_required_when_not_pos + CHECK (source = 'pos' OR user_id IS NOT NULL); + END IF; + END $$ + `); + + // ── Add COMPLETED to state CHECK ───────────────────────────────────────── + pgm.sql(`ALTER TABLE orders_orders DROP CONSTRAINT IF EXISTS orders_orders_state_check`); + pgm.sql(` + ALTER TABLE orders_orders + ADD CONSTRAINT orders_orders_state_check + CHECK (state IN ( + 'PENDING', + 'AWAITING_PAYMENT', + 'PAID', + 'PROCESSING', + 'SHIPPED', + 'DELIVERED', + 'COMPLETED', + 'CANCELLED', + 'REFUNDED', + 'PARTIALLY_REFUNDED' + )) + `); +}; + +export const down = (pgm) => { + // Re-tighten state CHECK to the pre-COMPLETED set; this will fail if any + // COMPLETED rows exist. + pgm.sql(`ALTER TABLE orders_orders DROP CONSTRAINT IF EXISTS orders_orders_state_check`); + pgm.sql(` + ALTER TABLE orders_orders + ADD CONSTRAINT orders_orders_state_check + CHECK (state IN ( + 'PENDING','AWAITING_PAYMENT','PAID','PROCESSING', + 'SHIPPED','DELIVERED','CANCELLED','REFUNDED','PARTIALLY_REFUNDED' + )) + `); + + // user_id becomes NOT NULL again; fails if any NULL rows exist. + pgm.sql(`ALTER TABLE orders_orders DROP CONSTRAINT IF EXISTS orders_orders_user_required_when_not_pos`); + pgm.sql(`ALTER TABLE orders_orders ALTER COLUMN user_id SET NOT NULL`); + + pgm.sql(`DROP INDEX IF EXISTS orders_orders_cash_session_idx`); + pgm.sql(`DROP INDEX IF EXISTS orders_orders_terminal_idx`); + pgm.sql(`DROP INDEX IF EXISTS orders_orders_source_idx`); + + pgm.sql(`ALTER TABLE orders_orders DROP CONSTRAINT IF EXISTS orders_orders_cash_session_id_fkey`); + pgm.sql(`ALTER TABLE orders_orders DROP CONSTRAINT IF EXISTS orders_orders_terminal_id_fkey`); + pgm.sql(`ALTER TABLE orders_orders DROP COLUMN IF EXISTS cash_session_id`); + pgm.sql(`ALTER TABLE orders_orders DROP COLUMN IF EXISTS terminal_id`); + pgm.sql(`ALTER TABLE orders_orders DROP CONSTRAINT IF EXISTS orders_orders_source_check`); + pgm.sql(`ALTER TABLE orders_orders DROP COLUMN IF EXISTS source`); +}; diff --git a/project/migrations/046_pos_backoffice_roles.js b/project/migrations/046_pos_backoffice_roles.js new file mode 100644 index 0000000..bbda713 --- /dev/null +++ b/project/migrations/046_pos_backoffice_roles.js @@ -0,0 +1,45 @@ +/** + * POS-002 — Extend `backoffice_users.role` CHECK to include POS roles. + * + * New roles: `pos_cashier`, `pos_manager`. The application-side enum + * (`Role` in `project/src/shared/auth.ts`) is updated in the same ticket. + * + * Idempotent: `IF EXISTS` on the old constraint + `IF NOT EXISTS` guard on + * the new one. + * + * @param {import('node-pg-migrate').MigrationBuilder} pgm + */ + +export const up = (pgm) => { + pgm.sql(`ALTER TABLE backoffice_users DROP CONSTRAINT IF EXISTS backoffice_users_role_check`); + pgm.sql(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'backoffice_users_role_check' + ) THEN + ALTER TABLE backoffice_users + ADD CONSTRAINT backoffice_users_role_check + CHECK (role IN ('admin','editor','pos_cashier','pos_manager')); + END IF; + END $$ + `); +}; + +export const down = (pgm) => { + pgm.sql(`ALTER TABLE backoffice_users DROP CONSTRAINT IF EXISTS backoffice_users_role_check`); + pgm.sql(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'backoffice_users_role_check' + ) THEN + ALTER TABLE backoffice_users + ADD CONSTRAINT backoffice_users_role_check + CHECK (role IN ('admin','editor')); + END IF; + END $$ + `); +}; diff --git a/project/migrations/047_orders_user_required_when_not_pos.js b/project/migrations/047_orders_user_required_when_not_pos.js new file mode 100644 index 0000000..76b6d9e --- /dev/null +++ b/project/migrations/047_orders_user_required_when_not_pos.js @@ -0,0 +1,34 @@ +/** + * POS-002 catch-up — Add the walk-in CHECK constraint to existing DBs. + * + * Migration 045 drops NOT NULL on `orders_orders.user_id` (so POS walk-in + * sales work) but originally forgot the matching CHECK: ecommerce and admin + * sales must still have a non-null user_id. This migration adds the CHECK + * for DBs that already ran 045 before the fix landed. + * + * Idempotent: the constraint is added via `DO $$ IF NOT EXISTS ...`, + * so re-running on a fresh DB that already has the CHECK (via 045) is a + * no-op. + * + * @param {import('node-pg-migrate').MigrationBuilder} pgm + */ + +export const up = (pgm) => { + pgm.sql(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'orders_orders_user_required_when_not_pos' + ) THEN + ALTER TABLE orders_orders + ADD CONSTRAINT orders_orders_user_required_when_not_pos + CHECK (source = 'pos' OR user_id IS NOT NULL); + END IF; + END $$ + `); +}; + +export const down = (pgm) => { + pgm.sql(`ALTER TABLE orders_orders DROP CONSTRAINT IF EXISTS orders_orders_user_required_when_not_pos`); +}; diff --git a/project/src/app/tests/inventory.itest.ts b/project/src/app/tests/inventory.itest.ts index 94d1e29..12b3460 100644 --- a/project/src/app/tests/inventory.itest.ts +++ b/project/src/app/tests/inventory.itest.ts @@ -9,6 +9,7 @@ import { } from '../../infrastructure/db/tests/db-test-support.js'; import { createInventoryService, + DEFAULT_STORE_ID, InsufficientStockError, type InventoryService, } from '../../modules/inventory/index.js'; @@ -33,10 +34,12 @@ describe.skipIf(!hasDb)('inventory flows (real PostgreSQL)', () => { it('allows exactly one concurrent reservation for the last unit (AC1)', async () => { const variantId = randomUUID(); - await inventory.setAvailable({ variantId, quantity: 1 }); + await inventory.setAvailable({ variantId, storeId: DEFAULT_STORE_ID, quantity: 1 }); const attempts = await Promise.allSettled( - Array.from({ length: 10 }, () => inventory.reserve({ variantId, quantity: 1 })), + Array.from({ length: 10 }, () => + inventory.reserve({ variantId, storeId: DEFAULT_STORE_ID, quantity: 1 }), + ), ); const fulfilled = attempts.filter((result) => result.status === 'fulfilled'); @@ -47,7 +50,7 @@ describe.skipIf(!hasDb)('inventory flows (real PostgreSQL)', () => { expect(result.reason).toBeInstanceOf(InsufficientStockError); } - const availability = await inventory.checkAvailability(variantId, 1); + const availability = await inventory.checkAvailability(variantId, DEFAULT_STORE_ID, 1); expect(availability).toEqual({ available: false, availableQuantity: 0 }); const row = await pool.query( 'SELECT available, reserved, sold, incoming FROM inventory_stock WHERE variant_id = $1', @@ -58,11 +61,11 @@ describe.skipIf(!hasDb)('inventory flows (real PostgreSQL)', () => { it('rejects zero-stock reservations and never makes stock negative (AC2)', async () => { const variantId = randomUUID(); - await inventory.setAvailable({ variantId, quantity: 0 }); + await inventory.setAvailable({ variantId, storeId: DEFAULT_STORE_ID, quantity: 0 }); - await expect(inventory.reserve({ variantId, quantity: 1 })).rejects.toBeInstanceOf( - InsufficientStockError, - ); + await expect( + inventory.reserve({ variantId, storeId: DEFAULT_STORE_ID, quantity: 1 }), + ).rejects.toBeInstanceOf(InsufficientStockError); const row = await pool.query( 'SELECT available, reserved, sold, incoming FROM inventory_stock WHERE variant_id = $1', @@ -73,9 +76,9 @@ describe.skipIf(!hasDb)('inventory flows (real PostgreSQL)', () => { it('exposes checkAvailability through the public InventoryService interface (AC4)', async () => { const variantId = randomUUID(); - await inventory.setAvailable({ variantId, quantity: 3 }); + await inventory.setAvailable({ variantId, storeId: DEFAULT_STORE_ID, quantity: 3 }); - await expect(inventory.checkAvailability(variantId, 2)).resolves.toEqual({ + await expect(inventory.checkAvailability(variantId, DEFAULT_STORE_ID, 2)).resolves.toEqual({ available: true, availableQuantity: 3, }); diff --git a/project/src/modules/cart/application/cart-service.ts b/project/src/modules/cart/application/cart-service.ts index bc68db4..cd48344 100644 --- a/project/src/modules/cart/application/cart-service.ts +++ b/project/src/modules/cart/application/cart-service.ts @@ -1,4 +1,4 @@ -import type { InventoryServicePort } from '../../inventory/index.js'; +import { DEFAULT_STORE_ID, type InventoryServicePort } from '../../inventory/index.js'; import type { PricingServicePort } from '../../pricing/index.js'; import type { PromotionServicePort } from '../../promotions/index.js'; import { InvalidCartQuantityError, InsufficientCartStockError } from '../domain/errors.js'; @@ -30,7 +30,7 @@ export class CartService { } private async assertStockAvailable(variantId: string, quantity: number): Promise { - const av = await this.inventory.checkAvailability(variantId, quantity); + const av = await this.inventory.checkAvailability(variantId, DEFAULT_STORE_ID, quantity); if (!av.available) { throw new InsufficientCartStockError(variantId, quantity, av.availableQuantity); } @@ -59,7 +59,7 @@ export class CartService { if (error instanceof Error && error.name === 'PriceNotFoundError') return null; throw error; }), - this.inventory.checkAvailability(item.variantId, item.quantity), + this.inventory.checkAvailability(item.variantId, DEFAULT_STORE_ID, item.quantity), ]); return { ...item, diff --git a/project/src/modules/checkout/application/checkout-service.ts b/project/src/modules/checkout/application/checkout-service.ts index 7a0e6f7..4234f6d 100644 --- a/project/src/modules/checkout/application/checkout-service.ts +++ b/project/src/modules/checkout/application/checkout-service.ts @@ -1,4 +1,4 @@ -import type { InventoryServicePort } from '../../inventory/index.js'; +import { DEFAULT_STORE_ID, type InventoryServicePort } from '../../inventory/index.js'; import type { OrderItemInput, OrderServicePort } from '../../orders/index.js'; import type { PricingServicePort } from '../../pricing/index.js'; import type { PromotionServicePort } from '../../promotions/index.js'; @@ -97,6 +97,7 @@ export class CheckoutService { } const availability = await this.deps.inventory.checkAvailability( cartItem.variantId, + DEFAULT_STORE_ID, cartItem.quantity, ); if (!availability.available) { @@ -174,12 +175,18 @@ export class CheckoutService { const reserved: string[] = []; try { for (const item of cart.items) { - await this.deps.inventory.reserve({ variantId: item.variantId, quantity: item.quantity }); + await this.deps.inventory.reserve({ + variantId: item.variantId, + storeId: DEFAULT_STORE_ID, + quantity: item.quantity, + }); reserved.push(item.variantId); } } catch (error) { for (const variantId of reserved) { - await this.deps.inventory.release({ variantId, quantity: 1 }).catch(() => undefined); + await this.deps.inventory + .release({ variantId, storeId: DEFAULT_STORE_ID, quantity: 1 }) + .catch(() => undefined); } await this.deps.orders .transition(orderView.id, 'CANCELLED', command.userId) diff --git a/project/src/modules/checkout/tests/checkout-service.test.ts b/project/src/modules/checkout/tests/checkout-service.test.ts index 6b63221..55a5068 100644 --- a/project/src/modules/checkout/tests/checkout-service.test.ts +++ b/project/src/modules/checkout/tests/checkout-service.test.ts @@ -211,7 +211,13 @@ describe('CheckoutService', () => { idempotencyKey: 'k-1', }); expect(result.order.state).toBe('AWAITING_PAYMENT'); - expect(deps.reserved.calls).toEqual([{ variantId: 'v-1', quantity: 1 }]); + expect(deps.reserved.calls).toEqual([ + { + variantId: 'v-1', + storeId: '00000000-0000-0000-0000-000000000001', + quantity: 1, + }, + ]); expect(deps.metrics.success).toBe(1); }); diff --git a/project/src/modules/inventory/api/inventory.routes.ts b/project/src/modules/inventory/api/inventory.routes.ts index 0c4b389..e8601d3 100644 --- a/project/src/modules/inventory/api/inventory.routes.ts +++ b/project/src/modules/inventory/api/inventory.routes.ts @@ -13,6 +13,7 @@ import { InvalidStockQuantityError, } from '../domain/errors.js'; import type { SetAvailableStockCommand, StockItem } from '../domain/stock.js'; +import { DEFAULT_STORE_ID } from '../domain/stock.js'; import { PgInventoryRepository } from '../infrastructure/pg-inventory-repository.js'; export interface InventoryRoutesDeps { @@ -23,9 +24,16 @@ export interface InventoryRoutesDeps { const variantParamSchema = z.object({ variantId: z.uuid() }); const availabilityQuerySchema = z.object({ quantity: z.coerce.number().int().positive().default(1), + storeId: z.uuid().optional(), +}); +const stockBodySchema = z.object({ + quantity: z.number().int().min(0), + storeId: z.uuid().optional(), +}); +const stockCommandBodySchema = z.object({ + quantity: z.number().int().positive(), + storeId: z.uuid().optional(), }); -const stockBodySchema = z.object({ quantity: z.number().int().min(0) }); -const stockCommandBodySchema = z.object({ quantity: z.number().int().positive() }); const bulkAdjustItemSchema = z.object({ variantId: z.uuid(), @@ -49,15 +57,25 @@ export async function registerInventoryRoutes( required: ['variantId'], properties: { variantId: { type: 'string', format: 'uuid' } }, }, - querystring: { type: 'object', properties: { quantity: { type: 'integer', default: 1 } } }, + querystring: { + type: 'object', + properties: { + quantity: { type: 'integer', default: 1 }, + storeId: { type: 'string', format: 'uuid' }, + }, + }, }; app.get( '/inventory/:variantId/availability', { schema: availabilitySchema }, async (request, reply) => { const { variantId } = parseJson(variantParamSchema, request.params); - const { quantity } = parseJson(availabilityQuerySchema, request.query); - const availability = await inventory.checkAvailability(variantId, quantity); + const { quantity, storeId } = parseJson(availabilityQuerySchema, request.query); + const availability = await inventory.checkAvailability( + variantId, + storeId ?? DEFAULT_STORE_ID, + quantity, + ); return reply.send(availability); }, ); @@ -81,9 +99,13 @@ export async function registerInventoryRoutes( const user = await deps.authenticate(request); requireRole(user, 'admin'); const { variantId } = parseJson(variantParamSchema, request.params); - const { quantity } = parseJson(stockBodySchema, request.body); + const { quantity, storeId } = parseJson(stockBodySchema, request.body); try { - const item = await inventory.setAvailable({ variantId, quantity }); + const item = await inventory.setAvailable({ + variantId, + storeId: storeId ?? DEFAULT_STORE_ID, + quantity, + }); return reply.send(serializeStockItem(item)); } catch (error) { throw mapInventoryError(error); @@ -112,9 +134,13 @@ export async function registerInventoryRoutes( const user = await deps.authenticate(request); requireRole(user, 'admin'); const { variantId } = parseJson(variantParamSchema, request.params); - const { quantity } = parseJson(stockCommandBodySchema, request.body); + const { quantity, storeId } = parseJson(stockCommandBodySchema, request.body); try { - const item = await inventory.reserve({ variantId, quantity }); + const item = await inventory.reserve({ + variantId, + storeId: storeId ?? DEFAULT_STORE_ID, + quantity, + }); return reply.code(201).send(serializeStockItem(item)); } catch (error) { throw mapInventoryError(error); @@ -144,9 +170,13 @@ export async function registerInventoryRoutes( const user = await deps.authenticate(request); requireRole(user, 'admin'); const { variantId } = parseJson(variantParamSchema, request.params); - const { quantity } = parseJson(stockCommandBodySchema, request.body); + const { quantity, storeId } = parseJson(stockCommandBodySchema, request.body); try { - const item = await inventory.release({ variantId, quantity }); + const item = await inventory.release({ + variantId, + storeId: storeId ?? DEFAULT_STORE_ID, + quantity, + }); return reply.send(serializeStockItem(item)); } catch (error) { throw mapInventoryError(error); @@ -176,9 +206,13 @@ export async function registerInventoryRoutes( const user = await deps.authenticate(request); requireRole(user, 'admin'); const { variantId } = parseJson(variantParamSchema, request.params); - const { quantity } = parseJson(stockCommandBodySchema, request.body); + const { quantity, storeId } = parseJson(stockCommandBodySchema, request.body); try { - const item = await inventory.confirm({ variantId, quantity }); + const item = await inventory.confirm({ + variantId, + storeId: storeId ?? DEFAULT_STORE_ID, + quantity, + }); return reply.send(serializeStockItem(item)); } catch (error) { throw mapInventoryError(error); @@ -223,6 +257,7 @@ export async function registerInventoryRoutes( for (const item of items) { const command: SetAvailableStockCommand = { variantId: item.variantId, + storeId: DEFAULT_STORE_ID, quantity: item.quantity, }; @@ -230,6 +265,7 @@ export async function registerInventoryRoutes( const result = await client.query<{ id: string; variant_id: string; + store_id: string; available: number; reserved: number; sold: number; @@ -237,19 +273,19 @@ export async function registerInventoryRoutes( created_at: Date; updated_at: Date; }>( - `INSERT INTO inventory_stock (variant_id, available) - VALUES ($1, $2) - ON CONFLICT (variant_id) DO UPDATE + `INSERT INTO inventory_stock (variant_id, store_id, available) + VALUES ($1, $2, $3) + ON CONFLICT (variant_id, store_id) DO UPDATE SET available = EXCLUDED.available, updated_at = now() RETURNING *`, - [command.variantId, command.quantity], + [command.variantId, command.storeId, command.quantity], ); // Log the movement await client.query( - `INSERT INTO inventory_movements (variant_id, operation, quantity) - VALUES ($1, $2, $3)`, - [command.variantId, 'bulk_adjust', command.quantity], + `INSERT INTO inventory_movements (variant_id, store_id, operation, quantity) + VALUES ($1, $2, $3, $4)`, + [command.variantId, command.storeId, 'bulk_adjust', command.quantity], ); const row = result.rows[0]; @@ -259,6 +295,7 @@ export async function registerInventoryRoutes( results.push({ id: row.id, variantId: row.variant_id, + storeId: row.store_id, available: row.available, reserved: row.reserved, sold: row.sold, diff --git a/project/src/modules/inventory/application/inventory-service.ts b/project/src/modules/inventory/application/inventory-service.ts index 7fe4749..341e49b 100644 --- a/project/src/modules/inventory/application/inventory-service.ts +++ b/project/src/modules/inventory/application/inventory-service.ts @@ -17,9 +17,9 @@ import type { export class InventoryService implements InventoryServicePort { constructor(private readonly repository: InventoryRepository) {} - async checkAvailability(variantId: string, quantity: number): Promise { + async checkAvailability(variantId: string, storeId: string, quantity: number): Promise { ensurePositiveQuantity(quantity); - const item = await this.repository.findByVariantId(variantId); + const item = await this.repository.findByVariantId(variantId, storeId); const availableQuantity = item?.available ?? 0; return { available: availableQuantity >= quantity, availableQuantity }; } diff --git a/project/src/modules/inventory/domain/ports.ts b/project/src/modules/inventory/domain/ports.ts index 1902bfa..6a61489 100644 --- a/project/src/modules/inventory/domain/ports.ts +++ b/project/src/modules/inventory/domain/ports.ts @@ -1,7 +1,7 @@ import type { Availability, SetAvailableStockCommand, StockCommand, StockItem } from './stock.js'; export interface InventoryService { - checkAvailability(variantId: string, quantity: number): Promise; + checkAvailability(variantId: string, storeId: string, quantity: number): Promise; reserve(input: StockCommand): Promise; release(input: StockCommand): Promise; confirm(input: StockCommand): Promise; @@ -9,7 +9,7 @@ export interface InventoryService { } export interface InventoryRepository { - findByVariantId(variantId: string): Promise; + findByVariantId(variantId: string, storeId: string): Promise; setAvailable(input: SetAvailableStockCommand): Promise; reserve(input: StockCommand): Promise; release(input: StockCommand): Promise; diff --git a/project/src/modules/inventory/domain/stock.ts b/project/src/modules/inventory/domain/stock.ts index 7d551fe..3f594e1 100644 --- a/project/src/modules/inventory/domain/stock.ts +++ b/project/src/modules/inventory/domain/stock.ts @@ -1,8 +1,16 @@ export type StockState = 'available' | 'reserved' | 'sold' | 'incoming'; +/** + * Well-known UUID of the default store seeded by migration 043 and used by + * the legacy ecommerce flow. Multi-store terminals pass their own storeId; + * the ecommerce flow passes this constant. + */ +export const DEFAULT_STORE_ID = '00000000-0000-0000-0000-000000000001'; + export interface StockItem { id: string; variantId: string; + storeId: string; available: number; reserved: number; sold: number; @@ -13,6 +21,7 @@ export interface StockItem { export interface StockCommand { variantId: string; + storeId: string; quantity: number; } diff --git a/project/src/modules/inventory/index.ts b/project/src/modules/inventory/index.ts index ec8b53b..32666d0 100644 --- a/project/src/modules/inventory/index.ts +++ b/project/src/modules/inventory/index.ts @@ -15,6 +15,7 @@ export type { InventoryService as InventoryServicePort, } from './domain/ports.js'; export type { Availability, StockCommand, StockItem, StockState } from './domain/stock.js'; +export { DEFAULT_STORE_ID } from './domain/stock.js'; export function createInventoryService(pool: pg.Pool): InventoryService { return new InventoryService(new PgInventoryRepository(pool)); diff --git a/project/src/modules/inventory/infrastructure/pg-inventory-repository.ts b/project/src/modules/inventory/infrastructure/pg-inventory-repository.ts index 5b86867..5e3e305 100644 --- a/project/src/modules/inventory/infrastructure/pg-inventory-repository.ts +++ b/project/src/modules/inventory/infrastructure/pg-inventory-repository.ts @@ -5,6 +5,7 @@ import type { SetAvailableStockCommand, StockCommand, StockItem } from '../domai interface StockRow { id: string; variant_id: string; + store_id: string; available: number; reserved: number; sold: number; @@ -18,10 +19,10 @@ type InventoryOperation = 'reserve' | 'release' | 'confirm' | 'set_available'; export class PgInventoryRepository implements InventoryRepository { constructor(private readonly pool: pg.Pool) {} - async findByVariantId(variantId: string): Promise { + async findByVariantId(variantId: string, storeId: string): Promise { const result = await this.pool.query( - 'SELECT * FROM inventory_stock WHERE variant_id = $1', - [variantId], + 'SELECT * FROM inventory_stock WHERE variant_id = $1 AND store_id = $2', + [variantId, storeId], ); const row = result.rows[0]; return row ? toStockItem(row) : undefined; @@ -32,15 +33,15 @@ export class PgInventoryRepository implements InventoryRepository { try { await client.query('BEGIN'); const result = await client.query( - `INSERT INTO inventory_stock (variant_id, available) - VALUES ($1, $2) - ON CONFLICT (variant_id) DO UPDATE + `INSERT INTO inventory_stock (variant_id, store_id, available) + VALUES ($1, $2, $3) + ON CONFLICT (variant_id, store_id) DO UPDATE SET available = EXCLUDED.available, updated_at = now() RETURNING *`, - [input.variantId, input.quantity], + [input.variantId, input.storeId, input.quantity], ); const item = rowOrThrow(result.rows[0], 'inventory_stock upsert returned no row'); - await insertMovement(client, input.variantId, 'set_available', input.quantity); + await insertMovement(client, input.variantId, input.storeId, 'set_available', input.quantity); await client.query('COMMIT'); return item; } catch (error) { @@ -56,9 +57,9 @@ export class PgInventoryRepository implements InventoryRepository { client.query( `UPDATE inventory_stock SET available = available - $2, reserved = reserved + $2, updated_at = now() - WHERE variant_id = $1 AND available >= $2 + WHERE variant_id = $1 AND store_id = $3 AND available >= $2 RETURNING *`, - [command.variantId, command.quantity], + [command.variantId, command.quantity, command.storeId], ), ); } @@ -68,9 +69,9 @@ export class PgInventoryRepository implements InventoryRepository { client.query( `UPDATE inventory_stock SET reserved = reserved - $2, available = available + $2, updated_at = now() - WHERE variant_id = $1 AND reserved >= $2 + WHERE variant_id = $1 AND store_id = $3 AND reserved >= $2 RETURNING *`, - [command.variantId, command.quantity], + [command.variantId, command.quantity, command.storeId], ), ); } @@ -80,9 +81,9 @@ export class PgInventoryRepository implements InventoryRepository { client.query( `UPDATE inventory_stock SET reserved = reserved - $2, sold = sold + $2, updated_at = now() - WHERE variant_id = $1 AND reserved >= $2 + WHERE variant_id = $1 AND store_id = $3 AND reserved >= $2 RETURNING *`, - [command.variantId, command.quantity], + [command.variantId, command.quantity, command.storeId], ), ); } @@ -101,7 +102,7 @@ export class PgInventoryRepository implements InventoryRepository { await client.query('ROLLBACK'); return undefined; } - await insertMovement(client, input.variantId, operation, input.quantity); + await insertMovement(client, input.variantId, input.storeId, operation, input.quantity); await client.query('COMMIT'); return toStockItem(row); } catch (error) { @@ -116,13 +117,14 @@ export class PgInventoryRepository implements InventoryRepository { async function insertMovement( client: pg.PoolClient, variantId: string, + storeId: string, operation: InventoryOperation, quantity: number, ): Promise { await client.query( - `INSERT INTO inventory_movements (variant_id, operation, quantity) - VALUES ($1, $2, $3)`, - [variantId, operation, quantity], + `INSERT INTO inventory_movements (variant_id, store_id, operation, quantity) + VALUES ($1, $2, $3, $4)`, + [variantId, storeId, operation, quantity], ); } @@ -137,6 +139,7 @@ function toStockItem(row: StockRow): StockItem { return { id: row.id, variantId: row.variant_id, + storeId: row.store_id, available: row.available, reserved: row.reserved, sold: row.sold, diff --git a/project/src/modules/inventory/tests/inventory-service.test.ts b/project/src/modules/inventory/tests/inventory-service.test.ts index 14a23df..959ad27 100644 --- a/project/src/modules/inventory/tests/inventory-service.test.ts +++ b/project/src/modules/inventory/tests/inventory-service.test.ts @@ -8,9 +8,12 @@ import { import type { InventoryRepository } from '../domain/ports.js'; import type { SetAvailableStockCommand, StockCommand, StockItem } from '../domain/stock.js'; +const STORE_ID = '00000000-0000-0000-0000-000000000001'; + const STOCK: StockItem = { id: 'stock-1', variantId: 'variant-1', + storeId: STORE_ID, available: 1, reserved: 0, sold: 0, @@ -34,35 +37,35 @@ describe('InventoryService', () => { it('rejects non-positive reserve quantities before persistence', async () => { const service = new InventoryService(repository()); - await expect(service.reserve({ variantId: 'variant-1', quantity: 0 })).rejects.toBeInstanceOf( - InvalidStockQuantityError, - ); + await expect( + service.reserve({ variantId: 'variant-1', storeId: STORE_ID, quantity: 0 }), + ).rejects.toBeInstanceOf(InvalidStockQuantityError); }); it('maps failed reservation to insufficient stock', async () => { const service = new InventoryService(repository({ reserve: async () => undefined })); - await expect(service.reserve({ variantId: 'variant-1', quantity: 1 })).rejects.toBeInstanceOf( - InsufficientStockError, - ); + await expect( + service.reserve({ variantId: 'variant-1', storeId: STORE_ID, quantity: 1 }), + ).rejects.toBeInstanceOf(InsufficientStockError); }); it('maps failed release to insufficient reserved stock', async () => { const service = new InventoryService(repository({ release: async () => undefined })); - await expect(service.release({ variantId: 'variant-1', quantity: 1 })).rejects.toBeInstanceOf( - InsufficientReservedStockError, - ); + await expect( + service.release({ variantId: 'variant-1', storeId: STORE_ID, quantity: 1 }), + ).rejects.toBeInstanceOf(InsufficientReservedStockError); }); it('checks availability without mutating stock', async () => { const service = new InventoryService(repository()); - await expect(service.checkAvailability('variant-1', 1)).resolves.toEqual({ + await expect(service.checkAvailability('variant-1', STORE_ID, 1)).resolves.toEqual({ available: true, availableQuantity: 1, }); - await expect(service.checkAvailability('variant-1', 2)).resolves.toEqual({ + await expect(service.checkAvailability('variant-1', STORE_ID, 2)).resolves.toEqual({ available: false, availableQuantity: 1, }); diff --git a/project/src/modules/orders/domain/order.ts b/project/src/modules/orders/domain/order.ts index b9444c3..2986c20 100644 --- a/project/src/modules/orders/domain/order.ts +++ b/project/src/modules/orders/domain/order.ts @@ -5,6 +5,7 @@ export type OrderState = | 'PROCESSING' | 'SHIPPED' | 'DELIVERED' + | 'COMPLETED' | 'CANCELLED' | 'REFUNDED' | 'PARTIALLY_REFUNDED'; @@ -50,10 +51,11 @@ export interface OrderView extends Order { export const ALLOWED_TRANSITIONS: Readonly>> = { PENDING: ['AWAITING_PAYMENT', 'CANCELLED'], AWAITING_PAYMENT: ['PAID', 'CANCELLED'], - PAID: ['PROCESSING', 'SHIPPED', 'CANCELLED', 'REFUNDED'], - PROCESSING: ['PAID', 'SHIPPED', 'CANCELLED', 'REFUNDED'], + PAID: ['PROCESSING', 'SHIPPED', 'COMPLETED', 'CANCELLED', 'REFUNDED'], + PROCESSING: ['PAID', 'SHIPPED', 'COMPLETED', 'CANCELLED', 'REFUNDED'], SHIPPED: ['PROCESSING', 'DELIVERED', 'PARTIALLY_REFUNDED'], DELIVERED: ['SHIPPED', 'PARTIALLY_REFUNDED'], + COMPLETED: [], CANCELLED: [], REFUNDED: [], PARTIALLY_REFUNDED: [], diff --git a/project/src/shared/auth.ts b/project/src/shared/auth.ts index 9bfc380..04771c6 100644 --- a/project/src/shared/auth.ts +++ b/project/src/shared/auth.ts @@ -6,7 +6,7 @@ import type { FastifyRequest } from 'fastify'; import { AppError } from './errors.js'; -export type Role = 'customer' | 'admin' | 'editor'; +export type Role = 'customer' | 'admin' | 'editor' | 'pos_cashier' | 'pos_manager'; export interface CurrentUser { id: string; @@ -27,6 +27,15 @@ export function requireRole(user: CurrentUser, role: Role): void { } } +/** Throws AppError(403) unless the user holds at least one of the allowed roles. + * Use when an endpoint accepts multiple roles (e.g. POS-002 endpoints accept + * `pos_cashier`, `pos_manager`, and `admin`). */ +export function requireAnyRole(user: CurrentUser, roles: ReadonlyArray): void { + if (!roles.includes(user.role)) { + throw new AppError(403, 'FORBIDDEN', 'Access denied'); + } +} + /** Throws AppError(403) unless the user is the resource owner or an admin. */ export function requireOwnerOrAdmin(user: CurrentUser, ownerId: string): void { if (user.role !== 'admin' && user.id !== ownerId) { diff --git a/project/src/shared/tests/auth.test.ts b/project/src/shared/tests/auth.test.ts index c167973..ef685a5 100644 --- a/project/src/shared/tests/auth.test.ts +++ b/project/src/shared/tests/auth.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from 'vitest'; import { AppError } from '../errors.js'; -import { requireOwnerOrAdmin, requireRole, type CurrentUser } from '../auth.js'; +import { requireAnyRole, requireOwnerOrAdmin, requireRole, type CurrentUser } from '../auth.js'; const customer: CurrentUser = { id: 'user-a', email: 'a@example.com', role: 'customer' }; const admin: CurrentUser = { id: 'user-admin', email: 'admin@example.com', role: 'admin' }; +const posCashier: CurrentUser = { id: 'user-cashier', email: 'cashier@example.com', role: 'pos_cashier' }; +const posManager: CurrentUser = { id: 'user-manager', email: 'manager@example.com', role: 'pos_manager' }; function codeOf(fn: () => void): string | undefined { try { @@ -25,6 +27,23 @@ describe('requireRole', () => { }); }); +describe('requireAnyRole', () => { + it('allows a user holding any of the allowed roles', () => { + expect(() => requireAnyRole(admin, ['pos_cashier', 'pos_manager', 'admin'])).not.toThrow(); + expect(() => requireAnyRole(posCashier, ['pos_cashier', 'pos_manager'])).not.toThrow(); + expect(() => requireAnyRole(posManager, ['pos_cashier', 'pos_manager'])).not.toThrow(); + }); + + it('throws 403 FORBIDDEN when no role matches', () => { + expect(codeOf(() => requireAnyRole(customer, ['pos_cashier', 'pos_manager']))).toBe('FORBIDDEN'); + expect(codeOf(() => requireAnyRole(posCashier, ['pos_manager']))).toBe('FORBIDDEN'); + }); + + it('throws 403 FORBIDDEN for empty role list', () => { + expect(codeOf(() => requireAnyRole(admin, []))).toBe('FORBIDDEN'); + }); +}); + describe('requireOwnerOrAdmin', () => { it('allows the owner regardless of role', () => { expect(() => requireOwnerOrAdmin(customer, 'user-a')).not.toThrow(); diff --git a/work/artifacts/POS-002/architect.md b/work/artifacts/POS-002/architect.md new file mode 100644 index 0000000..5f0aa09 --- /dev/null +++ b/work/artifacts/POS-002/architect.md @@ -0,0 +1,180 @@ +# 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). diff --git a/work/artifacts/POS-002/implementer.md b/work/artifacts/POS-002/implementer.md new file mode 100644 index 0000000..91f7d13 --- /dev/null +++ b/work/artifacts/POS-002/implementer.md @@ -0,0 +1,92 @@ +# POS-002 — Implementer notes + +## Cambios + +### Migraciones + +- `project/migrations/043_pos_basics.js` + - Crea `pos_stores`, `pos_terminals`, `pos_payment_methods`, `pos_quick_products`, `pos_parked_tickets`, `pos_cash_sessions`. + - Seed idempotente de la tienda por defecto **Natural - Mercado de Vida** (`natural-mercado-de-vida`) y métodos `cash` / `card`. + - Índice parcial para impedir más de una sesión OPEN por terminal. +- `project/migrations/044_pos_inventory_store.js` + - Añade `store_id` a `inventory_stock` e `inventory_movements`. + - Backfill de las 14 filas de stock y 13 movimientos existentes a la tienda por defecto. + - Sustituye unicidad de `variant_id` por `(variant_id, store_id)`. +- `project/migrations/045_pos_orders_source.js` + - Añade `source`, `terminal_id`, `cash_session_id` a `orders_orders`. + - Añade estado `COMPLETED` al CHECK. + - Permite `user_id NULL` para walk-ins POS, pero añade CHECK `source = 'pos' OR user_id IS NOT NULL`. +- `project/migrations/046_pos_backoffice_roles.js` + - Añade roles `pos_cashier` y `pos_manager` al CHECK de `backoffice_users.role`. +- `project/migrations/047_orders_user_required_when_not_pos.js` + - Catch-up para bases que ya habían aplicado la versión inicial de 045 sin el CHECK de `user_id`. + +### Dominio/auth + +- `src/shared/auth.ts`: `Role` ahora incluye `pos_cashier` y `pos_manager`; nuevo `requireAnyRole()`. +- `src/shared/tests/auth.test.ts`: 8 tests, incluyendo roles POS y lista vacía. +- `src/modules/orders/domain/order.ts`: `OrderState.COMPLETED` y estado terminal sin transiciones. + +### Inventario multi-tienda + +- `StockItem` ahora expone `storeId`. +- `StockCommand` ahora exige `storeId`. +- `InventoryService.checkAvailability(variantId, storeId, quantity)`. +- Repositorio PostgreSQL filtra y actualiza por `(variant_id, store_id)`; los movimientos también guardan `store_id`. +- El flujo ecommerce/cart usa `DEFAULT_STORE_ID` para mantener compatibilidad con la tienda única actual. +- Endpoints inventory aceptan `storeId` opcional y usan la tienda por defecto si no se envía. +- Bulk-adjust actualizado a la clave compuesta y movimiento con store. +- Tests unitarios e integración actualizados. + +### Build admin + +- `project/apps/admin/next.config.ts`: `turbopack.root = __dirname` para evitar que Next.js 16 elija el workspace incorrecto por la presencia de múltiples lockfiles (`project/package-lock.json` + `project/frontend/package-lock.json`). +- Se verificó build limpio después de borrar `.next`; `/brands` y `/categories` prerenderizan correctamente. + +## Evidencia + +```text +Backend build: npm run build ✅ +Backend tests: 191 passed, 56 skipped ✅ +Backend typecheck: npx tsc --noEmit ✅ +Admin build limpio: npm run build ✅ +Admin typecheck: npx tsc --noEmit ✅ +Admin ESLint relevante ✅ +Migrations 043..047 aplicadas ✅ +verify.sh ✅ +``` + +Pruebas de constraint: + +```text +ecommerce + user_id NULL → CHECK violation ✅ +pos + user_id NULL → insert allowed ✅ +re-run migrations → No migrations to run ✅ +``` + +## Nota de build + +El error reportado de Next.js (`Export encountered an error on /brands/page`) era un fallo de prerender con el backend inaccesible y raíz de Turbopack ambigua. El problema se reprodujo en el entorno reportado, se limpió `.next`, se fijó `turbopack.root`, y el build limpio pasó sin `ECONNREFUSED`, sin error de prerender y sin warning de lockfiles. + +## Archivos modificados + +- `project/migrations/043_pos_basics.js` +- `project/migrations/044_pos_inventory_store.js` +- `project/migrations/045_pos_orders_source.js` +- `project/migrations/046_pos_backoffice_roles.js` +- `project/migrations/047_orders_user_required_when_not_pos.js` +- `project/src/shared/auth.ts` +- `project/src/shared/tests/auth.test.ts` +- `project/src/modules/orders/domain/order.ts` +- `project/src/modules/inventory/domain/stock.ts` +- `project/src/modules/inventory/domain/ports.ts` +- `project/src/modules/inventory/application/inventory-service.ts` +- `project/src/modules/inventory/infrastructure/pg-inventory-repository.ts` +- `project/src/modules/inventory/api/inventory.routes.ts` +- `project/src/modules/inventory/index.ts` +- `project/src/modules/inventory/tests/inventory-service.test.ts` +- `project/src/app/tests/inventory.itest.ts` +- `project/src/modules/cart/application/cart-service.ts` +- `project/src/modules/checkout/application/checkout-service.ts` +- `project/src/modules/checkout/tests/checkout-service.test.ts` +- `project/apps/admin/next.config.ts` diff --git a/work/artifacts/POS-002/leader-close.json b/work/artifacts/POS-002/leader-close.json new file mode 100644 index 0000000..59ed522 --- /dev/null +++ b/work/artifacts/POS-002/leader-close.json @@ -0,0 +1,44 @@ +{ + "feature_id": "POS-002", + "agent": "leader", + "verdict": "APPROVED", + "summary": "POS-002 cerrado: foundation de schema POS, inventario por tienda, source/COMPLETED de orders, roles POS, requireAnyRole y fix de build admin entregados.", + "checks": [ + "reviewer.json APPROVED", + "security.json APPROVED", + "qa.json APPROVED", + "implementer.md completo", + "Backend build exit 0", + "Backend tests 191 passed / 56 skipped", + "Admin clean build exit 0", + "verify.sh exit 0", + "CHECK ecommerce/admin user_id non-null probado en PostgreSQL", + "POS walk-in user_id NULL probado en PostgreSQL", + "Migrations 043-047 aplicadas e idempotencia verificada" + ], + "files": [ + "project/migrations/043_pos_basics.js", + "project/migrations/044_pos_inventory_store.js", + "project/migrations/045_pos_orders_source.js", + "project/migrations/046_pos_backoffice_roles.js", + "project/migrations/047_orders_user_required_when_not_pos.js", + "project/src/shared/auth.ts", + "project/src/shared/tests/auth.test.ts", + "project/src/modules/orders/domain/order.ts", + "project/src/modules/inventory/domain/stock.ts", + "project/src/modules/inventory/domain/ports.ts", + "project/src/modules/inventory/application/inventory-service.ts", + "project/src/modules/inventory/infrastructure/pg-inventory-repository.ts", + "project/src/modules/inventory/api/inventory.routes.ts", + "project/src/modules/inventory/index.ts", + "project/src/modules/inventory/tests/inventory-service.test.ts", + "project/src/app/tests/inventory.itest.ts", + "project/src/modules/cart/application/cart-service.ts", + "project/src/modules/checkout/application/checkout-service.ts", + "project/src/modules/checkout/tests/checkout-service.test.ts", + "project/apps/admin/next.config.ts" + ], + "commit_message": "feat(POS-002): add POS foundation and scoped inventory", + "next_step": "Promote POS-003: pos module skeleton (domain, repositories, use cases)", + "closed_at": "2026-08-21T19:56:30Z" +} diff --git a/work/artifacts/POS-002/qa.json b/work/artifacts/POS-002/qa.json new file mode 100644 index 0000000..a8fb06c --- /dev/null +++ b/work/artifacts/POS-002/qa.json @@ -0,0 +1,30 @@ +{ + "feature_id": "POS-002", + "agent": "qa", + "stage": "qa_gate", + "verdict": "APPROVED", + "reviewed_at": "2026-08-21T19:56:00Z", + "summary": "Todas las verificaciones de foundation POS pasan. El CHECK solicitado se prueba explícitamente en PostgreSQL. El error de prerender del admin no se reproduce tras limpiar .next y fijar turbopack.root.", + "acceptance_traceability": [ + {"criterion":"AC-1/AC-2: migrations apply on fresh/current DB","evidence":"node-pg-migrate up applied 043-047 on current DB; fresh migration path validated by backend build/test migration setup","ok":true}, + {"criterion":"AC-3/AC-4: default store and payment methods seeded","evidence":"pos_stores count=1; payment methods count=2; name Natural - Mercado de Vida","ok":true}, + {"criterion":"AC-5/AC-6: inventory backfill preserves rows","evidence":"14 inventory_stock rows and 13 inventory_movements rows remain; NULL store_id counts are 0","ok":true}, + {"criterion":"AC-7: composite inventory uniqueness","evidence":"inventory_stock unique constraint inventory_stock_variant_store_unique; repository uses ON CONFLICT (variant_id, store_id)","ok":true}, + {"criterion":"AC-8: ecommerce NULL customer rejected; POS walk-in allowed","evidence":"ecommerce NULL insert fails with orders_orders_user_required_when_not_pos; pos NULL insert succeeds","ok":true}, + {"criterion":"AC-9/AC-10: COMPLETED state accepted","evidence":"POS test insert with source=pos,state=COMPLETED succeeded; state CHECK includes COMPLETED","ok":true}, + {"criterion":"AC-11/AC-12: POS roles accepted and invalid roles rejected","evidence":"backoffice_users role CHECK includes pos_cashier/pos_manager and excludes pos_owner","ok":true}, + {"criterion":"AC-13/AC-14: requireAnyRole behavior","evidence":"src/shared/tests/auth.test.ts: 8 tests passed, including allowed roles, rejected roles, empty list","ok":true}, + {"criterion":"AC-15: inventory storeId API and service behavior","evidence":"backend typecheck/build pass; inventory unit tests pass; integration test updated for DEFAULT_STORE_ID","ok":true}, + {"criterion":"AC-16: verify.sh green","evidence":"./scripts/verify.sh → Orquestra verificado. Puedes trabajar.","ok":true}, + {"criterion":"AC-17: migration re-run no-op","evidence":"second node-pg-migrate up → No migrations to run!","ok":true} + ], + "checks": [ + {"item":"Backend TypeScript build","ok":true,"evidence":"npm run build → exit 0"}, + {"item":"Backend test suite","ok":true,"evidence":"47 passed test files, 15 skipped; 191 passed tests, 56 skipped"}, + {"item":"Admin production build","ok":true,"evidence":"rm -rf apps/admin/.next && npm run build → compiled, TypeScript finished, all routes generated; no /brands prerender error"}, + {"item":"Admin Turbopack workspace warning","ok":true,"evidence":"next.config.ts turbopack.root=__dirname; clean build no multiple-lockfile warning"}, + {"item":"Diff hygiene","ok":true,"evidence":"git diff --check → no whitespace errors"} + ], + "issues": [], + "notes":"The reported admin error showed ECONNREFUSED to 192.168.18.93:3000 during /brands prerender. It was not reproducible after deleting .next; the build now completes with an unreachable NEXT_PUBLIC_API_URL as well. The workspace-root warning was fixed explicitly in next.config.ts. The backend itself must still be running for runtime API calls after deployment." +} diff --git a/work/artifacts/POS-002/reviewer.json b/work/artifacts/POS-002/reviewer.json new file mode 100644 index 0000000..6f2299b --- /dev/null +++ b/work/artifacts/POS-002/reviewer.json @@ -0,0 +1,23 @@ +{ + "feature_id": "POS-002", + "agent": "reviewer", + "stage": "review_gate", + "verdict": "APPROVED", + "reviewed_at": "2026-08-21T19:55:00Z", + "summary": "Fundación POS integrada sin duplicar pedidos ni inventario. Las migraciones 043–047 crean el modelo POS, añaden store_id al inventario, source/COMPLETED a órdenes, roles POS y el CHECK correcto para permitir walk-ins solo en source=pos. El error de build del admin se estabilizó fijando turbopack.root y verificando un build limpio.", + "checks": [ + {"item":"Migrations 043-047 applied successfully on current DB","ok":true,"evidence":"node-pg-migrate up → migrations complete; pgmigrations includes 043,044,045,046,047"}, + {"item":"Default store seeded with operator decision","ok":true,"evidence":"pos_stores: Natural - Mercado de Vida / natural-mercado-de-vida; payment methods cash + card"}, + {"item":"Existing inventory rows backfilled and store scoped","ok":true,"evidence":"inventory_stock 14 rows, NULL store_id = 0; inventory_movements 13 rows, NULL store_id = 0; composite unique constraint present"}, + {"item":"Order source and COMPLETED state added","ok":true,"evidence":"orders_orders accepts source in ecommerce/pos/admin and state COMPLETED"}, + {"item":"Walk-in invariant enforced at DB level","ok":true,"evidence":"ecommerce + NULL user_id → CHECK violation; pos + NULL user_id → allowed; constraint orders_orders_user_required_when_not_pos"}, + {"item":"POS roles and requireAnyRole added","ok":true,"evidence":"Role includes pos_cashier/pos_manager; 8 auth unit tests pass"}, + {"item":"Inventory service/repository uses (variant_id, store_id)","ok":true,"evidence":"availability, reserve, release, confirm, setAvailable and movements thread storeId; ecommerce/cart default to DEFAULT_STORE_ID"}, + {"item":"Legacy checkout behavior preserved","ok":true,"evidence":"DEFAULT_STORE_ID is supplied by cart/checkout; existing test expectation updated; all tests pass"}, + {"item":"Admin build root warning/failure addressed","ok":true,"evidence":"next.config.ts sets turbopack.root=__dirname; clean rm -rf .next && npm run build completes without workspace warning, ECONNREFUSED, or /brands prerender error"}, + {"item":"Backend build and test suite green","ok":true,"evidence":"npm run build exit 0; npm test: 191 passed, 56 skipped"}, + {"item":"No POS UI or new endpoints implemented prematurely","ok":true,"evidence":"Scope is schema/foundation only; POS API begins POS-004"} + ], + "issues": [], + "notes":"Migration 047 is intentionally a catch-up migration because 045 had already been applied before the user_id CHECK was identified. The previous invalid test ecommerce row was removed before applying the constraint; the real existing ecommerce order has a valid user_id." +} diff --git a/work/artifacts/POS-002/security.json b/work/artifacts/POS-002/security.json new file mode 100644 index 0000000..9e2e42d --- /dev/null +++ b/work/artifacts/POS-002/security.json @@ -0,0 +1,20 @@ +{ + "feature_id": "POS-002", + "agent": "security", + "stage": "security_gate", + "verdict": "APPROVED", + "reviewed_at": "2026-08-21T19:55:30Z", + "summary": "Schema hardening approved. The changes add DB-level tenancy isolation for stock, prevent ecommerce/admin orders without a customer, add explicit POS roles, and preserve existing authentication paths. No secrets or payment-card data are introduced.", + "checks": [ + {"item":"Store scoping is enforced in inventory SQL","ok":true,"evidence":"All stock read/write operations include variant_id + store_id; composite unique constraint prevents cross-store overwrite"}, + {"item":"Foreign keys prevent orphaned POS references","ok":true,"evidence":"terminal/session/payment/quick/parked tables reference pos_stores and relevant existing tables with explicit ON DELETE policies"}, + {"item":"Walk-in customer exception is narrowly scoped","ok":true,"evidence":"CHECK (source = 'pos' OR user_id IS NOT NULL); ecommerce/admin NULL user_id rejected by PostgreSQL"}, + {"item":"Role extension does not weaken existing auth","ok":true,"evidence":"requireRole unchanged; requireAnyRole is additive; existing admin/editor/customer behavior preserved"}, + {"item":"No card PAN/CVV/payment secrets added","ok":true,"evidence":"Only payment method configuration rows are seeded; no sensitive payment fields or provider credentials introduced"}, + {"item":"Migrations are parameter/static SQL only","ok":true,"evidence":"Migration inputs are fixed constants and DDL; no user-controlled SQL interpolation"}, + {"item":"Rollback safety documented","ok":true,"evidence":"POS-002 architect/implementer notes document down ordering and the destructive implications of reverting store-scoped inventory"}, + {"item":"Turbopack root change has no runtime security effect","ok":true,"evidence":"next.config.ts only pins build workspace root; no auth, proxy, cookie or endpoint behavior changed"} + ], + "issues": [], + "notes":"The stock store_id is currently supplied by the default store for ecommerce/cart flows and by explicit storeId for future POS flows. POS routes must never trust a client-supplied storeId without validating it against the authenticated terminal; that enforcement belongs in POS-004." +} diff --git a/work/runtime-status.json b/work/runtime-status.json index 4eb2ce7..8631905 100644 --- a/work/runtime-status.json +++ b/work/runtime-status.json @@ -1,26 +1,54 @@ { - "feature_id": "POS-001", + "feature_id": "POS-002", "stage": "close", "agent": "leader", - "action": "Close: 5 architecture docs + 45 queued tickets; awaiting operator sign-off", + "action": "Close: POS foundation, store-scoped inventory, order source/check, roles, and admin build fix approved", "state": "running", "next_agent": "leader", - "waiting_for": "operator_signoff", - "updated_at": "2026-08-21T19:24:55Z", + "waiting_for": "commit_and_promote_POS-003", + "updated_at": "2026-08-21T19:55:25Z", "timeline": [ { - "ts": "2026-08-21T19:15:49Z", + "ts": "2026-08-21T19:33:31Z", "agent": "architect", "stage": "design", "state": "running", - "message": "Discovery: writing POS_ARCHITECTURE.md, POS_DATA_MODEL.md, POS_API.md, POS_HARDWARE.md, POS_OPERATIONS.md, POS_TASKS.md under docs/pos/" + "message": "Architect: design for migrations 043-046 (POS basics) + OrderState.COMPLETED + Role pos_cashier/pos_manager + requireAnyRole helper + inventory.storeId" }, { - "ts": "2026-08-21T19:24:55Z", + "ts": "2026-08-21T19:35:18Z", + "agent": "implementer", + "stage": "build", + "state": "running", + "message": "Implement: 4 migrations (043-046) + OrderState.COMPLETED + Role pos_* + requireAnyRole + inventory.storeId" + }, + { + "ts": "2026-08-21T19:54:15Z", + "agent": "reviewer", + "stage": "review_gate", + "state": "running", + "message": "Review: migrations 043-047, store-scoped inventory, COMPLETED state, POS roles, user_id CHECK, Turbopack root fix" + }, + { + "ts": "2026-08-21T19:54:37Z", + "agent": "security", + "stage": "security_gate", + "state": "running", + "message": "Security: DB invariants, scoped stock, auth roles, migration safety, build config reviewed" + }, + { + "ts": "2026-08-21T19:54:54Z", + "agent": "qa", + "stage": "qa_gate", + "state": "running", + "message": "QA: migrations, constraints, inventory concurrency, tests, backend/admin builds and verify.sh green" + }, + { + "ts": "2026-08-21T19:55:25Z", "agent": "leader", "stage": "close", "state": "running", - "message": "Close: 5 architecture docs + 45 queued tickets; awaiting operator sign-off" + "message": "Close: POS foundation, store-scoped inventory, order source/check, roles, and admin build fix approved" } ] }