Bläddra i källkod

fix(console): recover canceled Go access

Jack 2 veckor sedan
förälder
incheckning
585df6864d

+ 74 - 0
packages/console/app/script/recover-lite-cancellations.ts

@@ -0,0 +1,74 @@
+import type { Stripe } from "stripe"
+import { Billing } from "@opencode-ai/console-core/billing.js"
+import { BlackData } from "@opencode-ai/console-core/black.js"
+import { Database, and, inArray, isNotNull } from "@opencode-ai/console-core/drizzle/index.js"
+import { LiteData } from "@opencode-ai/console-core/lite.js"
+import { BillingTable } from "@opencode-ai/console-core/schema/billing.sql.js"
+import { subscriptionKind } from "../src/routes/stripe/lite-cancellation.js"
+
+const after = option("after")
+const before = option("before")
+if (!after || !before) throw new Error("Usage: recover-lite-cancellations.ts --after=<ISO> --before=<ISO> [--apply --expect=<count>]")
+
+const start = new Date(after)
+const end = new Date(before)
+if (!Number.isFinite(start.getTime()) || !Number.isFinite(end.getTime())) throw new Error("Invalid recovery window")
+if (start >= end) throw new Error("Recovery window must end after it starts")
+if (end.getTime() - start.getTime() > 24 * 60 * 60 * 1000) throw new Error("Recovery window cannot exceed 24 hours")
+
+const subscriptionIDs: string[] = []
+for await (const event of Billing.stripe().events.list({
+  type: "customer.subscription.deleted",
+  created: {
+    gte: Math.floor(start.getTime() / 1000),
+    lt: Math.floor(end.getTime() / 1000),
+  },
+  limit: 100,
+})) {
+  const subscription = event.data.object as Stripe.Subscription
+  if (
+    subscriptionKind(subscription, {
+      liteProductID: LiteData.productID(),
+      blackProductID: BlackData.productID(),
+    }) !== "lite"
+  )
+    continue
+  subscriptionIDs.push(subscription.id)
+}
+
+const stale = subscriptionIDs.length
+  ? await Database.use((tx) =>
+      tx
+        .select({ subscriptionID: BillingTable.liteSubscriptionID })
+        .from(BillingTable)
+        .where(and(isNotNull(BillingTable.liteSubscriptionID), inArray(BillingTable.liteSubscriptionID, subscriptionIDs)))
+        .then((rows) => rows.map((row) => row.subscriptionID).filter((value): value is string => !!value)),
+    )
+  : []
+
+const apply = process.argv.includes("--apply")
+const expected = option("expect")
+if (!apply) {
+  console.log(JSON.stringify({ after: start.toISOString(), before: end.toISOString(), deletedGoSubscriptions: subscriptionIDs.length, staleEntitlements: stale.length, applied: false }))
+  process.exit(0)
+}
+if (!expected || Number(expected) !== stale.length) {
+  throw new Error(`Expected stale entitlement count must equal ${stale.length}`)
+}
+
+let cursor = 0
+await Promise.all(
+  Array.from({ length: Math.min(4, stale.length) }, async () => {
+    while (true) {
+      const index = cursor++
+      if (index >= stale.length) return
+      await Billing.unsubscribeLite({ subscriptionID: stale[index] })
+    }
+  }),
+)
+
+console.log(JSON.stringify({ after: start.toISOString(), before: end.toISOString(), deletedGoSubscriptions: subscriptionIDs.length, staleEntitlements: stale.length, applied: true }))
+
+function option(name: string) {
+  return process.argv.find((value) => value.startsWith(`--${name}=`))?.slice(name.length + 3)
+}

+ 50 - 0
packages/console/app/src/routes/stripe/lite-cancellation.ts

@@ -0,0 +1,50 @@
+type SubscriptionEventType = "customer.subscription.deleted" | "customer.subscription.updated"
+
+type Subscription = {
+  id: string
+  status: string
+  metadata: Record<string, string>
+  items: {
+    data: Array<{
+      price: {
+        product: string | { id: string }
+      }
+    }>
+  }
+}
+
+type Actions = {
+  liteProductID: string
+  blackProductID: string
+  unsubscribeLite(subscriptionID: string): Promise<unknown>
+  unsubscribeBlack(subscriptionID: string): Promise<unknown>
+}
+
+export function subscriptionKind(
+  subscription: Subscription,
+  products: Pick<Actions, "liteProductID" | "blackProductID">,
+) {
+  const product = subscription.items.data[0]?.price.product
+  const productID = typeof product === "string" ? product : product?.id
+  if (subscription.metadata.type === "lite" || productID === products.liteProductID) return "lite"
+  if (productID === products.blackProductID) return "black"
+}
+
+export async function handleSubscriptionCancellation(
+  type: SubscriptionEventType,
+  subscription: Subscription,
+  actions: Actions,
+) {
+  if (type === "customer.subscription.updated" && subscription.status !== "incomplete_expired") return false
+
+  const kind = subscriptionKind(subscription, actions)
+  if (kind === "lite") {
+    await actions.unsubscribeLite(subscription.id)
+    return true
+  }
+  if (kind === "black") {
+    await actions.unsubscribeBlack(subscription.id)
+    return true
+  }
+  throw new Error("Unknown subscription product")
+}

+ 8 - 21
packages/console/app/src/routes/stripe/webhook.ts

