diff --git a/packages/console/app/src/routes/black/common.tsx b/packages/console/app/src/routes/black/common.tsx
index dd643bbc5..950531da1 100644
--- a/packages/console/app/src/routes/black/common.tsx
+++ b/packages/console/app/src/routes/black/common.tsx
@@ -6,6 +6,7 @@ export const plans = [
{ id: "200", multiplier: "21x more usage than Black 20" },
] as const
+export type PlanID = (typeof plans)[number]["id"]
export type Plan = (typeof plans)[number]
export function PlanIcon(props: { plan: string }) {
diff --git a/packages/console/app/src/routes/black/index.tsx b/packages/console/app/src/routes/black/index.tsx
index 57d27a793..5d924a64b 100644
--- a/packages/console/app/src/routes/black/index.tsx
+++ b/packages/console/app/src/routes/black/index.tsx
@@ -22,7 +22,7 @@ export default function Black() {
- ${plan.amount} per month
+ ${plan.id} per month
{plan.multiplier}
@@ -43,7 +43,7 @@ export default function Black() {
- ${plan().amount}{" "}
+ ${plan().id}{" "}
per person billed monthly
{plan().multiplier}
diff --git a/packages/console/app/src/routes/black/subscribe/[plan].tsx b/packages/console/app/src/routes/black/subscribe/[plan].tsx
index e0992a1e2..b78d9138d 100644
--- a/packages/console/app/src/routes/black/subscribe/[plan].tsx
+++ b/packages/console/app/src/routes/black/subscribe/[plan].tsx
@@ -1,9 +1,9 @@
-import { A, action, createAsync, query, redirect, useParams } from "@solidjs/router"
+import { A, action, createAsync, json, query, redirect, useParams } from "@solidjs/router"
import { Title } from "@solidjs/meta"
-import { createEffect, createSignal, For, Show } from "solid-js"
+import { createEffect, createSignal, For, Match, Show, Switch } from "solid-js"
import { type Stripe, type PaymentMethod, loadStripe } from "@stripe/stripe-js"
import { Elements, PaymentElement, useStripe, useElements, AddressElement } from "solid-stripe"
-import { PlanIcon, plans } from "../common"
+import { PlanID, plans } from "../common"
import { getActor, useAuthSession } from "~/context/auth"
import { withActor } from "~/context/auth.withActor"
import { Actor } from "@opencode-ai/console-core/actor.js"
@@ -15,7 +15,7 @@ import { Modal } from "~/component/modal"
import { BillingTable } from "@opencode-ai/console-core/schema/billing.sql.js"
import { Billing } from "@opencode-ai/console-core/billing.js"
-const plansMap = Object.fromEntries(plans.map((p) => [p.id, p])) as Record
+const plansMap = Object.fromEntries(plans.map((p) => [p.id, p])) as Record
const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY!)
const getWorkspaces = query(async () => {
@@ -34,6 +34,7 @@ const getWorkspaces = query(async () => {
paymentMethodID: BillingTable.paymentMethodID,
paymentMethodType: BillingTable.paymentMethodType,
paymentMethodLast4: BillingTable.paymentMethodLast4,
+ subscriptionID: BillingTable.subscriptionID,
},
})
.from(UserTable)
@@ -50,85 +51,80 @@ const getWorkspaces = query(async () => {
})
}, "black.subscribe.workspaces")
-const createSetupIntent = action(async (input: { plan: string; workspaceID: string }) => {
+const createSetupIntent = async (input: { plan: string; workspaceID: string }) => {
"use server"
const { plan, workspaceID } = input
- if (!plan || !["20", "100", "200"].includes(plan)) {
- return { error: "Invalid plan" }
- }
+ if (!plan || !["20", "100", "200"].includes(plan)) return { error: "Invalid plan" }
+ if (!workspaceID) return { error: "Workspace ID is required" }
- if (!workspaceID) {
- return { error: "Workspace ID is required" }
- }
+ return withActor(async () => {
+ const session = await useAuthSession()
+ const account = session.data.account?.[session.data.current ?? ""]
+ const email = account?.email
- const actor = await getActor()
- if (actor.type === "public") {
- return { error: "Unauthorized" }
- }
+ const customer = await Database.use((tx) =>
+ tx
+ .select({
+ customerID: BillingTable.customerID,
+ subscriptionID: BillingTable.subscriptionID,
+ })
+ .from(BillingTable)
+ .where(eq(BillingTable.workspaceID, workspaceID))
+ .then((rows) => rows[0]),
+ )
+ if (customer?.subscriptionID) {
+ return { error: "This workspace already has a subscription" }
+ }
- const session = await useAuthSession()
- const account = session.data.account?.[session.data.current ?? ""]
- const email = account?.email
+ let customerID = customer?.customerID
+ if (!customerID) {
+ const customer = await Billing.stripe().customers.create({
+ email,
+ metadata: {
+ workspaceID,
+ },
+ })
+ customerID = customer.id
+ }
- const stripe = Billing.stripe()
-
- let customerID = await Database.use((tx) =>
- tx
- .select({ customerID: BillingTable.customerID })
- .from(BillingTable)
- .where(eq(BillingTable.workspaceID, workspaceID))
- .then((rows) => rows[0].customerID),
- )
- if (!customerID) {
- const customer = await stripe.customers.create({
- email,
+ const intent = await Billing.stripe().setupIntents.create({
+ customer: customerID,
+ payment_method_types: ["card"],
metadata: {
workspaceID,
},
})
- customerID = customer.id
- }
- const intent = await stripe.setupIntents.create({
- customer: customerID,
- payment_method_types: ["card"],
- metadata: {
- workspaceID,
- },
- })
+ return { clientSecret: intent.client_secret ?? undefined }
+ }, workspaceID)
+}
- return { clientSecret: intent.client_secret }
-})
-
-const bookSubscription = action(
- async (input: {
- workspaceID: string
- paymentMethodID: string
- paymentMethodType: string
- paymentMethodLast4?: string
- }) => {
- "use server"
- const actor = await getActor()
- if (actor.type === "public") {
- return { error: "Unauthorized" }
- }
-
- await Database.use((tx) =>
- tx
- .update(BillingTable)
- .set({
- paymentMethodID: input.paymentMethodID,
- paymentMethodType: input.paymentMethodType,
- paymentMethodLast4: input.paymentMethodLast4,
- timeSubscriptionBooked: new Date(),
- })
- .where(eq(BillingTable.workspaceID, input.workspaceID)),
- )
-
- return { success: true }
- },
-)
+const bookSubscription = async (input: {
+ workspaceID: string
+ plan: PlanID
+ paymentMethodID: string
+ paymentMethodType: string
+ paymentMethodLast4?: string
+}) => {
+ "use server"
+ return withActor(
+ () =>
+ Database.use((tx) =>
+ tx
+ .update(BillingTable)
+ .set({
+ paymentMethodID: input.paymentMethodID,
+ paymentMethodType: input.paymentMethodType,
+ paymentMethodLast4: input.paymentMethodLast4,
+ subscriptionPlan: input.plan,
+ timeSubscriptionBooked: new Date(),
+ })
+ .where(eq(BillingTable.workspaceID, input.workspaceID)),
+ ),
+ input.workspaceID,
+ )
+}
interface SuccessData {
plan: string
@@ -136,7 +132,16 @@ interface SuccessData {
paymentMethodLast4?: string
}
-function PaymentSuccess(props: SuccessData) {
+function Failure(props: { message: string }) {
+ return (
+
+
Uh oh, something went wrong
+
{props.message}
+
+ )
+}
+
+function Success(props: SuccessData) {
return (
You're on the OpenCode Black waitlist
@@ -169,10 +174,10 @@ function PaymentSuccess(props: SuccessData) {
)
}
-function PaymentForm(props: { plan: string; workspaceID: string; onSuccess: (data: SuccessData) => void }) {
+function IntentForm(props: { plan: PlanID; workspaceID: string; onSuccess: (data: SuccessData) => void }) {
const stripe = useStripe()
const elements = useElements()
- const [error, setError] = createSignal
(null)
+ const [error, setError] = createSignal(undefined)
const [loading, setLoading] = createSignal(false)
const handleSubmit = async (e: Event) => {
@@ -180,7 +185,7 @@ function PaymentForm(props: { plan: string; workspaceID: string; onSuccess: (dat
if (!stripe() || !elements()) return
setLoading(true)
- setError(null)
+ setError(undefined)
const result = await elements()!.submit()
if (result.error) {
@@ -211,6 +216,7 @@ function PaymentForm(props: { plan: string; workspaceID: string; onSuccess: (dat
await bookSubscription({
workspaceID: props.workspaceID,
+ plan: props.plan,
paymentMethodID: pm.id,
paymentMethodType: pm.type,
paymentMethodLast4: pm.card?.last4,
@@ -243,16 +249,14 @@ function PaymentForm(props: { plan: string; workspaceID: string; onSuccess: (dat
export default function BlackSubscribe() {
const workspaces = createAsync(() => getWorkspaces())
- const [selectedWorkspace, setSelectedWorkspace] = createSignal(null)
- const [success, setSuccess] = createSignal(null)
-
+ const [selectedWorkspace, setSelectedWorkspace] = createSignal(undefined)
+ const [success, setSuccess] = createSignal(undefined)
+ const [failure, setFailure] = createSignal(undefined)
+ const [clientSecret, setClientSecret] = createSignal(undefined)
+ const [stripe, setStripe] = createSignal(undefined)
const params = useParams()
- const plan = params.plan || "200"
- const planData = plansMap[plan] || plansMap["200"]
-
- const [clientSecret, setClientSecret] = createSignal(null)
- const [setupError, setSetupError] = createSignal(null)
- const [stripe, setStripe] = createSignal(null)
+ const planData = plansMap[(params.plan as PlanID) ?? "20"] ?? plansMap["20"]
+ const plan = planData.id
// Resolve stripe promise once
createEffect(() => {
@@ -275,27 +279,28 @@ export default function BlackSubscribe() {
if (!id) return
const ws = workspaces()?.find((w) => w.id === id)
- if (ws?.billing.paymentMethodID) {
+ if (ws?.billing?.subscriptionID) {
+ setFailure("This workspace already has a subscription")
+ return
+ }
+ if (ws?.billing?.paymentMethodID) {
setSuccess({
- plan,
+ plan: planData.id,
paymentMethodType: ws.billing.paymentMethodType!,
paymentMethodLast4: ws.billing.paymentMethodLast4 ?? undefined,
})
return
}
- setClientSecret(null)
- setSetupError(null)
-
createSetupIntent({ plan, workspaceID: id })
.then((data) => {
- if (data.clientSecret) {
+ if (data.error) {
+ setFailure(data.error)
+ } else if ("clientSecret" in data) {
setClientSecret(data.clientSecret)
- } else if (data.error) {
- setSetupError(data.error)
}
})
- .catch(() => setSetupError("Failed to initialize payment"))
+ .catch(() => setFailure("Failed to initialize payment"))
})
// Keyboard navigation for workspace picker
@@ -321,15 +326,13 @@ export default function BlackSubscribe() {
Subscribe to OpenCode Black
-
+ {(data) => }
+ {(data) => }
+
<>
Subscribe to OpenCode Black
-
${planData.id} per month
@@ -340,10 +343,6 @@ export default function BlackSubscribe() {
Payment method
-
- {setupError()}
-
-
-
+
>
- }
- >
- {(data) => }
-
+
+
{/* Workspace picker modal */}