Compare commits

...

1 Commits

Author SHA1 Message Date
Jack 6cc915bcf7 fix(console): recover delayed Go subscriptions 2026-07-30 09:32:06 +00:00
3 changed files with 207 additions and 79 deletions
@@ -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
View File
@@ -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)
})
})
}
@@ -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()
})