summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorFrank <[email protected]>2026-01-09 11:52:30 -0500
committerFrank <[email protected]>2026-01-09 11:52:31 -0500
commit18cf4df6c6a7175334485252c4c772921f07f93f (patch)
tree65607eeb3ca51cd1e343dc4cdc69866eb87e3a12
parentf3e8a275b8292609600c5634a9a9c52fa776f678 (diff)
downloadopencode-18cf4df6c6a7175334485252c4c772921f07f93f.tar.gz
opencode-18cf4df6c6a7175334485252c4c772921f07f93f.zip
wip: zen
-rw-r--r--packages/console/app/src/routes/stripe/webhook.ts269
1 files changed, 200 insertions, 69 deletions
diff --git a/packages/console/app/src/routes/stripe/webhook.ts b/packages/console/app/src/routes/stripe/webhook.ts
index 6f1637641..3422d9dd6 100644
--- a/packages/console/app/src/routes/stripe/webhook.ts
+++ b/packages/console/app/src/routes/stripe/webhook.ts
@@ -1,11 +1,13 @@
import { Billing } from "@opencode-ai/console-core/billing.js"
import type { APIEvent } from "@solidjs/start/server"
-import { and, Database, eq, sql } from "@opencode-ai/console-core/drizzle/index.js"
+import { and, Database, eq, isNull, sql } from "@opencode-ai/console-core/drizzle/index.js"
import { BillingTable, PaymentTable, SubscriptionTable } from "@opencode-ai/console-core/schema/billing.sql.js"
import { Identifier } from "@opencode-ai/console-core/identifier.js"
import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { Resource } from "@opencode-ai/console-resource"
+import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
+import { AuthTable } from "@opencode-ai/console-core/schema/auth.sql.js"
export async function POST(input: APIEvent) {
const body = await Billing.stripe().webhooks.constructEventAsync(
@@ -39,7 +41,7 @@ export async function POST(input: APIEvent) {
.where(eq(BillingTable.customerID, customerID))
})
}
- if (body.type === "checkout.session.completed") {
+ if (body.type === "checkout.session.completed" && body.data.object.mode === "payment") {
const workspaceID = body.data.object.metadata?.workspaceID
const amountInCents = body.data.object.metadata?.amount && parseInt(body.data.object.metadata?.amount)
const customerID = body.data.object.customer as string
@@ -102,85 +104,112 @@ export async function POST(input: APIEvent) {
})
})
}
- if (body.type === "charge.refunded") {
+ if (body.type === "checkout.session.completed" && body.data.object.mode === "subscription") {
+ const workspaceID = body.data.object.custom_fields.find((f) => f.key === "workspaceid")?.text?.value
+ const amountInCents = body.data.object.amount_total as number
const customerID = body.data.object.customer as string
- const paymentIntentID = body.data.object.payment_intent as string
- if (!customerID) throw new Error("Customer ID not found")
- if (!paymentIntentID) throw new Error("Payment ID not found")
+ const customerEmail = body.data.object.customer_details?.email as string
+ const invoiceID = body.data.object.invoice as string
+ const subscriptionID = body.data.object.subscription as string
+ const promoCode = body.data.object.discounts?.[0]?.promotion_code as string
- const workspaceID = await Database.use((tx) =>
- tx
- .select({
- workspaceID: BillingTable.workspaceID,
- })
- .from(BillingTable)
- .where(eq(BillingTable.customerID, customerID))
- .then((rows) => rows[0]?.workspaceID),
- )
if (!workspaceID) throw new Error("Workspace ID not found")
-
- const amount = await Database.use((tx) =>
- tx
- .select({
- amount: PaymentTable.amount,
- })
- .from(PaymentTable)
- .where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
- .then((rows) => rows[0]?.amount),
- )
- if (!amount) throw new Error("Payment not found")
-
- await Database.transaction(async (tx) => {
- await tx
- .update(PaymentTable)
- .set({
- timeRefunded: new Date(body.created * 1000),
- })
- .where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
-
- await tx
- .update(BillingTable)
- .set({
- balance: sql`${BillingTable.balance} - ${amount}`,
- })
- .where(eq(BillingTable.workspaceID, workspaceID))
- })
- }
- if (body.type === "invoice.payment_succeeded" && body.data.object.billing_reason === "subscription_cycle") {
- const invoiceID = body.data.object.id as string
- const amountInCents = body.data.object.amount_paid
- const customerID = body.data.object.customer as string
- const subscriptionID = body.data.object.parent?.subscription_details?.subscription as string
-
if (!customerID) throw new Error("Customer ID not found")
+ if (!amountInCents) throw new Error("Amount not found")
if (!invoiceID) throw new Error("Invoice ID not found")
if (!subscriptionID) throw new Error("Subscription ID not found")
+ // get payment id from invoice
const invoice = await Billing.stripe().invoices.retrieve(invoiceID, {
expand: ["payments"],
})
const paymentID = invoice.payments?.data[0].payment.payment_intent as string
if (!paymentID) throw new Error("Payment ID not found")
- const workspaceID = await Database.use((tx) =>
- tx
- .select({ workspaceID: BillingTable.workspaceID })
- .from(BillingTable)
- .where(eq(BillingTable.customerID, customerID))
- .then((rows) => rows[0]?.workspaceID),
- )
- if (!workspaceID) throw new Error("Workspace ID not found for customer")
-
- await Database.use((tx) =>
- tx.insert(PaymentTable).values({
- workspaceID,
- id: Identifier.create("payment"),
- amount: centsToMicroCents(amountInCents),
- paymentID,
- invoiceID,
- customerID,
- }),
- )
+ // get payment method for the payment intent
+ const paymentIntent = await Billing.stripe().paymentIntents.retrieve(paymentID, {
+ expand: ["payment_method"],
+ })
+ const paymentMethod = paymentIntent.payment_method
+ if (!paymentMethod || typeof paymentMethod === "string") throw new Error("Payment method not expanded")
+
+ // get coupon id from promotion code
+ const couponID = await (async () => {
+ if (!promoCode) return
+ const coupon = await Billing.stripe().promotionCodes.retrieve(promoCode)
+ const couponID = coupon.coupon.id
+ if (!couponID) throw new Error("Coupon not found for promotion code")
+ return couponID
+ })()
+
+ // get user
+
+ 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`)
+
+ // Temporarily skip this check because during Black drop, user can checkout
+ // as a new customer
+ //if (billing.customerID !== customerID) throw new Error("Customer ID mismatch")
+
+ // Temporarily check the user to apply to. After Black drop, we will allow
+ // look up the user to apply to
+ const users = await Database.use((tx) =>
+ tx
+ .select({ id: UserTable.id, email: AuthTable.subject })
+ .from(UserTable)
+ .innerJoin(AuthTable, and(eq(AuthTable.accountID, UserTable.accountID), eq(AuthTable.provider, "email")))
+ .where(and(eq(UserTable.workspaceID, workspaceID), isNull(UserTable.timeDeleted))),
+ )
+ const user = users.find((u) => u.email === customerEmail) ?? users[0]
+ if (!user) {
+ console.error(`Error: User with email ${customerEmail} not found in workspace ${workspaceID}`)
+ process.exit(1)
+ }
+
+ // 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,
+ subscriptionID,
+ subscriptionCouponID: couponID,
+ paymentMethodID: paymentMethod.id,
+ paymentMethodLast4: paymentMethod.card?.last4 ?? null,
+ paymentMethodType: paymentMethod.type,
+ })
+ .where(eq(BillingTable.workspaceID, workspaceID))
+
+ await tx.insert(SubscriptionTable).values({
+ workspaceID,
+ id: Identifier.create("subscription"),
+ userID: user.id,
+ })
+
+ await tx.insert(PaymentTable).values({
+ workspaceID,
+ id: Identifier.create("payment"),
+ amount: centsToMicroCents(amountInCents),
+ paymentID,
+ invoiceID,
+ customerID,
+ enrichment: {
+ type: "subscription",
+ couponID,
+ },
+ })
+ })
+ })
}
if (body.type === "customer.subscription.created") {
const data = {
@@ -377,11 +406,113 @@ export async function POST(input: APIEvent) {
if (!workspaceID) throw new Error("Workspace ID not found for subscription")
await Database.transaction(async (tx) => {
- await tx.update(BillingTable).set({ subscriptionID: null }).where(eq(BillingTable.workspaceID, workspaceID))
+ await tx
+ .update(BillingTable)
+ .set({ subscriptionID: null, subscriptionCouponID: null })
+ .where(eq(BillingTable.workspaceID, workspaceID))
await tx.delete(SubscriptionTable).where(eq(SubscriptionTable.workspaceID, workspaceID))
})
}
+ if (body.type === "invoice.payment_succeeded") {
+ if (body.data.object.billing_reason === "subscription_cycle") {
+ const invoiceID = body.data.object.id as string
+ const amountInCents = body.data.object.amount_paid
+ const customerID = body.data.object.customer as string
+ const subscriptionID = body.data.object.parent?.subscription_details?.subscription as string
+
+ 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")
+
+ // get coupon id from subscription
+ const subscriptionData = await Billing.stripe().subscriptions.retrieve(subscriptionID, {
+ expand: ["discounts"],
+ })
+ const couponID =
+ typeof subscriptionData.discounts[0] === "string"
+ ? subscriptionData.discounts[0]
+ : subscriptionData.discounts[0]?.coupon?.id
+
+ // get payment id from invoice
+ const invoice = await Billing.stripe().invoices.retrieve(invoiceID, {
+ expand: ["payments"],
+ })
+ const paymentID = invoice.payments?.data[0].payment.payment_intent as string
+ if (!paymentID) {
+ // payment id can be undefined when using coupon
+ if (!couponID) throw new Error("Payment ID not found")
+ }
+
+ const workspaceID = await Database.use((tx) =>
+ tx
+ .select({ workspaceID: BillingTable.workspaceID })
+ .from(BillingTable)
+ .where(eq(BillingTable.customerID, customerID))
+ .then((rows) => rows[0]?.workspaceID),
+ )
+ if (!workspaceID) throw new Error("Workspace ID not found for customer")
+
+ await Database.use((tx) =>
+ tx.insert(PaymentTable).values({
+ workspaceID,
+ id: Identifier.create("payment"),
+ amount: centsToMicroCents(amountInCents),
+ paymentID,
+ invoiceID,
+ customerID,
+ enrichment: {
+ type: "subscription",
+ couponID,
+ },
+ }),
+ )
+ }
+ }
+ if (body.type === "charge.refunded") {
+ const customerID = body.data.object.customer as string
+ const paymentIntentID = body.data.object.payment_intent as string
+ if (!customerID) throw new Error("Customer ID not found")
+ if (!paymentIntentID) throw new Error("Payment ID not found")
+
+ const workspaceID = await Database.use((tx) =>
+ tx
+ .select({
+ workspaceID: BillingTable.workspaceID,
+ })
+ .from(BillingTable)
+ .where(eq(BillingTable.customerID, customerID))
+ .then((rows) => rows[0]?.workspaceID),
+ )
+ if (!workspaceID) throw new Error("Workspace ID not found")
+
+ const amount = await Database.use((tx) =>
+ tx
+ .select({
+ amount: PaymentTable.amount,
+ })
+ .from(PaymentTable)
+ .where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
+ .then((rows) => rows[0]?.amount),
+ )
+ if (!amount) throw new Error("Payment not found")
+
+ await Database.transaction(async (tx) => {
+ await tx
+ .update(PaymentTable)
+ .set({
+ timeRefunded: new Date(body.created * 1000),
+ })
+ .where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
+
+ await tx
+ .update(BillingTable)
+ .set({
+ balance: sql`${BillingTable.balance} - ${amount}`,
+ })
+ .where(eq(BillingTable.workspaceID, workspaceID))
+ })
+ }
})()
.then((message) => {
return Response.json({ message: message ?? "done" }, { status: 200 })