fix(console): recover delayed Go subscriptions
This commit is contained in:
parent
c08350b83e
commit
6cc915bcf7
3 changed files with 207 additions and 79 deletions
40
packages/console/app/src/routes/stripe/lite-subscription.ts
Normal file
40
packages/console/app/src/routes/stripe/lite-subscription.ts
Normal file
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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
packages/console/app/test/liteSubscription.test.ts
Normal file
66
packages/console/app/test/liteSubscription.test.ts
Normal file
|
|
@ -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()
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue