diff --git a/packages/db/prisma/migrations/20261009110000_billing_checkout_sessions/migration.sql b/packages/db/prisma/migrations/20261009110000_billing_checkout_sessions/migration.sql new file mode 100644 index 00000000..30dd512a --- /dev/null +++ b/packages/db/prisma/migrations/20261009110000_billing_checkout_sessions/migration.sql @@ -0,0 +1,21 @@ +-- Preserve every checkout reference_id so superseded Xendit sessions still resolve on webhook. +CREATE TABLE "billing_checkout_sessions" ( + "provider_reference" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "workspace_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "billing_checkout_sessions_pkey" PRIMARY KEY ("provider_reference") +); + +CREATE INDEX "billing_checkout_sessions_workspace_id_created_at_idx" + ON "billing_checkout_sessions"("workspace_id", "created_at"); + +ALTER TABLE "billing_checkout_sessions" ADD CONSTRAINT "billing_checkout_sessions_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "billing_checkout_sessions" ADD CONSTRAINT "billing_checkout_sessions_workspace_id_fkey" + FOREIGN KEY ("workspace_id") REFERENCES "organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +INSERT INTO "billing_checkout_sessions" ("provider_reference", "user_id", "workspace_id", "created_at") +SELECT "provider_reference", "user_id", "workspace_id", COALESCE("created_at", CURRENT_TIMESTAMP) +FROM "subscriptions" +WHERE "provider_reference" IS NOT NULL; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index dbdf5ea7..cd040955 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -29,6 +29,7 @@ model User { actionAutoReviewPreferences ActionAutoReviewPreference[] devices Device[] subscriptions Subscription[] + billingCheckoutSessions BillingCheckoutSession[] paymentEvents PaymentEvent[] usageReservations UsageReservation[] usageLedger UsageLedger[] @@ -125,6 +126,7 @@ model Organization { runtimes Runtime[] keyEnvelopes KeyEnvelope[] subscriptions Subscription[] + billingCheckoutSessions BillingCheckoutSession[] paymentEvents PaymentEvent[] usageReservations UsageReservation[] usageLedger UsageLedger[] @@ -202,6 +204,19 @@ model Subscription { @@map("subscriptions") } +/// Immutable lookup from Xendit reference_id to workspace for every checkout started. +model BillingCheckoutSession { + providerReference String @id @map("provider_reference") + userId String @map("user_id") + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + workspaceId String @map("workspace_id") + workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) @map("created_at") + + @@index([workspaceId, createdAt]) + @@map("billing_checkout_sessions") +} + model PaymentEvent { id String @id @default(cuid()) userId String @map("user_id") diff --git a/packages/db/src/platform.test.ts b/packages/db/src/platform.test.ts index 986253bf..1a48b7a1 100644 --- a/packages/db/src/platform.test.ts +++ b/packages/db/src/platform.test.ts @@ -473,4 +473,54 @@ describe("platform control-plane repositories", () => { data: { status: "released", finalizedAt: new Date("2026-09-02T00:00:00.000Z") }, }); }); + + it("resolves payment targets for superseded checkout references", async () => { + const { findPaymentTargetByProviderReference } = await import("./platform.js"); + const findUniqueSession = vi.fn().mockResolvedValue({ + userId: "user-1", + workspaceId: "workspace-1", + }); + const subscriptionFindUnique = vi.fn(); + const db = { + billingCheckoutSession: { findUnique: findUniqueSession }, + subscription: { findUnique: subscriptionFindUnique }, + }; + + await expect(findPaymentTargetByProviderReference(db as any, "checkout-old")).resolves.toEqual({ + userId: "user-1", + workspaceId: "workspace-1", + }); + expect(subscriptionFindUnique).not.toHaveBeenCalled(); + }); + + it("records every checkout reference when a workspace starts a new session", async () => { + const { beginCheckout } = await import("./platform.js"); + const createSession = vi.fn().mockResolvedValue(undefined); + const upsert = vi.fn().mockResolvedValue(undefined); + const db = { + $transaction: async (steps: unknown[]) => { + for (const step of steps) { + if (typeof step === "object" && step !== null && "then" in step) await step; + } + }, + billingCheckoutSession: { create: createSession }, + subscription: { upsert }, + }; + + await beginCheckout(db as any, { + userId: "user-1", + workspaceId: "workspace-1", + provider: "xendit", + providerReference: "checkout-2", + }); + + expect(createSession).toHaveBeenCalledWith({ + data: { + providerReference: "checkout-2", + userId: "user-1", + workspaceId: "workspace-1", + }, + }); + expect(upsert).toHaveBeenCalledOnce(); + }); }); diff --git a/packages/db/src/platform.ts b/packages/db/src/platform.ts index 8466f11e..0439e653 100644 --- a/packages/db/src/platform.ts +++ b/packages/db/src/platform.ts @@ -524,6 +524,11 @@ export async function tombstoneEncryptedSyncObject( } export async function findPaymentTargetByProviderReference(db: Db, providerReference: string) { + const session = await db.billingCheckoutSession.findUnique({ + where: { providerReference }, + select: { userId: true, workspaceId: true }, + }); + if (session) return session; return db.subscription.findUnique({ where: { providerReference }, select: { userId: true, workspaceId: true }, @@ -534,24 +539,33 @@ export async function beginCheckout( db: Db, input: { userId: string; workspaceId: string; provider: string; providerReference: string }, ): Promise { - await db.subscription.upsert({ - where: { workspaceId: input.workspaceId }, - create: { - userId: input.userId, - workspaceId: input.workspaceId, - planCode: "plus", - state: "checkout_pending", - provider: input.provider, - providerReference: input.providerReference, - }, - update: { - planCode: "plus", - state: "checkout_pending", - provider: input.provider, - providerReference: input.providerReference, - graceEndsAt: null, - }, - }); + await db.$transaction([ + db.billingCheckoutSession.create({ + data: { + providerReference: input.providerReference, + userId: input.userId, + workspaceId: input.workspaceId, + }, + }), + db.subscription.upsert({ + where: { workspaceId: input.workspaceId }, + create: { + userId: input.userId, + workspaceId: input.workspaceId, + planCode: "plus", + state: "checkout_pending", + provider: input.provider, + providerReference: input.providerReference, + }, + update: { + planCode: "plus", + state: "checkout_pending", + provider: input.provider, + providerReference: input.providerReference, + graceEndsAt: null, + }, + }), + ]); } interface PlatformTransaction {