Compare commits
1 commit
dev
...
go-cancel-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
585df6864d |
4 changed files with 247 additions and 21 deletions
74
packages/console/app/script/recover-lite-cancellations.ts
Normal file
74
packages/console/app/script/recover-lite-cancellations.ts
Normal file
|
|
@ -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
packages/console/app/src/routes/stripe/lite-cancellation.ts
Normal file
50
packages/console/app/src/routes/stripe/lite-cancellation.ts
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ import { Resource } from "@opencode-ai/console-resource"
|
||||||
import { LiteData } from "@opencode-ai/console-core/lite.js"
|
import { LiteData } from "@opencode-ai/console-core/lite.js"
|
||||||
import { BlackData } from "@opencode-ai/console-core/black.js"
|
import { BlackData } from "@opencode-ai/console-core/black.js"
|
||||||
import { Referral } from "@opencode-ai/console-core/referral.js"
|
import { Referral } from "@opencode-ai/console-core/referral.js"
|
||||||
|
import { handleSubscriptionCancellation } from "./lite-cancellation.js"
|
||||||
|
|
||||||
export async function POST(input: APIEvent) {
|
export async function POST(input: APIEvent) {
|
||||||
const body = await Billing.stripe().webhooks.constructEventAsync(
|
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") {
|
if (body.type === "customer.subscription.updated" || body.type === "customer.subscription.deleted") {
|
||||||
const subscriptionID = body.data.object.id
|
await handleSubscriptionCancellation(body.type, body.data.object, {
|
||||||
if (!subscriptionID) throw new Error("Subscription ID not found")
|
liteProductID: LiteData.productID(),
|
||||||
|
blackProductID: BlackData.productID(),
|
||||||
const productID = body.data.object.items.data[0].price.product as string
|
unsubscribeLite: (subscriptionID) => Billing.unsubscribeLite({ subscriptionID }),
|
||||||
if (productID === LiteData.productID()) {
|
unsubscribeBlack: (subscriptionID) => Billing.unsubscribeBlack({ subscriptionID }),
|
||||||
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 === "invoice.payment_succeeded") {
|
if (body.type === "invoice.payment_succeeded") {
|
||||||
if (
|
if (
|
||||||
|
|
|
||||||
115
packages/console/app/test/liteCancellation.test.ts
Normal file
115
packages/console/app/test/liteCancellation.test.ts
Normal file
|
|
@ -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")
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue