feat/checkout-orders #3

Merged
mihir merged 5 commits from feat/checkout-orders into main 2026-09-11 00:40:00 +05:30
9 changed files with 351 additions and 5 deletions
Showing only changes of commit 6401d2c1c2 - Show all commits

View File

@ -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")
}

93
prisma/checkout.prisma Normal file
View File

@ -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")
}

View File

@ -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")
}

View File

@ -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"
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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();

View File

@ -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])