summaryrefslogtreecommitdiffhomepage
path: root/cloud/app/src/routes
diff options
context:
space:
mode:
authorFrank <[email protected]>2025-09-15 14:48:00 -0400
committerFrank <[email protected]>2025-09-15 14:48:02 -0400
commit5e6dd312eb9e4883b43fe9e61f9df7677e880812 (patch)
tree429d5eae2a4aca9af94b2e44ded090794e9dd693 /cloud/app/src/routes
parent7218a662ab08e5526d469090a565342872f04a48 (diff)
downloadopencode-5e6dd312eb9e4883b43fe9e61f9df7677e880812.tar.gz
opencode-5e6dd312eb9e4883b43fe9e61f9df7677e880812.zip
wip: zen
Diffstat (limited to 'cloud/app/src/routes')
-rw-r--r--cloud/app/src/routes/stripe/webhook.ts31
-rw-r--r--cloud/app/src/routes/workspace/[id].tsx233
-rw-r--r--cloud/app/src/routes/zen/handler.ts73
3 files changed, 305 insertions, 32 deletions
diff --git a/cloud/app/src/routes/stripe/webhook.ts b/cloud/app/src/routes/stripe/webhook.ts
index b69d54df8..925ede1ac 100644
--- a/cloud/app/src/routes/stripe/webhook.ts
+++ b/cloud/app/src/routes/stripe/webhook.ts
@@ -15,6 +15,28 @@ export async function POST(input: APIEvent) {
)
console.log(body.type, JSON.stringify(body, null, 2))
+ if (body.type === "customer.updated") {
+ // check default payment method changed
+ const prevInvoiceSettings = body.data.previous_attributes?.invoice_settings ?? {}
+ if (!("default_payment_method" in prevInvoiceSettings)) return
+
+ const customerID = body.data.object.id
+ const paymentMethodID = body.data.object.invoice_settings.default_payment_method as string
+
+ if (!customerID) throw new Error("Customer ID not found")
+ if (!paymentMethodID) throw new Error("Payment method ID not found")
+
+ const paymentMethod = await Billing.stripe().paymentMethods.retrieve(paymentMethodID)
+ await Database.use(async (tx) => {
+ await tx
+ .update(BillingTable)
+ .set({
+ paymentMethodID,
+ paymentMethodLast4: paymentMethod.card!.last4,
+ })
+ .where(eq(BillingTable.customerID, customerID))
+ })
+ }
if (body.type === "checkout.session.completed") {
const workspaceID = body.data.object.metadata?.workspaceID
const customerID = body.data.object.customer as string
@@ -26,8 +48,6 @@ export async function POST(input: APIEvent) {
if (!amount) throw new Error("Amount not found")
if (!paymentID) throw new Error("Payment ID not found")
- const chargedAmount = 2000
-
await Actor.provide("system", { workspaceID }, async () => {
const customer = await Billing.get()
if (customer?.customerID && customer.customerID !== customerID) throw new Error("Customer ID mismatch")
@@ -52,16 +72,19 @@ export async function POST(input: APIEvent) {
await tx
.update(BillingTable)
.set({
- balance: sql`${BillingTable.balance} + ${centsToMicroCents(chargedAmount)}`,
+ balance: sql`${BillingTable.balance} + ${centsToMicroCents(Billing.CHARGE_AMOUNT)}`,
customerID,
paymentMethodID: paymentMethod.id,
paymentMethodLast4: paymentMethod.card!.last4,
+ reload: true,
+ reloadError: null,
+ timeReloadError: null,
})
.where(eq(BillingTable.workspaceID, workspaceID))
await tx.insert(PaymentTable).values({
workspaceID,
id: Identifier.create("payment"),
- amount: centsToMicroCents(chargedAmount),
+ amount: centsToMicroCents(Billing.CHARGE_AMOUNT),
paymentID,
customerID,
})
diff --git a/cloud/app/src/routes/workspace/[id].tsx b/cloud/app/src/routes/workspace/[id].tsx
index 8c20fc722..f2b7bf760 100644
--- a/cloud/app/src/routes/workspace/[id].tsx
+++ b/cloud/app/src/routes/workspace/[id].tsx
@@ -4,7 +4,7 @@ import { Key } from "@opencode/cloud-core/key.js"
import { json, query, action, useParams, useAction, createAsync, useSubmission } from "@solidjs/router"
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
import { withActor } from "~/context/auth.withActor"
-import { IconCopy, IconCheck } from "~/component/icon"
+import { IconCopy, IconCheck, IconCreditCard } from "~/component/icon"
import { createStore } from "solid-js/store"
function formatDateForTable(date: Date) {
@@ -73,36 +73,68 @@ const removeKey = action(async (form: FormData) => {
// Billing related queries and actions
/////////////////////////////////////
-const getBalanceInfo = query(async (workspaceID: string) => {
+const getBillingInfo = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
return await Billing.get()
}, workspaceID)
-}, "balanceInfo")
+}, "billing.get")
const getUsageInfo = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
return await Billing.usages()
}, workspaceID)
-}, "usageInfo")
+}, "usage.list")
const getPaymentsInfo = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
return await Billing.payments()
}, workspaceID)
-}, "paymentsInfo")
+}, "payment.list")
+
+const setMonthlyLimit = action(async (form: FormData) => {
+ "use server"
+ const limit = form.get("limit")?.toString()
+ if (!limit) return { error: "Limit is required" }
+ const workspaceID = form.get("workspaceID")?.toString()
+ if (!workspaceID) return { error: "Workspace ID is required" }
+ return json(
+ await withActor(
+ () =>
+ Billing.setMonthlyLimit(parseInt(limit))
+ .then((data) => ({ error: undefined, data }))
+ .catch((e) => ({ error: e.message as string })),
+ workspaceID,
+ ),
+ { revalidate: getBillingInfo.key },
+ )
+}, "billing.setMonthlyLimit")
+
+const reload = action(async (form: FormData) => {
+ "use server"
+ const workspaceID = form.get("workspaceID")?.toString()
+ if (!workspaceID) return { error: "Workspace ID is required" }
+ return json(await withActor(() => Billing.reload(), workspaceID), { revalidate: getBillingInfo.key })
+}, "billing.reload")
+
+const disableReload = action(async (form: FormData) => {
+ "use server"
+ const workspaceID = form.get("workspaceID")?.toString()
+ if (!workspaceID) return { error: "Workspace ID is required" }
+ return json(await withActor(() => Billing.disableReload(), workspaceID), { revalidate: getBillingInfo.key })
+}, "billing.disableReload")
const createCheckoutUrl = action(async (workspaceID: string, successUrl: string, cancelUrl: string) => {
"use server"
return withActor(() => Billing.generateCheckoutUrl({ successUrl, cancelUrl }), workspaceID)
}, "checkoutUrl")
-// const createPortalUrl = action(async (workspaceID: string, returnUrl: string) => {
-// "use server"
-// return withActor(() => Billing.generatePortalUrl({ returnUrl }), workspaceID)
-// }, "portalUrl")
+const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
+ "use server"
+ return withActor(() => Billing.generateSessionUrl({ returnUrl }), workspaceID)
+}, "sessionUrl")
function KeySection() {
const params = useParams()
@@ -248,9 +280,13 @@ function KeyCreateForm() {
function BalanceSection() {
const params = useParams()
- const balanceInfo = createAsync(() => getBalanceInfo(params.id))
+ const balanceInfo = createAsync(() => getBillingInfo(params.id))
const createCheckoutUrlAction = useAction(createCheckoutUrl)
const createCheckoutUrlSubmission = useSubmission(createCheckoutUrl)
+ const createSessionUrlAction = useAction(createSessionUrl)
+ const createSessionUrlSubmission = useSubmission(createSessionUrl)
+ const disableReloadSubmission = useSubmission(disableReload)
+ const reloadSubmission = useSubmission(reload)
return (
<section data-component="balance-section">
@@ -274,24 +310,176 @@ function BalanceSection() {
})()}
</span>
</div>
- <button
- data-color="primary"
- disabled={createCheckoutUrlSubmission.pending}
- onClick={async () => {
- const baseUrl = window.location.href
- const checkoutUrl = await createCheckoutUrlAction(params.id, baseUrl, baseUrl)
- if (checkoutUrl) {
- window.location.href = checkoutUrl
- }
- }}
+ <Show
+ when={balanceInfo()?.reload}
+ fallback={
+ <>
+ <button
+ data-color="primary"
+ disabled={createCheckoutUrlSubmission.pending}
+ onClick={async () => {
+ const baseUrl = window.location.href
+ const checkoutUrl = await createCheckoutUrlAction(params.id, baseUrl, baseUrl)
+ if (checkoutUrl) {
+ window.location.href = checkoutUrl
+ }
+ }}
+ >
+ {createCheckoutUrlSubmission.pending ? "Loading..." : "Enable Billing"}
+ </button>
+ <p>You can continue using the API with the remaining credits.</p>
+ </>
+ }
>
- {createCheckoutUrlSubmission.pending ? "Loading..." : "Buy Credits"}
- </button>
+ <>
+ <div>
+ <p>
+ You will be automatically reloading <b>$20</b> (+$1.23 processing fee) when your balance reaches{" "}
+ <b>$5</b>.
+ </p>
+ <form action={disableReload} method="post" data-slot="create-form">
+ <input type="hidden" name="workspaceID" value={params.id} />
+ <button data-color="primary" type="submit" disabled={disableReloadSubmission.pending}>
+ {disableReloadSubmission.pending ? "Disabling..." : "Disable Billing"}
+ </button>
+ </form>
+ <p>You will be able to continue using the API with the remaining credits after disabling billing.</p>
+ <Show when={balanceInfo()?.reloadError}>
+ <>
+ <p>
+ Reload failed at{" "}
+ {balanceInfo()?.timeReloadError!.toLocaleString("en-US", {
+ month: "short",
+ day: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ second: "2-digit",
+ })}{" "}
+ . Reason: {balanceInfo()?.reloadError?.replace(/\.$/, "")}. Please update your payment method and
+ try again.
+ </p>
+ <form action={reload} method="post" data-slot="create-form">
+ <input type="hidden" name="workspaceID" value={params.id} />
+ <button data-color="primary" type="submit" disabled={reloadSubmission.pending}>
+ {reloadSubmission.pending ? "Reloading..." : "Retry Reload"}
+ </button>
+ </form>
+ </>
+ </Show>
+ </div>
+ <hr />
+ <div data-slot="amount">
+ <IconCreditCard style={{ width: "32px", height: "32px" }} />
+ <span data-slot="currency">••••</span>
+ <span data-slot="value">{balanceInfo()?.paymentMethodLast4}</span>
+ </div>
+ <button
+ data-color="primary"
+ disabled={createSessionUrlSubmission.pending}
+ onClick={async () => {
+ const baseUrl = window.location.href
+ const sessionUrl = await createSessionUrlAction(params.id, baseUrl)
+ if (sessionUrl) {
+ window.location.href = sessionUrl
+ }
+ }}
+ >
+ {createSessionUrlSubmission.pending ? "Loading..." : "Manage Payment Methods"}
+ </button>
+ <hr />
+ <Show when={balanceInfo()?.monthlyLimit} fallback={<p>No spending limit set.</p>}>
+ <p>
+ Spending limit is ${balanceInfo()?.monthlyLimit ?? 0}. Current usage for the month of{" "}
+ {new Date().toLocaleDateString("en-US", { month: "long", timeZone: "UTC" })} is $
+ {(() => {
+ const dateLastUsed = balanceInfo()?.timeMonthlyUsageUpdated
+ if (!dateLastUsed) return "0"
+
+ const current = new Date().toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "long",
+ timeZone: "UTC",
+ })
+ const lastUsed = dateLastUsed.toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "long",
+ timeZone: "UTC",
+ })
+ if (current !== lastUsed) return "0"
+ return ((balanceInfo()?.monthlyUsage ?? 0) / 100000000).toFixed(2)
+ })()}
+ </p>
+ </Show>
+ <BalanceLimitForm />
+ </>
+ </Show>
</div>
</section>
)
}
+function BalanceLimitForm() {
+ const params = useParams()
+ const submission = useSubmission(setMonthlyLimit)
+ const [store, setStore] = createStore({ show: false })
+ const balanceInfo = createAsync(() => getBillingInfo(params.id))
+
+ let input: HTMLInputElement
+
+ createEffect(() => {
+ if (!submission.pending && submission.result && !submission.result.error) {
+ hide()
+ }
+ })
+
+ function show() {
+ // submission.clear() does not clear the result in some cases, ie.
+ // 1. Create key with empty name => error shows
+ // 2. Put in a key name and creates the key => form hides
+ // 3. Click add key button again => form shows with the same error if
+ // submission.clear() is called only once
+ while (true) {
+ submission.clear()
+ if (!submission.result) break
+ }
+ setStore("show", true)
+ input.focus()
+ }
+
+ function hide() {
+ setStore("show", false)
+ }
+
+ return (
+ <Show
+ when={store.show}
+ fallback={
+ <button data-color="primary" onClick={() => show()}>
+ {balanceInfo()?.monthlyLimit ? "Edit Spending Limit" : "Set Spending Limit"}
+ </button>
+ }
+ >
+ <form action={setMonthlyLimit} method="post" data-slot="create-form">
+ <div data-slot="input-container">
+ <input ref={(r) => (input = r)} data-component="input" name="limit" type="number" placeholder="Enter limit" />
+ <Show when={submission.result && submission.result.error}>
+ {(err) => <div data-slot="form-error">{err()}</div>}
+ </Show>
+ </div>
+ <input type="hidden" name="workspaceID" value={params.id} />
+ <div data-slot="form-actions">
+ <button type="reset" data-color="ghost" onClick={() => hide()}>
+ Cancel
+ </button>
+ <button type="submit" data-color="primary" disabled={submission.pending}>
+ {submission.pending ? "Setting..." : "Set"}
+ </button>
+ </div>
+ </form>
+ </Show>
+ )
+}
+
function UsageSection() {
const params = useParams()
const usage = createAsync(() => getUsageInfo(params.id))
@@ -349,6 +537,7 @@ function UsageSection() {
function PaymentSection() {
const params = useParams()
const payments = createAsync(() => getPaymentsInfo(params.id))
+ console.log("!#!@", payments())
return (
payments() &&
diff --git a/cloud/app/src/routes/zen/handler.ts b/cloud/app/src/routes/zen/handler.ts
index 7a7643568..120e4932b 100644
--- a/cloud/app/src/routes/zen/handler.ts
+++ b/cloud/app/src/routes/zen/handler.ts
@@ -1,11 +1,13 @@
import type { APIEvent } from "@solidjs/start/server"
import path from "node:path"
-import { and, Database, eq, isNull, sql } from "@opencode/cloud-core/drizzle/index.js"
+import { and, Database, eq, isNull, lt, or, sql } from "@opencode/cloud-core/drizzle/index.js"
import { KeyTable } from "@opencode/cloud-core/schema/key.sql.js"
-import { BillingTable, UsageTable } from "@opencode/cloud-core/schema/billing.sql.js"
+import { BillingTable, PaymentTable, UsageTable } from "@opencode/cloud-core/schema/billing.sql.js"
import { centsToMicroCents } from "@opencode/cloud-core/util/price.js"
import { Identifier } from "@opencode/cloud-core/identifier.js"
import { Resource } from "@opencode/cloud-resource"
+import { Billing } from "../../../../core/src/billing"
+import { Actor } from "@opencode/cloud-core/actor.js"
type ModelCost = {
input: number
@@ -51,6 +53,7 @@ export async function handler(
) {
class AuthError extends Error {}
class CreditsError extends Error {}
+ class MonthlyLimitError extends Error {}
class ModelError extends Error {}
const MODELS: Record<string, Model> = {
@@ -259,7 +262,7 @@ export async function handler(
const MODEL = validateModel()
const apiKey = await authenticate()
const isFree = FREE_WORKSPACES.includes(apiKey?.workspaceID ?? "")
- await checkCredits()
+ await checkCreditsAndLimit()
const providerName = selectProvider()
const providerData = MODEL.providers[providerName]
logger.metric({ provider: providerName })
@@ -300,6 +303,7 @@ export async function handler(
logger.metric({ response_length: body.length })
logger.debug(body)
await trackUsage(json.usage)
+ await reload()
return new Response(body, {
status: res.status,
statusText: res.statusText,
@@ -321,7 +325,10 @@ export async function handler(
if (done) {
logger.metric({ response_length: responseLength })
const usage = opts.getStreamUsage()
- if (usage) await trackUsage(usage)
+ if (usage) {
+ await trackUsage(usage)
+ await reload()
+ }
c.close()
return
}
@@ -395,13 +402,16 @@ export async function handler(
}
}
- async function checkCredits() {
+ async function checkCreditsAndLimit() {
if (!apiKey || !MODEL.auth || isFree) return
const billing = await Database.use((tx) =>
tx
.select({
balance: BillingTable.balance,
+ monthlyLimit: BillingTable.monthlyLimit,
+ monthlyUsage: BillingTable.monthlyUsage,
+ timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated,
})
.from(BillingTable)
.where(eq(BillingTable.workspaceID, apiKey.workspaceID))
@@ -409,6 +419,20 @@ export async function handler(
)
if (billing.balance <= 0) throw new CreditsError("Insufficient balance")
+ if (
+ billing.monthlyLimit &&
+ billing.monthlyUsage &&
+ billing.timeMonthlyUsageUpdated &&
+ billing.monthlyUsage >= centsToMicroCents(billing.monthlyLimit * 100)
+ ) {
+ const now = new Date()
+ const currentYear = now.getUTCFullYear()
+ const currentMonth = now.getUTCMonth()
+ const dateYear = billing.timeMonthlyUsageUpdated.getUTCFullYear()
+ const dateMonth = billing.timeMonthlyUsageUpdated.getUTCMonth()
+ if (currentYear === dateYear && currentMonth === dateMonth)
+ throw new MonthlyLimitError(`You have reached your monthly spending limit of $${billing.monthlyLimit}.`)
+ }
}
function selectProvider() {
@@ -490,6 +514,13 @@ export async function handler(
.update(BillingTable)
.set({
balance: sql`${BillingTable.balance} - ${cost}`,
+ monthlyUsage: sql`
+ CASE
+ WHEN MONTH(${BillingTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${BillingTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${BillingTable.monthlyUsage} + ${cost}
+ ELSE ${cost}
+ END
+ `,
+ timeMonthlyUsageUpdated: sql`now()`,
})
.where(eq(BillingTable.workspaceID, apiKey.workspaceID))
})
@@ -501,6 +532,31 @@ export async function handler(
.where(eq(KeyTable.id, apiKey.id)),
)
}
+
+ async function reload() {
+ if (!apiKey) return
+
+ // acquire reload lock
+ const lock = await Database.use((tx) =>
+ tx
+ .update(BillingTable)
+ .set({
+ timeReloadLockedTill: sql`now() + interval 1 minute`,
+ })
+ .where(
+ and(
+ eq(BillingTable.workspaceID, apiKey.workspaceID),
+ lt(BillingTable.balance, centsToMicroCents(Billing.CHARGE_THRESHOLD)),
+ or(isNull(BillingTable.timeReloadLockedTill), lt(BillingTable.timeReloadLockedTill, sql`now()`)),
+ ),
+ ),
+ )
+ if (lock.rowsAffected === 0) return
+
+ await Actor.provide("system", { workspaceID: apiKey.workspaceID }, async () => {
+ await Billing.reload()
+ })
+ }
} catch (error: any) {
logger.metric({
"error.type": error.constructor.name,
@@ -508,7 +564,12 @@ export async function handler(
})
// Note: both top level "type" and "error.type" fields are used by the @ai-sdk/anthropic client to render the error message.
- if (error instanceof AuthError || error instanceof CreditsError || error instanceof ModelError)
+ if (
+ error instanceof AuthError ||
+ error instanceof CreditsError ||
+ error instanceof MonthlyLimitError ||
+ error instanceof ModelError
+ )
return new Response(
JSON.stringify({
type: "error",