feat(POS-002): completed feature
This commit is contained in:
@@ -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: [
|
||||
|
||||
188
project/migrations/043_pos_basics.js
Normal file
188
project/migrations/043_pos_basics.js
Normal file
@@ -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');
|
||||
};
|
||||
112
project/migrations/044_pos_inventory_store.js
Normal file
112
project/migrations/044_pos_inventory_store.js
Normal file
@@ -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`);
|
||||
};
|
||||
149
project/migrations/045_pos_orders_source.js
Normal file
149
project/migrations/045_pos_orders_source.js
Normal file
@@ -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`);
|
||||
};
|
||||
45
project/migrations/046_pos_backoffice_roles.js
Normal file
45
project/migrations/046_pos_backoffice_roles.js
Normal file
@@ -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 $$
|
||||
`);
|
||||
};
|
||||
34
project/migrations/047_orders_user_required_when_not_pos.js
Normal file
34
project/migrations/047_orders_user_required_when_not_pos.js
Normal file
@@ -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`);
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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<void> {
|
||||
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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -17,9 +17,9 @@ import type {
|
||||
export class InventoryService implements InventoryServicePort {
|
||||
constructor(private readonly repository: InventoryRepository) {}
|
||||
|
||||
async checkAvailability(variantId: string, quantity: number): Promise<Availability> {
|
||||
async checkAvailability(variantId: string, storeId: string, quantity: number): Promise<Availability> {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Availability, SetAvailableStockCommand, StockCommand, StockItem } from './stock.js';
|
||||
|
||||
export interface InventoryService {
|
||||
checkAvailability(variantId: string, quantity: number): Promise<Availability>;
|
||||
checkAvailability(variantId: string, storeId: string, quantity: number): Promise<Availability>;
|
||||
reserve(input: StockCommand): Promise<StockItem>;
|
||||
release(input: StockCommand): Promise<StockItem>;
|
||||
confirm(input: StockCommand): Promise<StockItem>;
|
||||
@@ -9,7 +9,7 @@ export interface InventoryService {
|
||||
}
|
||||
|
||||
export interface InventoryRepository {
|
||||
findByVariantId(variantId: string): Promise<StockItem | undefined>;
|
||||
findByVariantId(variantId: string, storeId: string): Promise<StockItem | undefined>;
|
||||
setAvailable(input: SetAvailableStockCommand): Promise<StockItem>;
|
||||
reserve(input: StockCommand): Promise<StockItem | undefined>;
|
||||
release(input: StockCommand): Promise<StockItem | undefined>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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<StockItem | undefined> {
|
||||
async findByVariantId(variantId: string, storeId: string): Promise<StockItem | undefined> {
|
||||
const result = await this.pool.query<StockRow>(
|
||||
'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<StockRow>(
|
||||
`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<StockRow>(
|
||||
`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<StockRow>(
|
||||
`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<StockRow>(
|
||||
`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<void> {
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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<Record<OrderState, ReadonlyArray<OrderState>>> = {
|
||||
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: [],
|
||||
|
||||
@@ -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<Role>): 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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user