feat/commerce-operations #4

Merged
mihir merged 5 commits from feat/commerce-operations into main 2026-09-11 17:01:26 +05:30
7 changed files with 228 additions and 1 deletions
Showing only changes of commit 27a1172683 - Show all commits

View File

@ -68,6 +68,8 @@ model Order {
coupon Coupon? @relation(fields: [couponId, organizationId], references: [id, organizationId], onDelete: Restrict) coupon Coupon? @relation(fields: [couponId, organizationId], references: [id, organizationId], onDelete: Restrict)
lines OrderLine[] lines OrderLine[]
reservations StockReservation[] reservations StockReservation[]
pricing OrderPricing?
events CommerceEvent[]
@@unique([userId, organizationId, idempotencyKey]) @@unique([userId, organizationId, idempotencyKey])
@@unique([id, organizationId]) @@unique([id, organizationId])
@@index([organizationId, userId, createdAt, id]) @@index([organizationId, userId, createdAt, id])

24
prisma/events.prisma Normal file
View File

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

View File

@ -9,5 +9,7 @@
"20260910182626_checkout_orders": "aa924db3868c96475c8255800788de42d8e7447cf0a2c84b789b116e533582cb", "20260910182626_checkout_orders": "aa924db3868c96475c8255800788de42d8e7447cf0a2c84b789b116e533582cb",
"20260910182800_checkout_integrity": "e88b972ab16a4cd95f6fb4f097ce41ddfc7917899dc3803a1e7757c3e56c19b2", "20260910182800_checkout_integrity": "e88b972ab16a4cd95f6fb4f097ce41ddfc7917899dc3803a1e7757c3e56c19b2",
"20260910183016_checkout_snapshot_guards": "f1c7a95b5a620e06a518a96d457fb493241bd2d5e7deb6b5788ad85c8f3b59f7", "20260910183016_checkout_snapshot_guards": "f1c7a95b5a620e06a518a96d457fb493241bd2d5e7deb6b5788ad85c8f3b59f7",
"20260910184357_order_reconciliation": "c32e7a917618e02ed1658abb740a7f4e0513a47e0734ad29d90fff325fd05336" "20260910184357_order_reconciliation": "c32e7a917618e02ed1658abb740a7f4e0513a47e0734ad29d90fff325fd05336",
"20260911110906_pricing_events": "60a4a5a0a05821c2a9785496cd2e9bc0f839e5fb2ae3c59275655481c96eb66b",
"20260911111403_operations_integrity": "f40bddf9e29d6518bc765cbaca7688c04cb95d5ff729c52e7dc775eefa1e521e"
} }

View File

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

View File

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

41
prisma/pricing.prisma Normal file
View File

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

View File

@ -23,6 +23,7 @@ model Organization {
catalogGroups CatalogGroup[] catalogGroups CatalogGroup[]
warehouses Warehouse[] warehouses Warehouse[]
coupons Coupon[] coupons Coupon[]
pricingPolicies PricingPolicy[]
@@map("organizations") @@map("organizations")
} }
model User { model User {