Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
15 changes: 15 additions & 0 deletions packages/db/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ model User {
actionAutoReviewPreferences ActionAutoReviewPreference[]
devices Device[]
subscriptions Subscription[]
billingCheckoutSessions BillingCheckoutSession[]
paymentEvents PaymentEvent[]
usageReservations UsageReservation[]
usageLedger UsageLedger[]
Expand Down Expand Up @@ -125,6 +126,7 @@ model Organization {
runtimes Runtime[]
keyEnvelopes KeyEnvelope[]
subscriptions Subscription[]
billingCheckoutSessions BillingCheckoutSession[]
paymentEvents PaymentEvent[]
usageReservations UsageReservation[]
usageLedger UsageLedger[]
Expand Down Expand Up @@ -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")
Expand Down
50 changes: 50 additions & 0 deletions packages/db/src/platform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
50 changes: 32 additions & 18 deletions packages/db/src/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -534,24 +539,33 @@ export async function beginCheckout(
db: Db,
input: { userId: string; workspaceId: string; provider: string; providerReference: string },
): Promise<void> {
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 {
Expand Down
Loading