@@ -10,6 +10,7 @@ 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 { handleSubscriptionCancellation } from "./lite-cancellation.js"
 
 export async function POST(input: APIEvent) {
   const body = await Billing.stripe().webhooks.constructEventAsync(
@@ -184,27 +185,13 @@ export async function POST(input: APIEvent) {
         })
       }
     }
-    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")
-
-      const productID = body.data.object.items.data[0].price.product as string
-      if (productID === LiteData.productID()) {
-        await Billing.unsubscribeLite({ subscriptionID })
-      } else if (productID === BlackData.productID()) {
-        await Billing.unsubscribeBlack({ subscriptionID })
-      }
-    }
-    if (body.type === "customer.subscription.deleted") {
-      const subscriptionID = body.data.object.id
-      if (!subscriptionID) throw new Error("Subscription ID not found")
-
-      const productID = body.data.object.items.data[0].price.product as string
-      if (productID === LiteData.productID()) {
-        await Billing.unsubscribeLite({ subscriptionID })
-      } else if (productID === BlackData.productID()) {
-        await Billing.unsubscribeBlack({ subscriptionID })
-      }
+    if (body.type === "customer.subscription.updated" || body.type === "customer.subscription.deleted") {
+      await handleSubscriptionCancellation(body.type, body.data.object, {
+        liteProductID: LiteData.productID(),
+        blackProductID: BlackData.productID(),
+        unsubscribeLite: (subscriptionID) => Billing.unsubscribeLite({ subscriptionID }),
+        unsubscribeBlack: (subscriptionID) => Billing.unsubscribeBlack({ subscriptionID }),
+      })
     }
     if (body.type === "invoice.payment_succeeded") {
       if (

+ 115 - 0
packages/console/app/test/liteCancellation.test.ts

@@ -0,0 +1,115 @@
+import { expect, test } from "bun:test"
+import { handleSubscriptionCancellation } from "../src/routes/stripe/lite-cancellation"
+
+test("revokes a deleted Go subscription from metadata even when the product ID changed", async () => {
+  const revoked: string[] = []
+
+  expect(
+    await handleSubscriptionCancellation(
+      "customer.subscription.deleted",
+      {
+        id: "sub_go",
+        status: "canceled",
+        metadata: { type: "lite" },
+        items: { data: [{ price: { product: "prod_old" } }] },
+      },
+      {
+        liteProductID: "prod_current",
+        blackProductID: "prod_black",
+        unsubscribeLite: async (subscriptionID) => revoked.push(subscriptionID),
+        unsubscribeBlack: async () => {},
+      },
+    ),
+  ).toBe(true)
+  expect(revoked).toEqual(["sub_go"])
+})
+
+test("ignores active subscription updates", async () => {
+  expect(
+    await handleSubscriptionCancellation(
+      "customer.subscription.updated",
+      {
+        id: "sub_go",
+        status: "active",
+        metadata: { type: "lite" },
+        items: { data: [{ price: { product: "prod_go" } }] },
+      },
+      {
+        liteProductID: "prod_go",
+        blackProductID: "prod_black",
+        unsubscribeLite: async () => {
+          throw new Error("unexpected unsubscribe")
+        },
+        unsubscribeBlack: async () => {
+          throw new Error("unexpected unsubscribe")
+        },
+      },
+    ),
+  ).toBe(false)
+})
+
+test("revokes an incomplete expired Go subscription update", async () => {
+  const revoked: string[] = []
+
+  expect(
+    await handleSubscriptionCancellation(
+      "customer.subscription.updated",
+      {
+        id: "sub_go",
+        status: "incomplete_expired",
+        metadata: { type: "lite" },
+        items: { data: [{ price: { product: "prod_go" } }] },
+      },
+      {
+        liteProductID: "prod_go",
+        blackProductID: "prod_black",
+        unsubscribeLite: async (subscriptionID) => revoked.push(subscriptionID),
+        unsubscribeBlack: async () => {},
+      },
+    ),
+  ).toBe(true)
+  expect(revoked).toEqual(["sub_go"])
+})
+
+test("preserves Black cancellation handling", async () => {
+  const revoked: string[] = []
+
+  expect(
+    await handleSubscriptionCancellation(
+      "customer.subscription.deleted",
+      {
+        id: "sub_black",
+        status: "canceled",
+        metadata: {},
+        items: { data: [{ price: { product: "prod_black" } }] },
+      },
+      {
+        liteProductID: "prod_go",
+        blackProductID: "prod_black",
+        unsubscribeLite: async () => {},
+        unsubscribeBlack: async (subscriptionID) => revoked.push(subscriptionID),
+      },
+    ),
+  ).toBe(true)
+  expect(revoked).toEqual(["sub_black"])
+})
+
+test("rejects deleted subscriptions that cannot be classified", async () => {
+  expect(
+    handleSubscriptionCancellation(
+      "customer.subscription.deleted",
+      {
+        id: "sub_unknown",
+        status: "canceled",
+        metadata: {},
+        items: { data: [{ price: { product: "prod_unknown" } }] },
+      },
+      {
+        liteProductID: "prod_go",
+        blackProductID: "prod_black",
+        unsubscribeLite: async () => {},
+        unsubscribeBlack: async () => {},
+      },
+    ),
+  ).rejects.toThrow("Unknown subscription product")
+})