From 585df6864d911501ce8c45427d58a7d97bf15e1f Mon Sep 17 00:00:00 2001 From: Jack <19261047+MrMushrooooom@users.noreply.github.com> Date: Sat, 1 Aug 2026 06:39:12 +0000 Subject: [PATCH] fix(console): recover canceled Go access --- .../app/script/recover-lite-cancellations.ts | 74 +++++++++++ .../src/routes/stripe/lite-cancellation.ts | 50 ++++++++ .../console/app/src/routes/stripe/webhook.ts | 29 ++--- .../console/app/test/liteCancellation.test.ts | 115 ++++++++++++++++++ 4 files changed, 247 insertions(+), 21 deletions(-) create mode 100644 packages/console/app/script/recover-lite-cancellations.ts create mode 100644 packages/console/app/src/routes/stripe/lite-cancellation.ts create mode 100644 packages/console/app/test/liteCancellation.test.ts diff --git a/packages/console/app/script/recover-lite-cancellations.ts b/packages/console/app/script/recover-lite-cancellations.ts new file mode 100644 index 0000000000..ee65919ef5 --- /dev/null +++ b/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= --before= [--apply --expect=]") + +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) +} diff --git a/packages/console/app/src/routes/stripe/lite-cancellation.ts b/packages/console/app/src/routes/stripe/lite-cancellation.ts new file mode 100644 index 0000000000..9ade01f48e --- /dev/null +++ b/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 + items: { + data: Array<{ + price: { + product: string | { id: string } + } + }> + } +} + +type Actions = { + liteProductID: string + blackProductID: string + unsubscribeLite(subscriptionID: string): Promise + unsubscribeBlack(subscriptionID: string): Promise +} + +export function subscriptionKind( + subscription: Subscription, + products: Pick, +) { + 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") +} diff --git a/packages/console/app/src/routes/stripe/webhook.ts b/packages/console/app/src/routes/stripe/webhook.ts index e1e4e6cbd3..edb076f181 100644 --- a/packages/console/app/src/routes/stripe/webhook.ts +++ b/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 ( diff --git a/packages/console/app/test/liteCancellation.test.ts b/packages/console/app/test/liteCancellation.test.ts new file mode 100644 index 0000000000..b636df51b7 --- /dev/null +++ b/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") +})