Procházet zdrojové kódy

fix(console): recover delayed Go subscriptions

Jack před 2 týdny
rodič
revize
6cc915bcf7

+ 40 - 0
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<Stripe.Checkout.Session, "mode" | "payment_status" | "subscription">,
+) {
+  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,
+  }
+}

+ 101 - 79
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)
+    })
+  })
+}

+ 66 - 0
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()
+})