mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-27 03:01:57 +00:00
fix(console): rate limit checkout session creation (#45007)
This commit is contained in:
parent
a7444bf944
commit
8615731d46
5 changed files with 13 additions and 493 deletions
|
|
@ -103,9 +103,6 @@ export default function Black() {
|
|||
<button type="button" onClick={() => cancel()} data-slot="cancel">
|
||||
{i18n.t("common.cancel")}
|
||||
</button>
|
||||
<a href={`/black/subscribe/${plan().id}`} data-slot="continue">
|
||||
{i18n.t("black.action.continue")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,489 +0,0 @@
|
|||
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"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
|
||||
const getEnabled = query(async () => {
|
||||
"use server"
|
||||
return Resource.App.stage !== "production"
|
||||
}, "black.subscribe.enabled")
|
||||
|
||||
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 enabled = createAsync(() => getEnabled())
|
||||
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(() => {
|
||||
void 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 (
|
||||
<Show when={enabled()}>
|
||||
<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")}
|
||||
variant="black"
|
||||
>
|
||||
<div data-component="black-workspace-picker-modal" 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>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ import { formatResetTime, liteResetTimeKeys } from "~/lib/format-reset-time"
|
|||
import { createReferralFromCookie } from "~/lib/referral-invite"
|
||||
import { getRequestEvent } from "solid-js/web"
|
||||
import { countryFromRequest } from "~/lib/request-country"
|
||||
import { checkCheckoutRateLimit } from "~/routes/zen/util/redis"
|
||||
|
||||
import { IconAlipay, IconChevron, IconUpi } from "~/component/icon"
|
||||
import { buildLiteUsageBreakdown, getModelQuotaLimit, getUsagePercent } from "~/lib/lite-usage"
|
||||
|
|
@ -219,6 +220,7 @@ const createLiteCheckoutUrl = action(
|
|||
"use server"
|
||||
return json(
|
||||
await withActor(async () => {
|
||||
await checkCheckoutRateLimit(Actor.account())
|
||||
const data = await Billing.generateLiteCheckoutUrl({ successUrl, cancelUrl, method })
|
||||
await createReferralFromCookie()
|
||||
return { error: undefined, data }
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Billing } from "@opencode-ai/console-core/billing.js"
|
|||
import { and, Database, desc, 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 { checkCheckoutRateLimit } from "~/routes/zen/util/redis"
|
||||
|
||||
export function formatDateForTable(date: Date) {
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
|
|
@ -77,7 +78,8 @@ export const createCheckoutUrl = action(
|
|||
return json(
|
||||
await withActor(
|
||||
() =>
|
||||
Billing.generateCheckoutUrl({ amount, successUrl, cancelUrl })
|
||||
checkCheckoutRateLimit(Actor.account())
|
||||
.then(() => Billing.generateCheckoutUrl({ amount, successUrl, cancelUrl }))
|
||||
.then((data) => ({ error: undefined, data }))
|
||||
.catch((e) => ({
|
||||
error: e.message as string,
|
||||
|
|
|
|||
|
|
@ -16,3 +16,11 @@ export function getRedis() {
|
|||
export function buildRateLimitKey(kind: string, identifier: string, interval?: string) {
|
||||
return `${Resource.App.stage}:ratelimit:${kind}:${identifier}${interval ? `:${interval}` : ""}`
|
||||
}
|
||||
|
||||
export async function checkCheckoutRateLimit(accountID: string) {
|
||||
const redis = getRedis()
|
||||
const key = buildRateLimitKey("checkout", accountID)
|
||||
const count = await redis.incr(key)
|
||||
if (count === 1) await redis.expire(key, 60 * 60)
|
||||
if (count > 5) throw new Error("Too many payment attempts. Please try again later.")
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue