This commit is contained in:
Frank
2025-09-15 14:48:00 -04:00
parent 7218a662ab
commit 5e6dd312eb
19 changed files with 4408 additions and 39 deletions

View File

@@ -69,3 +69,14 @@ export function IconCheck(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
</svg>
)
}
export function IconCreditCard(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg {...props} viewBox="0 0 24 24">
<path
fill="currentColor"
d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 14H4V8h16v10z"
/>
</svg>
)
}

View File

@@ -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,
})

View File

@@ -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() &&

View File

@@ -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",