diff --git a/prisma/checkout.prisma b/prisma/checkout.prisma index 5e93729..3d5cd40 100644 --- a/prisma/checkout.prisma +++ b/prisma/checkout.prisma @@ -68,6 +68,8 @@ model Order { coupon Coupon? @relation(fields: [couponId, organizationId], references: [id, organizationId], onDelete: Restrict) lines OrderLine[] reservations StockReservation[] + pricing OrderPricing? + events CommerceEvent[] @@unique([userId, organizationId, idempotencyKey]) @@unique([id, organizationId]) @@index([organizationId, userId, createdAt, id]) diff --git a/prisma/events.prisma b/prisma/events.prisma new file mode 100644 index 0000000..78e6421 --- /dev/null +++ b/prisma/events.prisma @@ -0,0 +1,24 @@ +model CommerceEvent { + id String @id @default(uuid()) @db.Uuid + organizationId String @map("organization_id") @db.Uuid + orderId String @map("order_id") @db.Uuid + kind String @db.VarChar(80) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + order Order @relation(fields: [orderId, organizationId], references: [id, organizationId], onDelete: Restrict) + delivery EventDelivery? + @@unique([orderId, kind]) + @@index([organizationId, createdAt, id]) + @@map("commerce_events") +} +model EventDelivery { + eventId String @id @map("event_id") @db.Uuid + attempts Int @default(0) + availableAt DateTime @default(now()) @map("available_at") @db.Timestamptz(3) + leaseToken String? @map("lease_token") @db.Uuid + leaseExpiresAt DateTime? @map("lease_expires_at") @db.Timestamptz(3) + deliveredAt DateTime? @map("delivered_at") @db.Timestamptz(3) + lastErrorCode String? @map("last_error_code") @db.VarChar(80) + event CommerceEvent @relation(fields: [eventId], references: [id], onDelete: Restrict) + @@index([deliveredAt, availableAt, leaseExpiresAt]) + @@map("event_deliveries") +} diff --git a/prisma/migration-checksums.json b/prisma/migration-checksums.json index 9e5c06e..3abdb0f 100644 --- a/prisma/migration-checksums.json +++ b/prisma/migration-checksums.json @@ -9,5 +9,7 @@ "20260910182626_checkout_orders": "aa924db3868c96475c8255800788de42d8e7447cf0a2c84b789b116e533582cb", "20260910182800_checkout_integrity": "e88b972ab16a4cd95f6fb4f097ce41ddfc7917899dc3803a1e7757c3e56c19b2", "20260910183016_checkout_snapshot_guards": "f1c7a95b5a620e06a518a96d457fb493241bd2d5e7deb6b5788ad85c8f3b59f7", - "20260910184357_order_reconciliation": "c32e7a917618e02ed1658abb740a7f4e0513a47e0734ad29d90fff325fd05336" + "20260910184357_order_reconciliation": "c32e7a917618e02ed1658abb740a7f4e0513a47e0734ad29d90fff325fd05336", + "20260911110906_pricing_events": "60a4a5a0a05821c2a9785496cd2e9bc0f839e5fb2ae3c59275655481c96eb66b", + "20260911111403_operations_integrity": "f40bddf9e29d6518bc765cbaca7688c04cb95d5ff729c52e7dc775eefa1e521e" } diff --git a/prisma/migrations/20260911110906_pricing_events/migration.sql b/prisma/migrations/20260911110906_pricing_events/migration.sql new file mode 100644 index 0000000..8dab894 --- /dev/null +++ b/prisma/migrations/20260911110906_pricing_events/migration.sql @@ -0,0 +1,94 @@ +-- CreateEnum +CREATE TYPE "TaxMode" AS ENUM ('INCLUSIVE', 'EXCLUSIVE'); + +-- CreateTable +CREATE TABLE "commerce_events" ( + "id" UUID NOT NULL, + "organization_id" UUID NOT NULL, + "order_id" UUID NOT NULL, + "kind" VARCHAR(80) NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "commerce_events_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "event_deliveries" ( + "event_id" UUID NOT NULL, + "attempts" INTEGER NOT NULL DEFAULT 0, + "available_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lease_token" UUID, + "lease_expires_at" TIMESTAMPTZ(3), + "delivered_at" TIMESTAMPTZ(3), + "last_error_code" VARCHAR(80), + + CONSTRAINT "event_deliveries_pkey" PRIMARY KEY ("event_id") +); + +-- CreateTable +CREATE TABLE "pricing_policies" ( + "id" UUID NOT NULL, + "organization_id" UUID NOT NULL, + "name" VARCHAR(100) NOT NULL, + "currency" CHAR(3) NOT NULL, + "country_code" CHAR(2) NOT NULL, + "region" VARCHAR(100) NOT NULL DEFAULT '', + "tax_mode" "TaxMode" NOT NULL, + "merchandise_tax_bps" INTEGER NOT NULL, + "shipping_fee" DECIMAL(12,2) NOT NULL, + "shipping_tax_bps" INTEGER NOT NULL, + "free_shipping_minimum" DECIMAL(12,2), + "active" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "pricing_policies_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "order_pricing" ( + "order_id" UUID NOT NULL, + "organization_id" UUID NOT NULL, + "policy_id" UUID NOT NULL, + "policy_snapshot" JSONB NOT NULL, + "merchandise_tax" DECIMAL(16,2) NOT NULL, + "shipping_net" DECIMAL(16,2) NOT NULL, + "shipping_tax" DECIMAL(16,2) NOT NULL, + "tax_total" DECIMAL(16,2) NOT NULL, + "payable_total" DECIMAL(16,2) NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "order_pricing_pkey" PRIMARY KEY ("order_id") +); + +-- CreateIndex +CREATE INDEX "commerce_events_organization_id_created_at_id_idx" ON "commerce_events"("organization_id", "created_at", "id"); + +-- CreateIndex +CREATE UNIQUE INDEX "commerce_events_order_id_kind_key" ON "commerce_events"("order_id", "kind"); + +-- CreateIndex +CREATE INDEX "event_deliveries_delivered_at_available_at_lease_expires_at_idx" ON "event_deliveries"("delivered_at", "available_at", "lease_expires_at"); + +-- CreateIndex +CREATE INDEX "pricing_policies_organization_id_currency_country_code_regi_idx" ON "pricing_policies"("organization_id", "currency", "country_code", "region", "active"); + +-- CreateIndex +CREATE UNIQUE INDEX "pricing_policies_id_organization_id_key" ON "pricing_policies"("id", "organization_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "order_pricing_order_id_organization_id_key" ON "order_pricing"("order_id", "organization_id"); + +-- AddForeignKey +ALTER TABLE "commerce_events" ADD CONSTRAINT "commerce_events_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 "event_deliveries" ADD CONSTRAINT "event_deliveries_event_id_fkey" FOREIGN KEY ("event_id") REFERENCES "commerce_events"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "pricing_policies" ADD CONSTRAINT "pricing_policies_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "order_pricing" ADD CONSTRAINT "order_pricing_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_pricing" ADD CONSTRAINT "order_pricing_policy_id_organization_id_fkey" FOREIGN KEY ("policy_id", "organization_id") REFERENCES "pricing_policies"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20260911111403_operations_integrity/migration.sql b/prisma/migrations/20260911111403_operations_integrity/migration.sql new file mode 100644 index 0000000..4a609b7 --- /dev/null +++ b/prisma/migrations/20260911111403_operations_integrity/migration.sql @@ -0,0 +1,63 @@ +ALTER TABLE pricing_policies ADD CONSTRAINT pricing_rule_bounds CHECK ( + merchandise_tax_bps BETWEEN 0 AND 10000 AND shipping_tax_bps BETWEEN 0 AND 10000 + AND shipping_fee >= 0 AND (free_shipping_minimum IS NULL OR free_shipping_minimum >= 0) + AND currency IN ('INR','USD','EUR','GBP') AND country_code ~ '^[A-Z]{2}$' AND region = upper(region) +); +CREATE UNIQUE INDEX pricing_one_active_scope ON pricing_policies (organization_id, currency, country_code, region) +WHERE active = true; +CREATE FUNCTION protect_pricing_policy() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF (to_jsonb(NEW) - 'active') IS DISTINCT FROM (to_jsonb(OLD) - 'active') THEN + RAISE EXCEPTION 'Pricing policies are immutable; create a new version'; + END IF; + RETURN NEW; +END; +$$; +CREATE TRIGGER pricing_policy_immutable BEFORE UPDATE ON pricing_policies +FOR EACH ROW EXECUTE FUNCTION protect_pricing_policy(); + +CREATE FUNCTION reject_commerce_snapshot_mutation() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + RAISE EXCEPTION 'Commerce snapshots and events are immutable'; +END; +$$; +CREATE TRIGGER order_pricing_immutable BEFORE UPDATE OR DELETE ON order_pricing +FOR EACH ROW EXECUTE FUNCTION reject_commerce_snapshot_mutation(); +CREATE TRIGGER commerce_events_immutable BEFORE UPDATE OR DELETE ON commerce_events +FOR EACH ROW EXECUTE FUNCTION reject_commerce_snapshot_mutation(); + +CREATE FUNCTION reconcile_order_pricing() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE + p pricing_policies%ROWTYPE; + merchandise NUMERIC; + expected_tax NUMERIC; + shipping NUMERIC; + shipping_tax NUMERIC; + payable NUMERIC; +BEGIN + SELECT * INTO STRICT p FROM pricing_policies WHERE id = NEW.policy_id AND organization_id = NEW.organization_id; + SELECT merchandise_total INTO STRICT merchandise FROM orders WHERE id = NEW.order_id AND organization_id = NEW.organization_id; + expected_tax := ROUND(merchandise * p.merchandise_tax_bps / + (10000 + CASE WHEN p.tax_mode = 'INCLUSIVE' THEN p.merchandise_tax_bps ELSE 0 END), 2); + shipping := CASE WHEN p.free_shipping_minimum IS NOT NULL AND merchandise >= p.free_shipping_minimum + THEN 0 ELSE p.shipping_fee END; + shipping_tax := ROUND(shipping * p.shipping_tax_bps / 10000, 2); + payable := merchandise + shipping + shipping_tax + CASE WHEN p.tax_mode = 'EXCLUSIVE' THEN expected_tax ELSE 0 END; + IF NEW.merchandise_tax <> expected_tax OR NEW.shipping_net <> shipping OR NEW.shipping_tax <> shipping_tax + OR NEW.tax_total <> expected_tax + shipping_tax OR NEW.payable_total <> payable THEN + RAISE EXCEPTION 'Final pricing does not match the selected policy'; + END IF; + RETURN NEW; +END; +$$; +CREATE TRIGGER order_pricing_reconciled BEFORE INSERT ON order_pricing +FOR EACH ROW EXECUTE FUNCTION reconcile_order_pricing(); + +ALTER TABLE commerce_events ADD CONSTRAINT commerce_event_kind CHECK (kind IN ('order.created','order.cancelled')); +ALTER TABLE event_deliveries ADD CONSTRAINT delivery_attempt_bounds CHECK (attempts BETWEEN 0 AND 5); +ALTER TABLE event_deliveries ADD CONSTRAINT delivery_lease_pair CHECK ((lease_token IS NULL) = (lease_expires_at IS NULL)); +UPDATE roles SET permissions = ARRAY( + SELECT DISTINCT permission FROM unnest(permissions || ARRAY[ + 'pricing.manage', 'operations.read', 'notifications.retry' + ]::text[]) AS permission ORDER BY permission +) WHERE is_system = true; diff --git a/prisma/pricing.prisma b/prisma/pricing.prisma new file mode 100644 index 0000000..5417a95 --- /dev/null +++ b/prisma/pricing.prisma @@ -0,0 +1,41 @@ +enum TaxMode { + INCLUSIVE + EXCLUSIVE +} +model PricingPolicy { + id String @id @default(uuid()) @db.Uuid + organizationId String @map("organization_id") @db.Uuid + name String @db.VarChar(100) + currency String @db.Char(3) + countryCode String @map("country_code") @db.Char(2) + region String @default("") @db.VarChar(100) + taxMode TaxMode @map("tax_mode") + merchandiseTaxBps Int @map("merchandise_tax_bps") + shippingFee Decimal @map("shipping_fee") @db.Decimal(12,2) + shippingTaxBps Int @map("shipping_tax_bps") + freeShippingMinimum Decimal? @map("free_shipping_minimum") @db.Decimal(12,2) + active Boolean @default(false) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Restrict) + prices OrderPricing[] + @@unique([id, organizationId]) + @@index([organizationId, currency, countryCode, region, active]) + @@map("pricing_policies") +} +model OrderPricing { + orderId String @id @map("order_id") @db.Uuid + organizationId String @map("organization_id") @db.Uuid + policyId String @map("policy_id") @db.Uuid + policySnapshot Json @map("policy_snapshot") + merchandiseTax Decimal @map("merchandise_tax") @db.Decimal(16,2) + shippingNet Decimal @map("shipping_net") @db.Decimal(16,2) + shippingTax Decimal @map("shipping_tax") @db.Decimal(16,2) + taxTotal Decimal @map("tax_total") @db.Decimal(16,2) + payableTotal Decimal @map("payable_total") @db.Decimal(16,2) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + order Order @relation(fields: [orderId, organizationId], references: [id, organizationId], onDelete: Restrict) + policy PricingPolicy @relation(fields: [policyId, organizationId], references: [id, organizationId], onDelete: Restrict) + @@unique([orderId, organizationId]) + @@map("order_pricing") +} + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 157b82b..2e2d547 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -23,6 +23,7 @@ model Organization { catalogGroups CatalogGroup[] warehouses Warehouse[] coupons Coupon[] + pricingPolicies PricingPolicy[] @@map("organizations") } model User {