From 5e6dd312eb9e4883b43fe9e61f9df7677e880812 Mon Sep 17 00:00:00 2001
From: Frank
Date: Mon, 15 Sep 2025 14:48:00 -0400
Subject: wip: zen
---
cloud/app/src/component/icon.tsx | 11 ++
cloud/app/src/routes/stripe/webhook.ts | 31 ++++-
cloud/app/src/routes/workspace/[id].tsx | 233 +++++++++++++++++++++++++++++---
cloud/app/src/routes/zen/handler.ts | 73 +++++++++-
4 files changed, 316 insertions(+), 32 deletions(-)
(limited to 'cloud/app/src')
diff --git a/cloud/app/src/component/icon.tsx b/cloud/app/src/component/icon.tsx
index c91c4937d..a82572e62 100644
--- a/cloud/app/src/component/icon.tsx
+++ b/cloud/app/src/component/icon.tsx
@@ -69,3 +69,14 @@ export function IconCheck(props: JSX.SvgSVGAttributes) {
)
}
+
+export function IconCreditCard(props: JSX.SvgSVGAttributes) {
+ return (
+
+ )
+}
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 (
@@ -274,24 +310,176 @@ function BalanceSection() {
})()}
-
+ <>
+
+
+ You will be automatically reloading $20 (+$1.23 processing fee) when your balance reaches{" "}
+ $5.
+
+
+
You will be able to continue using the API with the remaining credits after disabling billing.
+
+ <>
+
+ 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.
+
+
+ >
+
+
+
+
+
+ ••••
+ {balanceInfo()?.paymentMethodLast4}
+
+ {
+ const baseUrl = window.location.href
+ const sessionUrl = await createSessionUrlAction(params.id, baseUrl)
+ if (sessionUrl) {
+ window.location.href = sessionUrl
+ }
+ }}
+ >
+ {createSessionUrlSubmission.pending ? "Loading..." : "Manage Payment Methods"}
+
+
+ No spending limit set.
}>
+
+ 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)
+ })()}
+
+
+
+ >
+
)
}
+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()}>
+ {balanceInfo()?.monthlyLimit ? "Edit Spending Limit" : "Set Spending Limit"}
+
+ }
+ >
+
+
+ )
+}
+
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 = {
@@ -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",
--
cgit v1.2.3