mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-24 23:04:55 +00:00
478 lines
16 KiB
TypeScript
478 lines
16 KiB
TypeScript
import { A, createAsync, query, redirect, useParams } from "@solidjs/router"
|
|
import { Title } from "@solidjs/meta"
|
|
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 { 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"
|
|
import { and, Database, eq, isNull } from "@opencode-ai/console-core/drizzle/index.js"
|
|
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
|
|
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
|
|
import { createList } from "solid-list"
|
|
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"
|
|
import { useI18n } from "~/context/i18n"
|
|
import { useLanguage } from "~/context/language"
|
|
import { formError } from "~/lib/form-error"
|
|
|
|
const plansMap = Object.fromEntries(plans.map((p) => [p.id, p])) as Record<PlanID, (typeof plans)[number]>
|
|
const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY!)
|
|
|
|
const getWorkspaces = query(async (plan: string) => {
|
|
"use server"
|
|
const actor = await getActor()
|
|
if (actor.type === "public") throw redirect("/auth/authorize?continue=/black/subscribe/" + plan)
|
|
return withActor(async () => {
|
|
return Database.use((tx) =>
|
|
tx
|
|
.select({
|
|
id: WorkspaceTable.id,
|
|
name: WorkspaceTable.name,
|
|
slug: WorkspaceTable.slug,
|
|
billing: {
|
|
customerID: BillingTable.customerID,
|
|
paymentMethodID: BillingTable.paymentMethodID,
|
|
paymentMethodType: BillingTable.paymentMethodType,
|
|
paymentMethodLast4: BillingTable.paymentMethodLast4,
|
|
subscriptionID: BillingTable.subscriptionID,
|
|
timeSubscriptionBooked: BillingTable.timeSubscriptionBooked,
|
|
},
|
|
})
|
|
.from(UserTable)
|
|
.innerJoin(WorkspaceTable, eq(UserTable.workspaceID, WorkspaceTable.id))
|
|
.innerJoin(BillingTable, eq(WorkspaceTable.id, BillingTable.workspaceID))
|
|
.where(
|
|
and(
|
|
eq(UserTable.accountID, Actor.account()),
|
|
isNull(WorkspaceTable.timeDeleted),
|
|
isNull(UserTable.timeDeleted),
|
|
),
|
|
),
|
|
)
|
|
})
|
|
}, "black.subscribe.workspaces")
|
|
|
|
const createSetupIntent = async (input: { plan: string; workspaceID: string }) => {
|
|
"use server"
|
|
const { plan, workspaceID } = input
|
|
|
|
if (!plan || !["20", "100", "200"].includes(plan)) return { error: formError.invalidPlan }
|
|
if (!workspaceID) return { error: formError.workspaceRequired }
|
|
|
|
return withActor(async () => {
|
|
const session = await useAuthSession()
|
|
const account = session.data.account?.[session.data.current ?? ""]
|
|
const email = account?.email
|
|
|
|
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: formError.alreadySubscribed }
|
|
}
|
|
|
|
let customerID = customer?.customerID
|
|
if (!customerID) {
|
|
const customer = await Billing.stripe().customers.create({
|
|
email,
|
|
metadata: {
|
|
workspaceID,
|
|
},
|
|
})
|
|
customerID = customer.id
|
|
await Database.use((tx) =>
|
|
tx
|
|
.update(BillingTable)
|
|
.set({
|
|
customerID,
|
|
})
|
|
.where(eq(BillingTable.workspaceID, workspaceID)),
|
|
)
|
|
}
|
|
|
|
const intent = await Billing.stripe().setupIntents.create({
|
|
customer: customerID,
|
|
payment_method_types: ["card"],
|
|
metadata: {
|
|
workspaceID,
|
|
},
|
|
})
|
|
|
|
return { clientSecret: intent.client_secret ?? undefined }
|
|
}, workspaceID)
|
|
}
|
|
|
|
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
|
|
paymentMethodType: string
|
|
paymentMethodLast4?: string
|
|
}
|
|
|
|
function Failure(props: { message: string }) {
|
|
const i18n = useI18n()
|
|
|
|
return (
|
|
<div data-slot="failure">
|
|
<p data-slot="message">
|
|
{i18n.t("black.subscribe.failurePrefix")} {props.message}
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Success(props: SuccessData) {
|
|
const i18n = useI18n()
|
|
|
|
return (
|
|
<div data-slot="success">
|
|
<p data-slot="title">{i18n.t("black.subscribe.success.title")}</p>
|
|
<dl data-slot="details">
|
|
<div>
|
|
<dt>{i18n.t("black.subscribe.success.subscriptionPlan")}</dt>
|
|
<dd>{i18n.t("black.subscribe.success.planName", { plan: props.plan })}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>{i18n.t("black.subscribe.success.amount")}</dt>
|
|
<dd>{i18n.t("black.subscribe.success.amountValue", { plan: props.plan })}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>{i18n.t("black.subscribe.success.paymentMethod")}</dt>
|
|
<dd>
|
|
<Show when={props.paymentMethodLast4} fallback={<span>{props.paymentMethodType}</span>}>
|
|
<span>
|
|
{props.paymentMethodType} - {props.paymentMethodLast4}
|
|
</span>
|
|
</Show>
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt>{i18n.t("black.subscribe.success.dateJoined")}</dt>
|
|
<dd>{new Date().toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })}</dd>
|
|
</div>
|
|
</dl>
|
|
<p data-slot="charge-notice">{i18n.t("black.subscribe.success.chargeNotice")}</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function IntentForm(props: { plan: PlanID; workspaceID: string; onSuccess: (data: SuccessData) => void }) {
|
|
const i18n = useI18n()
|
|
const stripe = useStripe()
|
|
const elements = useElements()
|
|
const [error, setError] = createSignal<string | undefined>(undefined)
|
|
const [loading, setLoading] = createSignal(false)
|
|
|
|
const handleSubmit = async (e: Event) => {
|
|
e.preventDefault()
|
|
if (!stripe() || !elements()) return
|
|
|
|
setLoading(true)
|
|
setError(undefined)
|
|
|
|
const result = await elements()!.submit()
|
|
if (result.error) {
|
|
setError(result.error.message ?? i18n.t("black.subscribe.error.generic"))
|
|
setLoading(false)
|
|
return
|
|
}
|
|
|
|
const { error: confirmError, setupIntent } = await stripe()!.confirmSetup({
|
|
elements: elements()!,
|
|
confirmParams: {
|
|
expand: ["payment_method"],
|
|
payment_method_data: {
|
|
allow_redisplay: "always",
|
|
},
|
|
},
|
|
redirect: "if_required",
|
|
})
|
|
|
|
if (confirmError) {
|
|
setError(confirmError.message ?? i18n.t("black.subscribe.error.generic"))
|
|
setLoading(false)
|
|
return
|
|
}
|
|
|
|
if (setupIntent?.status === "succeeded") {
|
|
const pm = setupIntent.payment_method as PaymentMethod
|
|
|
|
await bookSubscription({
|
|
workspaceID: props.workspaceID,
|
|
plan: props.plan,
|
|
paymentMethodID: pm.id,
|
|
paymentMethodType: pm.type,
|
|
paymentMethodLast4: pm.card?.last4,
|
|
})
|
|
|
|
props.onSuccess({
|
|
plan: props.plan,
|
|
paymentMethodType: pm.type,
|
|
paymentMethodLast4: pm.card?.last4,
|
|
})
|
|
}
|
|
|
|
setLoading(false)
|
|
}
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit} data-slot="checkout-form">
|
|
<PaymentElement />
|
|
<AddressElement options={{ mode: "billing" }} />
|
|
<Show when={error()}>
|
|
<p data-slot="error">{error()}</p>
|
|
</Show>
|
|
<button type="submit" disabled={loading() || !stripe() || !elements()} data-slot="submit-button">
|
|
{loading() ? i18n.t("black.subscribe.processing") : i18n.t("black.subscribe.submit", { plan: props.plan })}
|
|
</button>
|
|
<p data-slot="charge-notice">{i18n.t("black.subscribe.form.chargeNotice")}</p>
|
|
</form>
|
|
)
|
|
}
|
|
|
|
export default function BlackSubscribe() {
|
|
const params = useParams()
|
|
const i18n = useI18n()
|
|
const language = useLanguage()
|
|
const planData = plansMap[(params.plan as PlanID) ?? "20"] ?? plansMap["20"]
|
|
const plan = planData.id
|
|
|
|
const workspaces = createAsync(() => getWorkspaces(plan))
|
|
const [selectedWorkspace, setSelectedWorkspace] = createSignal<string | undefined>(undefined)
|
|
const [success, setSuccess] = createSignal<SuccessData | undefined>(undefined)
|
|
const [failure, setFailure] = createSignal<string | undefined>(undefined)
|
|
const [clientSecret, setClientSecret] = createSignal<string | undefined>(undefined)
|
|
const [stripe, setStripe] = createSignal<Stripe | undefined>(undefined)
|
|
|
|
const formatError = (error: string) => {
|
|
if (error === formError.invalidPlan) return i18n.t("black.subscribe.error.invalidPlan")
|
|
if (error === formError.workspaceRequired) return i18n.t("black.subscribe.error.workspaceRequired")
|
|
if (error === formError.alreadySubscribed) return i18n.t("black.subscribe.error.alreadySubscribed")
|
|
if (error === "Invalid plan") return i18n.t("black.subscribe.error.invalidPlan")
|
|
if (error === "Workspace ID is required") return i18n.t("black.subscribe.error.workspaceRequired")
|
|
if (error === "This workspace already has a subscription") return i18n.t("black.subscribe.error.alreadySubscribed")
|
|
return error
|
|
}
|
|
|
|
// Resolve stripe promise once
|
|
createEffect(() => {
|
|
stripePromise.then((s) => {
|
|
if (s) setStripe(s)
|
|
})
|
|
})
|
|
|
|
// Auto-select if only one workspace
|
|
createEffect(() => {
|
|
const ws = workspaces()
|
|
if (ws?.length === 1 && !selectedWorkspace()) {
|
|
setSelectedWorkspace(ws[0].id)
|
|
}
|
|
})
|
|
|
|
// Fetch setup intent when workspace is selected (unless workspace already has payment method)
|
|
createEffect(async () => {
|
|
const id = selectedWorkspace()
|
|
if (!id) return
|
|
|
|
const ws = workspaces()?.find((w) => w.id === id)
|
|
if (ws?.billing?.subscriptionID) {
|
|
setFailure(i18n.t("black.subscribe.error.alreadySubscribed"))
|
|
return
|
|
}
|
|
if (ws?.billing?.paymentMethodID) {
|
|
if (!ws?.billing?.timeSubscriptionBooked) {
|
|
await bookSubscription({
|
|
workspaceID: id,
|
|
plan: planData.id,
|
|
paymentMethodID: ws.billing.paymentMethodID!,
|
|
paymentMethodType: ws.billing.paymentMethodType!,
|
|
paymentMethodLast4: ws.billing.paymentMethodLast4 ?? undefined,
|
|
})
|
|
}
|
|
setSuccess({
|
|
plan: planData.id,
|
|
paymentMethodType: ws.billing.paymentMethodType!,
|
|
paymentMethodLast4: ws.billing.paymentMethodLast4 ?? undefined,
|
|
})
|
|
return
|
|
}
|
|
|
|
const result = await createSetupIntent({ plan, workspaceID: id })
|
|
if (result.error) {
|
|
setFailure(formatError(result.error))
|
|
} else if ("clientSecret" in result) {
|
|
setClientSecret(result.clientSecret)
|
|
}
|
|
})
|
|
|
|
// Keyboard navigation for workspace picker
|
|
const { active, setActive, onKeyDown } = createList({
|
|
items: () => workspaces()?.map((w) => w.id) ?? [],
|
|
initialActive: null,
|
|
})
|
|
|
|
const handleSelectWorkspace = (id: string) => {
|
|
setSelectedWorkspace(id)
|
|
}
|
|
|
|
let listRef: HTMLUListElement | undefined
|
|
|
|
// Show workspace picker if multiple workspaces and none selected
|
|
const showWorkspacePicker = () => {
|
|
const ws = workspaces()
|
|
return ws && ws.length > 1 && !selectedWorkspace()
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Title>{i18n.t("black.subscribe.title")}</Title>
|
|
<section data-slot="subscribe-form">
|
|
<div data-slot="form-card">
|
|
<Switch>
|
|
<Match when={success()}>{(data) => <Success {...data()} />}</Match>
|
|
<Match when={failure()}>{(data) => <Failure message={data()} />}</Match>
|
|
<Match when={true}>
|
|
<>
|
|
<div data-slot="plan-header">
|
|
<p data-slot="title">{i18n.t("black.subscribe.title")}</p>
|
|
<p data-slot="price">
|
|
<span data-slot="amount">${planData.id}</span>{" "}
|
|
<span data-slot="period">{i18n.t("black.price.perMonth")}</span>
|
|
<Show when={planData.multiplier}>
|
|
{(multiplier) => <span data-slot="multiplier">{i18n.t(multiplier())}</span>}
|
|
</Show>
|
|
</p>
|
|
</div>
|
|
<div data-slot="divider" />
|
|
<p data-slot="section-title">{i18n.t("black.subscribe.paymentMethod")}</p>
|
|
|
|
<Show
|
|
when={clientSecret() && selectedWorkspace() && stripe()}
|
|
fallback={
|
|
<div data-slot="loading">
|
|
<p>
|
|
{selectedWorkspace()
|
|
? i18n.t("black.subscribe.loadingPaymentForm")
|
|
: i18n.t("black.subscribe.selectWorkspaceToContinue")}
|
|
</p>
|
|
</div>
|
|
}
|
|
>
|
|
<Elements
|
|
stripe={stripe()!}
|
|
options={{
|
|
clientSecret: clientSecret()!,
|
|
appearance: {
|
|
theme: "night",
|
|
variables: {
|
|
colorPrimary: "#ffffff",
|
|
colorBackground: "#1a1a1a",
|
|
colorText: "#ffffff",
|
|
colorTextSecondary: "#999999",
|
|
colorDanger: "#ff6b6b",
|
|
fontFamily: "JetBrains Mono, monospace",
|
|
borderRadius: "4px",
|
|
spacingUnit: "4px",
|
|
},
|
|
rules: {
|
|
".Input": {
|
|
backgroundColor: "#1a1a1a",
|
|
border: "1px solid rgba(255, 255, 255, 0.17)",
|
|
color: "#ffffff",
|
|
},
|
|
".Input:focus": {
|
|
borderColor: "rgba(255, 255, 255, 0.35)",
|
|
boxShadow: "none",
|
|
},
|
|
".Label": {
|
|
color: "rgba(255, 255, 255, 0.59)",
|
|
fontSize: "14px",
|
|
marginBottom: "8px",
|
|
},
|
|
},
|
|
},
|
|
}}
|
|
>
|
|
<IntentForm plan={plan} workspaceID={selectedWorkspace()!} onSuccess={setSuccess} />
|
|
</Elements>
|
|
</Show>
|
|
</>
|
|
</Match>
|
|
</Switch>
|
|
</div>
|
|
|
|
{/* Workspace picker modal */}
|
|
<Modal open={showWorkspacePicker() ?? false} onClose={() => {}} title={i18n.t("black.workspace.selectPlan")}>
|
|
<div data-slot="workspace-picker">
|
|
<ul
|
|
ref={listRef}
|
|
data-slot="workspace-list"
|
|
tabIndex={0}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && active()) {
|
|
handleSelectWorkspace(active()!)
|
|
} else {
|
|
onKeyDown(e)
|
|
}
|
|
}}
|
|
>
|
|
<For each={workspaces()}>
|
|
{(workspace) => (
|
|
<li
|
|
data-slot="workspace-item"
|
|
data-active={active() === workspace.id}
|
|
onMouseEnter={() => setActive(workspace.id)}
|
|
onClick={() => handleSelectWorkspace(workspace.id)}
|
|
>
|
|
<span data-slot="selected-icon">[*]</span>
|
|
<span>{workspace.name || workspace.slug}</span>
|
|
</li>
|
|
)}
|
|
</For>
|
|
</ul>
|
|
</div>
|
|
</Modal>
|
|
<p data-slot="fine-print">
|
|
{i18n.t("black.finePrint.beforeTerms")} ·{" "}
|
|
<A href={language.route("/legal/terms-of-service")}>{i18n.t("black.finePrint.terms")}</A>
|
|
</p>
|
|
</section>
|
|
</>
|
|
)
|
|
}
|