diff options
| author | Frank <[email protected]> | 2025-09-15 14:48:00 -0400 |
|---|---|---|
| committer | Frank <[email protected]> | 2025-09-15 14:48:02 -0400 |
| commit | 5e6dd312eb9e4883b43fe9e61f9df7677e880812 (patch) | |
| tree | 429d5eae2a4aca9af94b2e44ded090794e9dd693 /cloud/app/src/routes | |
| parent | 7218a662ab08e5526d469090a565342872f04a48 (diff) | |
| download | opencode-5e6dd312eb9e4883b43fe9e61f9df7677e880812.tar.gz opencode-5e6dd312eb9e4883b43fe9e61f9df7677e880812.zip | |
wip: zen
Diffstat (limited to 'cloud/app/src/routes')
| -rw-r--r-- | cloud/app/src/routes/stripe/webhook.ts | 31 | ||||
| -rw-r--r-- | cloud/app/src/routes/workspace/[id].tsx | 233 | ||||
| -rw-r--r-- | cloud/app/src/routes/zen/handler.ts | 73 |
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", |
