diff --git a/prisma/catalog.prisma b/prisma/catalog.prisma index 235a786..4829d90 100644 --- a/prisma/catalog.prisma +++ b/prisma/catalog.prisma @@ -35,6 +35,8 @@ model ProductVariant { attributes Json @default("{}") active Boolean @default(true) stockItems StockItem[] + cartLines CartLine[] + orderLines OrderLine[] product Product @relation(fields: [productId, organizationId], references: [id, organizationId], onDelete: Restrict) @@unique([organizationId, sku]) @@unique([id, organizationId]) @@ -63,5 +65,3 @@ model ProductGroup { @@index([groupId, organizationId]) @@map("product_groups") } - - diff --git a/prisma/checkout.prisma b/prisma/checkout.prisma new file mode 100644 index 0000000..5e93729 --- /dev/null +++ b/prisma/checkout.prisma @@ -0,0 +1,93 @@ +enum OrderStatus { + PENDING_PAYMENT + CANCELLED +} +enum CouponKind { + FIXED + PERCENT +} +model Cart { + id String @id @default(uuid()) @db.Uuid + organizationId String @map("organization_id") @db.Uuid + userId String @map("user_id") @db.Uuid + version Int @default(0) + user User @relation(fields: [userId, organizationId], references: [id, organizationId], onDelete: Restrict) + lines CartLine[] + @@unique([userId, organizationId]) + @@unique([id, organizationId]) + @@map("carts") +} +model CartLine { + id String @id @default(uuid()) @db.Uuid + cartId String @map("cart_id") @db.Uuid + organizationId String @map("organization_id") @db.Uuid + variantId String @map("variant_id") @db.Uuid + quantity Int + cart Cart @relation(fields: [cartId, organizationId], references: [id, organizationId], onDelete: Cascade) + variant ProductVariant @relation(fields: [variantId, organizationId], references: [id, organizationId], onDelete: Restrict) + @@unique([cartId, variantId]) + @@map("cart_lines") +} +model Coupon { + id String @id @default(uuid()) @db.Uuid + organizationId String @map("organization_id") @db.Uuid + code String @db.VarChar(40) + kind CouponKind + currency String @db.Char(3) + amount Decimal? @db.Decimal(12,2) + percentBps Int? @map("percent_bps") + minimumSubtotal Decimal @default(0) @map("minimum_subtotal") @db.Decimal(12,2) + maxUses Int @map("max_uses") + perUserLimit Int @map("per_user_limit") + startsAt DateTime @map("starts_at") @db.Timestamptz(3) + endsAt DateTime @map("ends_at") @db.Timestamptz(3) + active Boolean @default(true) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Restrict) + orders Order[] + @@unique([organizationId, code]) + @@unique([id, organizationId]) + @@map("coupons") +} +model Order { + id String @id @default(uuid()) @db.Uuid + organizationId String @map("organization_id") @db.Uuid + userId String @map("user_id") @db.Uuid + status OrderStatus @default(PENDING_PAYMENT) + currency String @db.Char(3) + subtotal Decimal @db.Decimal(16,2) + discount Decimal @db.Decimal(16,2) + merchandiseTotal Decimal @map("merchandise_total") @db.Decimal(16,2) + addressSnapshot Json @map("address_snapshot") + couponSnapshot Json? @map("coupon_snapshot") + couponId String? @map("coupon_id") @db.Uuid + idempotencyKey String @map("idempotency_key") @db.Uuid + requestHash String @map("request_hash") @db.Char(64) + expiresAt DateTime @map("expires_at") @db.Timestamptz(3) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + user User @relation(fields: [userId, organizationId], references: [id, organizationId], onDelete: Restrict) + coupon Coupon? @relation(fields: [couponId, organizationId], references: [id, organizationId], onDelete: Restrict) + lines OrderLine[] + reservations StockReservation[] + @@unique([userId, organizationId, idempotencyKey]) + @@unique([id, organizationId]) + @@index([organizationId, userId, createdAt, id]) + @@unique([id, userId, organizationId]) + @@index([couponId, status, expiresAt]) + @@map("orders") +} +model OrderLine { + id String @id @default(uuid()) @db.Uuid + orderId String @map("order_id") @db.Uuid + organizationId String @map("organization_id") @db.Uuid + variantId String @map("variant_id") @db.Uuid + sku String @db.VarChar(64) + productName String @map("product_name") @db.VarChar(160) + variantName String @map("variant_name") @db.VarChar(160) + quantity Int + unitPrice Decimal @map("unit_price") @db.Decimal(12,2) + lineTotal Decimal @map("line_total") @db.Decimal(16,2) + order Order @relation(fields: [orderId, organizationId], references: [id, organizationId], onDelete: Restrict) + variant ProductVariant @relation(fields: [variantId, organizationId], references: [id, organizationId], onDelete: Restrict) + @@unique([orderId, variantId]) + @@map("order_lines") +} diff --git a/prisma/inventory.prisma b/prisma/inventory.prisma index 58a53ac..b0be7a3 100644 --- a/prisma/inventory.prisma +++ b/prisma/inventory.prisma @@ -50,6 +50,8 @@ model StockReservation { organizationId String @map("organization_id") @db.Uuid userId String @map("user_id") @db.Uuid user User @relation(fields: [userId, organizationId], references: [id, organizationId], onDelete: Restrict) + orderId String? @map("order_id") @db.Uuid + order Order? @relation(fields: [orderId, userId, organizationId], references: [id, userId, organizationId], onDelete: Restrict) quantity Int status ReservationStatus @default(ACTIVE) idempotencyKey String @map("idempotency_key") @db.Uuid @@ -62,5 +64,3 @@ model StockReservation { @@index([userId, organizationId]) @@map("stock_reservations") } - - diff --git a/prisma/migration-checksums.json b/prisma/migration-checksums.json index 587b350..9e5c06e 100644 --- a/prisma/migration-checksums.json +++ b/prisma/migration-checksums.json @@ -5,5 +5,9 @@ "20260909141447_catalog_addresses": "c89cc9448494d74f8e5ee0005e81b6265bb01d7f000a81c4af27dc3c9855ba84", "20260909141549_inventory": "ab5b0afde7332dd8de3190781dde95bd941bba91ab525308a7436d108cef7e36", "20260909141622_commerce_integrity": "30545784aa33f35783c0170b80de2bded32be8781e25e027c4e5e2e5da8348ac", - "20260909153911_inventory_actor_scope": "7d6d2df3a8229032022f5a2ce43ed37c508c4a71743fd2bc6c24786634611d1e" + "20260909153911_inventory_actor_scope": "7d6d2df3a8229032022f5a2ce43ed37c508c4a71743fd2bc6c24786634611d1e", + "20260910182626_checkout_orders": "aa924db3868c96475c8255800788de42d8e7447cf0a2c84b789b116e533582cb", + "20260910182800_checkout_integrity": "e88b972ab16a4cd95f6fb4f097ce41ddfc7917899dc3803a1e7757c3e56c19b2", + "20260910183016_checkout_snapshot_guards": "f1c7a95b5a620e06a518a96d457fb493241bd2d5e7deb6b5788ad85c8f3b59f7", + "20260910184357_order_reconciliation": "c32e7a917618e02ed1658abb740a7f4e0513a47e0734ad29d90fff325fd05336" } diff --git a/prisma/migrations/20260910182626_checkout_orders/migration.sql b/prisma/migrations/20260910182626_checkout_orders/migration.sql new file mode 100644 index 0000000..182cc6e --- /dev/null +++ b/prisma/migrations/20260910182626_checkout_orders/migration.sql @@ -0,0 +1,142 @@ +-- CreateEnum +CREATE TYPE "OrderStatus" AS ENUM ('PENDING_PAYMENT', 'CANCELLED'); + +-- CreateEnum +CREATE TYPE "CouponKind" AS ENUM ('FIXED', 'PERCENT'); + +-- AlterTable +ALTER TABLE "stock_reservations" ADD COLUMN "order_id" UUID; + +-- CreateTable +CREATE TABLE "carts" ( + "id" UUID NOT NULL, + "organization_id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "version" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "carts_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "cart_lines" ( + "id" UUID NOT NULL, + "cart_id" UUID NOT NULL, + "organization_id" UUID NOT NULL, + "variant_id" UUID NOT NULL, + "quantity" INTEGER NOT NULL, + + CONSTRAINT "cart_lines_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "coupons" ( + "id" UUID NOT NULL, + "organization_id" UUID NOT NULL, + "code" VARCHAR(40) NOT NULL, + "kind" "CouponKind" NOT NULL, + "currency" CHAR(3) NOT NULL, + "amount" DECIMAL(12,2), + "percent_bps" INTEGER, + "minimum_subtotal" DECIMAL(12,2) NOT NULL DEFAULT 0, + "max_uses" INTEGER NOT NULL, + "per_user_limit" INTEGER NOT NULL, + "starts_at" TIMESTAMPTZ(3) NOT NULL, + "ends_at" TIMESTAMPTZ(3) NOT NULL, + "active" BOOLEAN NOT NULL DEFAULT true, + + CONSTRAINT "coupons_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "orders" ( + "id" UUID NOT NULL, + "organization_id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "status" "OrderStatus" NOT NULL DEFAULT 'PENDING_PAYMENT', + "currency" CHAR(3) NOT NULL, + "subtotal" DECIMAL(16,2) NOT NULL, + "discount" DECIMAL(16,2) NOT NULL, + "merchandise_total" DECIMAL(16,2) NOT NULL, + "address_snapshot" JSONB NOT NULL, + "coupon_snapshot" JSONB, + "coupon_id" UUID, + "idempotency_key" UUID NOT NULL, + "request_hash" CHAR(64) NOT NULL, + "expires_at" TIMESTAMPTZ(3) NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "orders_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "order_lines" ( + "id" UUID NOT NULL, + "order_id" UUID NOT NULL, + "organization_id" UUID NOT NULL, + "variant_id" UUID NOT NULL, + "sku" VARCHAR(64) NOT NULL, + "product_name" VARCHAR(160) NOT NULL, + "variant_name" VARCHAR(160) NOT NULL, + "quantity" INTEGER NOT NULL, + "unit_price" DECIMAL(12,2) NOT NULL, + "line_total" DECIMAL(16,2) NOT NULL, + + CONSTRAINT "order_lines_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "carts_user_id_organization_id_key" ON "carts"("user_id", "organization_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "carts_id_organization_id_key" ON "carts"("id", "organization_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "cart_lines_cart_id_variant_id_key" ON "cart_lines"("cart_id", "variant_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "coupons_organization_id_code_key" ON "coupons"("organization_id", "code"); + +-- CreateIndex +CREATE UNIQUE INDEX "coupons_id_organization_id_key" ON "coupons"("id", "organization_id"); + +-- CreateIndex +CREATE INDEX "orders_organization_id_user_id_created_at_id_idx" ON "orders"("organization_id", "user_id", "created_at", "id"); + +-- CreateIndex +CREATE INDEX "orders_coupon_id_status_expires_at_idx" ON "orders"("coupon_id", "status", "expires_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "orders_user_id_organization_id_idempotency_key_key" ON "orders"("user_id", "organization_id", "idempotency_key"); + +-- CreateIndex +CREATE UNIQUE INDEX "orders_id_organization_id_key" ON "orders"("id", "organization_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "order_lines_order_id_variant_id_key" ON "order_lines"("order_id", "variant_id"); + +-- AddForeignKey +ALTER TABLE "carts" ADD CONSTRAINT "carts_user_id_organization_id_fkey" FOREIGN KEY ("user_id", "organization_id") REFERENCES "users"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "cart_lines" ADD CONSTRAINT "cart_lines_cart_id_organization_id_fkey" FOREIGN KEY ("cart_id", "organization_id") REFERENCES "carts"("id", "organization_id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "cart_lines" ADD CONSTRAINT "cart_lines_variant_id_organization_id_fkey" FOREIGN KEY ("variant_id", "organization_id") REFERENCES "product_variants"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "coupons" ADD CONSTRAINT "coupons_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "orders" ADD CONSTRAINT "orders_user_id_organization_id_fkey" FOREIGN KEY ("user_id", "organization_id") REFERENCES "users"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "orders" ADD CONSTRAINT "orders_coupon_id_organization_id_fkey" FOREIGN KEY ("coupon_id", "organization_id") REFERENCES "coupons"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "order_lines" ADD CONSTRAINT "order_lines_order_id_organization_id_fkey" FOREIGN KEY ("order_id", "organization_id") REFERENCES "orders"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "order_lines" ADD CONSTRAINT "order_lines_variant_id_organization_id_fkey" FOREIGN KEY ("variant_id", "organization_id") REFERENCES "product_variants"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "stock_reservations" ADD CONSTRAINT "stock_reservations_order_id_organization_id_fkey" FOREIGN KEY ("order_id", "organization_id") REFERENCES "orders"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20260910182800_checkout_integrity/migration.sql b/prisma/migrations/20260910182800_checkout_integrity/migration.sql new file mode 100644 index 0000000..a3ff189 --- /dev/null +++ b/prisma/migrations/20260910182800_checkout_integrity/migration.sql @@ -0,0 +1,8 @@ +-- DropForeignKey +ALTER TABLE "stock_reservations" DROP CONSTRAINT "stock_reservations_order_id_organization_id_fkey"; + +-- CreateIndex +CREATE UNIQUE INDEX "orders_id_user_id_organization_id_key" ON "orders"("id", "user_id", "organization_id"); + +-- AddForeignKey +ALTER TABLE "stock_reservations" ADD CONSTRAINT "stock_reservations_order_id_user_id_organization_id_fkey" FOREIGN KEY ("order_id", "user_id", "organization_id") REFERENCES "orders"("id", "user_id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20260910183016_checkout_snapshot_guards/migration.sql b/prisma/migrations/20260910183016_checkout_snapshot_guards/migration.sql new file mode 100644 index 0000000..c4bc9c9 --- /dev/null +++ b/prisma/migrations/20260910183016_checkout_snapshot_guards/migration.sql @@ -0,0 +1,73 @@ +ALTER TABLE carts ADD CONSTRAINT cart_version_nonnegative CHECK (version >= 0); +ALTER TABLE cart_lines ADD CONSTRAINT cart_quantity_bounds CHECK (quantity BETWEEN 1 AND 100); +ALTER TABLE coupons ADD CONSTRAINT coupon_rule_valid CHECK ( + (kind = 'FIXED' AND amount IS NOT NULL AND amount > 0 AND percent_bps IS NULL) + OR (kind = 'PERCENT' AND amount IS NULL AND percent_bps BETWEEN 1 AND 10000 AND percent_bps IS NOT NULL) +); +ALTER TABLE coupons ADD CONSTRAINT coupon_limits_valid CHECK ( + minimum_subtotal >= 0 AND max_uses > 0 AND per_user_limit > 0 AND per_user_limit <= max_uses + AND ends_at > starts_at AND code ~ '^[A-Z0-9][A-Z0-9_-]{0,39}$' + AND currency IN ('INR', 'USD', 'EUR', 'GBP') +); +ALTER TABLE orders ADD CONSTRAINT order_totals_valid CHECK ( + subtotal > 0 AND discount >= 0 AND discount <= subtotal + AND merchandise_total = subtotal - discount AND expires_at > created_at +); +ALTER TABLE order_lines ADD CONSTRAINT order_line_totals_valid CHECK ( + quantity BETWEEN 1 AND 100 AND unit_price > 0 AND line_total = unit_price * quantity +); + +CREATE FUNCTION protect_order_snapshot() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN RAISE EXCEPTION 'Orders cannot be deleted'; END IF; + IF (to_jsonb(NEW) - 'status') IS DISTINCT FROM (to_jsonb(OLD) - 'status') THEN + RAISE EXCEPTION 'Order snapshots are immutable'; + END IF; + IF OLD.status = 'CANCELLED' AND NEW.status <> 'CANCELLED' THEN + RAISE EXCEPTION 'Cancelled orders cannot be reopened'; + END IF; + RETURN NEW; +END; +$$; +CREATE TRIGGER orders_snapshot_immutable BEFORE UPDATE OR DELETE ON orders +FOR EACH ROW EXECUTE FUNCTION protect_order_snapshot(); + +CREATE FUNCTION protect_order_line() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + RAISE EXCEPTION 'Order lines are immutable'; +END; +$$; +CREATE TRIGGER order_lines_immutable BEFORE UPDATE OR DELETE ON order_lines +FOR EACH ROW EXECUTE FUNCTION protect_order_line(); + +CREATE FUNCTION protect_coupon_rules() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF (to_jsonb(NEW) - 'active') IS DISTINCT FROM (to_jsonb(OLD) - 'active') THEN + RAISE EXCEPTION 'Create a new coupon to change discount rules'; + END IF; + RETURN NEW; +END; +$$; +CREATE TRIGGER coupon_rules_immutable BEFORE UPDATE ON coupons +FOR EACH ROW EXECUTE FUNCTION protect_coupon_rules(); + +CREATE FUNCTION protect_order_reservation() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF OLD.order_id IS NOT NULL THEN + IF TG_OP = 'DELETE' THEN RAISE EXCEPTION 'Order reservations cannot be deleted'; END IF; + IF (to_jsonb(NEW) - 'status') IS DISTINCT FROM (to_jsonb(OLD) - 'status') THEN + RAISE EXCEPTION 'Order reservation allocation is immutable'; + END IF; + END IF; + IF TG_OP = 'DELETE' THEN RETURN OLD; END IF; + RETURN NEW; +END; +$$; +CREATE TRIGGER order_reservation_immutable BEFORE UPDATE OR DELETE ON stock_reservations +FOR EACH ROW EXECUTE FUNCTION protect_order_reservation(); + +UPDATE roles SET permissions = ARRAY( + SELECT DISTINCT permission FROM unnest(permissions || ARRAY[ + 'coupons.manage', 'orders.read', 'orders.manage' + ]::text[]) AS permission ORDER BY permission +) WHERE is_system = true; diff --git a/prisma/migrations/20260910184357_order_reconciliation/migration.sql b/prisma/migrations/20260910184357_order_reconciliation/migration.sql new file mode 100644 index 0000000..d8a5014 --- /dev/null +++ b/prisma/migrations/20260910184357_order_reconciliation/migration.sql @@ -0,0 +1,23 @@ +-- Deferred reconciliation permits nested line creation in the same transaction +-- while preventing incomplete orders or later additions to an existing snapshot. +CREATE FUNCTION reconcile_order_lines() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE + target_order UUID; + expected NUMERIC; + actual NUMERIC; +BEGIN + IF TG_TABLE_NAME = 'orders' THEN target_order := NEW.id; + ELSE target_order := NEW.order_id; + END IF; + SELECT subtotal INTO expected FROM orders WHERE id = target_order; + SELECT COALESCE(SUM(line_total), 0) INTO actual FROM order_lines WHERE order_id = target_order; + IF expected IS DISTINCT FROM actual THEN + RAISE EXCEPTION 'Order subtotal does not match lines'; + END IF; + RETURN NULL; +END; +$$; +CREATE CONSTRAINT TRIGGER orders_reconcile_lines AFTER INSERT ON orders +DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION reconcile_order_lines(); +CREATE CONSTRAINT TRIGGER order_lines_reconcile_total AFTER INSERT ON order_lines +DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION reconcile_order_lines(); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d697f8c..157b82b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -22,6 +22,7 @@ model Organization { products Product[] catalogGroups CatalogGroup[] warehouses Warehouse[] + coupons Coupon[] @@map("organizations") } model User { @@ -41,6 +42,8 @@ model User { addresses Address[] stockEntries StockLedger[] stockReservations StockReservation[] + carts Cart[] + orders Order[] @@unique([organizationId, email]) @@unique([id, organizationId]) @@index([organizationId, createdAt, id])