feat(data): add immutable pricing and transactional event migrations
This commit is contained in:
parent
068b0f9829
commit
27a1172683
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
@ -23,6 +23,7 @@ model Organization {
|
|||
catalogGroups CatalogGroup[]
|
||||
warehouses Warehouse[]
|
||||
coupons Coupon[]
|
||||
pricingPolicies PricingPolicy[]
|
||||
@@map("organizations")
|
||||
}
|
||||
model User {
|
||||
|
|
|
|||
Loading…
Reference in New Issue