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 can continue using the API with the remaining credits.

+ + } > - {createCheckoutUrlSubmission.pending ? "Loading..." : "Buy Credits"} - + <> +
+

+ 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} +
+ +
+ 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"} + + } + > +
+
+ (input = r)} data-component="input" name="limit" type="number" placeholder="Enter limit" /> + + {(err) =>
{err()}
} +
+
+ +
+ + +
+
+
+ ) +} + 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