From 6cc915bcf73abcbfc668f52c40350617098ac8cb Mon Sep 17 00:00:00 2001 From: Jack <19261047+MrMushrooooom@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:32:06 +0000 Subject: [PATCH] fix(console): recover delayed Go subscriptions --- .../src/routes/stripe/lite-subscription.ts | 40 ++++ .../console/app/src/routes/stripe/webhook.ts | 180 ++++++++++-------- .../console/app/test/liteSubscription.test.ts | 66 +++++++ 3 files changed, 207 insertions(+), 79 deletions(-) create mode 100644 packages/console/app/src/routes/stripe/lite-subscription.ts create mode 100644 packages/console/app/test/liteSubscription.test.ts diff --git a/packages/console/app/src/routes/stripe/lite-subscription.ts b/packages/console/app/src/routes/stripe/lite-subscription.ts new file mode 100644 index 0000000000..a2576e8697 --- /dev/null +++ b/packages/console/app/src/routes/stripe/lite-subscription.ts @@ -0,0 +1,40 @@ +import type { Stripe } from "stripe" + +export function liteCheckoutSubscriptionToProvision( + type: "checkout.session.completed" | "checkout.session.async_payment_succeeded", + session: Pick, +) { + if (session.mode !== "subscription") return + if (typeof session.subscription !== "string") return + if (type === "checkout.session.completed" && session.payment_status === "unpaid") return + return session.subscription +} + +export function liteSubscriptionProvisioningData( + subscription: Pick< + Stripe.Subscription, + "id" | "status" | "customer" | "default_payment_method" | "metadata" + >, +) { + if (subscription.metadata.type !== "lite") return + if (subscription.status !== "active" && subscription.status !== "trialing") return + if (typeof subscription.customer !== "string") throw new Error("Customer ID not found") + + const workspaceID = subscription.metadata.workspaceID + const userID = subscription.metadata.userID + if (!workspaceID) throw new Error("Workspace ID not found") + if (!userID) throw new Error("User ID not found") + + return { + subscriptionID: subscription.id, + customerID: subscription.customer, + workspaceID, + userID, + userEmail: subscription.metadata.userEmail || undefined, + coupon: subscription.metadata.coupon || undefined, + paymentMethodID: + typeof subscription.default_payment_method === "string" + ? subscription.default_payment_method + : subscription.default_payment_method?.id, + } +} diff --git a/packages/console/app/src/routes/stripe/webhook.ts b/packages/console/app/src/routes/stripe/webhook.ts index e1e4e6cbd3..62e21573f1 100644 --- a/packages/console/app/src/routes/stripe/webhook.ts +++ b/packages/console/app/src/routes/stripe/webhook.ts @@ -10,6 +10,10 @@ import { Resource } from "@opencode-ai/console-resource" import { LiteData } from "@opencode-ai/console-core/lite.js" import { BlackData } from "@opencode-ai/console-core/black.js" import { Referral } from "@opencode-ai/console-core/referral.js" +import { + liteCheckoutSubscriptionToProvision, + liteSubscriptionProvisioningData, +} from "./lite-subscription.js" export async function POST(input: APIEvent) { const body = await Billing.stripe().webhooks.constructEventAsync( @@ -20,6 +24,10 @@ export async function POST(input: APIEvent) { console.log(body.type, JSON.stringify(body, null, 2)) return (async () => { + if (body.type === "checkout.session.completed" || body.type === "checkout.session.async_payment_succeeded") { + const subscriptionID = liteCheckoutSubscriptionToProvision(body.type, body.data.object) + if (subscriptionID) await provisionLiteSubscription(subscriptionID) + } if (body.type === "customer.updated") { // check default payment method changed const prevInvoiceSettings = body.data.previous_attributes?.invoice_settings ?? {} @@ -105,85 +113,6 @@ export async function POST(input: APIEvent) { }) }) } - if (body.type === "customer.subscription.created") { - const type = body.data.object.metadata?.type - if (type === "lite") { - const workspaceID = body.data.object.metadata?.workspaceID - const userID = body.data.object.metadata?.userID - const userEmail = body.data.object.metadata?.userEmail - const coupon = body.data.object.metadata?.coupon - const customerID = body.data.object.customer as string - const invoiceID = body.data.object.latest_invoice as string - const subscriptionID = body.data.object.id as string - const paymentMethodID = body.data.object.default_payment_method as string - - if (!workspaceID) throw new Error("Workspace ID not found") - if (!userID) throw new Error("User ID not found") - if (!customerID) throw new Error("Customer ID not found") - if (!invoiceID) throw new Error("Invoice ID not found") - if (!subscriptionID) throw new Error("Subscription ID not found") - if (!paymentMethodID) throw new Error("Payment method ID not found") - - // get payment method for the payment intent - const paymentMethod = await Billing.stripe().paymentMethods.retrieve(paymentMethodID) - await Actor.provide("system", { workspaceID }, async () => { - // look up current billing - const billing = await Billing.get() - if (!billing) throw new Error(`Workspace with ID ${workspaceID} not found`) - if (billing.customerID && billing.customerID !== customerID) throw new Error("Customer ID mismatch") - - // set customer metadata - if (!billing?.customerID) { - await Billing.stripe().customers.update(customerID, { - metadata: { - workspaceID, - }, - }) - } - - await Database.transaction(async (tx) => { - await tx - .update(BillingTable) - .set({ - customerID, - liteSubscriptionID: subscriptionID, - lite: {}, - paymentMethodID: paymentMethod.id, - paymentMethodLast4: paymentMethod.card?.last4 ?? null, - paymentMethodType: paymentMethod.type, - }) - .where(eq(BillingTable.workspaceID, workspaceID)) - - await tx.insert(LiteTable).values({ - workspaceID, - id: Identifier.create("lite"), - userID: userID, - }) - - if (userEmail) { - if (coupon === LiteData.firstMonth50Coupon) { - await Billing.redeemCoupon(userEmail, "GO1MONTH50") - } else if (coupon === LiteData.firstMonth100Coupon) { - await Billing.redeemCoupon(userEmail, "GOFREEMONTH") - } else if (coupon === LiteData.threeMonths100Coupon) { - await Billing.redeemCoupon(userEmail, "GO3MONTHS100") - } else if (coupon === LiteData.sixMonths100Coupon) { - await Billing.redeemCoupon(userEmail, "GO6MONTHS100") - } else if (coupon === LiteData.twelveMonths100Coupon) { - await Billing.redeemCoupon(userEmail, "GO12MONTHS100") - } - } - }) - - await Referral.completeFromLiteSubscription({ - workspaceID, - userID, - }).catch((error) => { - console.error("Referral sync failed", error) - }) - }) - } - } if (body.type === "customer.subscription.updated" && body.data.object.status === "incomplete_expired") { const subscriptionID = body.data.object.id if (!subscriptionID) throw new Error("Subscription ID not found") @@ -221,6 +150,10 @@ export async function POST(input: APIEvent) { if (!invoiceID) throw new Error("Invoice ID not found") if (!subscriptionID) throw new Error("Subscription ID not found") + if (body.data.object.billing_reason === "subscription_create" && productID === LiteData.productID()) { + await provisionLiteSubscription(subscriptionID) + } + // get coupon id from subscription const invoice = await Billing.stripe().invoices.retrieve(invoiceID, { expand: ["discounts", "payments"], @@ -378,3 +311,92 @@ export async function POST(input: APIEvent) { return Response.json({ message: error.message }, { status: 500 }) }) } + +async function provisionLiteSubscription(subscriptionID: string) { + const stripe = Billing.stripe() + const subscription = liteSubscriptionProvisioningData(await stripe.subscriptions.retrieve(subscriptionID)) + if (!subscription) return + + await Actor.provide("system", { workspaceID: subscription.workspaceID }, async () => { + const billing = await Billing.get() + if (!billing) throw new Error(`Workspace with ID ${subscription.workspaceID} not found`) + if (billing.customerID && billing.customerID !== subscription.customerID) throw new Error("Customer ID mismatch") + if (billing.liteSubscriptionID === subscription.subscriptionID) return + + const paymentMethod = subscription.paymentMethodID + ? await stripe.paymentMethods.retrieve(subscription.paymentMethodID) + : undefined + + if (!billing.customerID) { + await stripe.customers.update(subscription.customerID, { + metadata: { + workspaceID: subscription.workspaceID, + }, + }) + } + + const provisioned = await Database.transaction(async (tx) => { + const current = await tx + .select({ + customerID: BillingTable.customerID, + liteSubscriptionID: BillingTable.liteSubscriptionID, + }) + .from(BillingTable) + .where(eq(BillingTable.workspaceID, subscription.workspaceID)) + .for("update") + .then((rows) => rows[0]) + if (!current) throw new Error(`Workspace with ID ${subscription.workspaceID} not found`) + if (current.customerID && current.customerID !== subscription.customerID) throw new Error("Customer ID mismatch") + if (current.liteSubscriptionID === subscription.subscriptionID) return false + if (current.liteSubscriptionID) throw new Error("Workspace already has another Go subscription") + + await tx + .update(BillingTable) + .set({ + customerID: subscription.customerID, + liteSubscriptionID: subscription.subscriptionID, + lite: {}, + ...(paymentMethod + ? { + paymentMethodID: paymentMethod.id, + paymentMethodLast4: paymentMethod.card?.last4 ?? null, + paymentMethodType: paymentMethod.type, + } + : {}), + }) + .where(eq(BillingTable.workspaceID, subscription.workspaceID)) + + await tx + .insert(LiteTable) + .ignore() + .values({ + workspaceID: subscription.workspaceID, + id: Identifier.create("lite"), + userID: subscription.userID, + }) + + if (subscription.userEmail) { + if (subscription.coupon === LiteData.firstMonth50Coupon) { + await Billing.redeemCoupon(subscription.userEmail, "GO1MONTH50") + } else if (subscription.coupon === LiteData.firstMonth100Coupon) { + await Billing.redeemCoupon(subscription.userEmail, "GOFREEMONTH") + } else if (subscription.coupon === LiteData.threeMonths100Coupon) { + await Billing.redeemCoupon(subscription.userEmail, "GO3MONTHS100") + } else if (subscription.coupon === LiteData.sixMonths100Coupon) { + await Billing.redeemCoupon(subscription.userEmail, "GO6MONTHS100") + } else if (subscription.coupon === LiteData.twelveMonths100Coupon) { + await Billing.redeemCoupon(subscription.userEmail, "GO12MONTHS100") + } + } + return true + }) + if (!provisioned) return + + await Referral.completeFromLiteSubscription({ + workspaceID: subscription.workspaceID, + userID: subscription.userID, + }).catch((error) => { + console.error("Referral sync failed", error) + }) + }) +} diff --git a/packages/console/app/test/liteSubscription.test.ts b/packages/console/app/test/liteSubscription.test.ts new file mode 100644 index 0000000000..5be75e88ee --- /dev/null +++ b/packages/console/app/test/liteSubscription.test.ts @@ -0,0 +1,66 @@ +import { expect, test } from "bun:test" +import { + liteCheckoutSubscriptionToProvision, + liteSubscriptionProvisioningData, +} from "../src/routes/stripe/lite-subscription" + +test("waits for an unpaid Checkout Session's asynchronous payment", () => { + const session = { + mode: "subscription" as const, + payment_status: "unpaid" as const, + subscription: "sub_go", + } + + expect(liteCheckoutSubscriptionToProvision("checkout.session.completed", session)).toBeUndefined() + expect(liteCheckoutSubscriptionToProvision("checkout.session.async_payment_succeeded", session)).toBe("sub_go") +}) + +test("provisions a paid Checkout Session immediately", () => { + expect( + liteCheckoutSubscriptionToProvision("checkout.session.completed", { + mode: "subscription", + payment_status: "paid", + subscription: "sub_go", + }), + ).toBe("sub_go") +}) + +test("provisions an active Go subscription without a default payment method", () => { + expect( + liteSubscriptionProvisioningData({ + id: "sub_go", + status: "active", + customer: "cus_go", + default_payment_method: null, + metadata: { + type: "lite", + workspaceID: "wrk_go", + userID: "usr_go", + }, + }), + ).toEqual({ + subscriptionID: "sub_go", + customerID: "cus_go", + workspaceID: "wrk_go", + userID: "usr_go", + userEmail: undefined, + coupon: undefined, + paymentMethodID: undefined, + }) +}) + +test("waits for an asynchronous Go subscription to become active", () => { + expect( + liteSubscriptionProvisioningData({ + id: "sub_go", + status: "incomplete", + customer: "cus_go", + default_payment_method: null, + metadata: { + type: "lite", + workspaceID: "wrk_go", + userID: "usr_go", + }, + }), + ).toBeUndefined() +})