From 8d6a03cc898c0b982b7a05419d41a07e8db579f8 Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 4 Nov 2025 16:51:38 -0500 Subject: [PATCH] zen: custom reload amount --- .../console/app/src/routes/stripe/webhook.ts | 265 +++++++++--------- .../[id]/billing/billing-section.module.css | 51 ++++ .../[id]/billing/billing-section.tsx | 187 ++++++++---- .../[id]/billing/monthly-limit-section.tsx | 32 +-- .../[id]/billing/reload-section.module.css | 202 +++++++++++++ .../workspace/[id]/billing/reload-section.tsx | 162 +++++++++-- .../app/src/routes/workspace/[id]/index.tsx | 42 +-- .../app/src/routes/workspace/common.tsx | 33 ++- .../app/src/routes/zen/util/handler.ts | 6 +- packages/console/core/src/billing.ts | 61 ++-- 10 files changed, 767 insertions(+), 274 deletions(-) diff --git a/packages/console/app/src/routes/stripe/webhook.ts b/packages/console/app/src/routes/stripe/webhook.ts index cc44f867..d8d85725 100644 --- a/packages/console/app/src/routes/stripe/webhook.ts +++ b/packages/console/app/src/routes/stripe/webhook.ts @@ -13,146 +13,157 @@ export async function POST(input: APIEvent) { input.request.headers.get("stripe-signature")!, Resource.STRIPE_WEBHOOK_SECRET.value, ) - 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 + return (async () => { + 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 "ignored" - if (!customerID) throw new Error("Customer ID not found") - if (!paymentMethodID) throw new Error("Payment method ID not found") + const customerID = body.data.object.id + const paymentMethodID = body.data.object.invoice_settings.default_payment_method as string - const paymentMethod = await Billing.stripe().paymentMethods.retrieve(paymentMethodID) - await Database.use(async (tx) => { - await tx - .update(BillingTable) - .set({ - paymentMethodID, - paymentMethodLast4: paymentMethod.card?.last4 ?? null, - paymentMethodType: paymentMethod.type, - }) - .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 - const paymentID = body.data.object.payment_intent as string - const invoiceID = body.data.object.invoice as string - const amount = body.data.object.amount_total + if (!customerID) throw new Error("Customer ID not found") + if (!paymentMethodID) throw new Error("Payment method ID not found") - if (!workspaceID) throw new Error("Workspace ID not found") - if (!customerID) throw new Error("Customer ID not found") - if (!amount) throw new Error("Amount not found") - if (!paymentID) throw new Error("Payment ID not found") - if (!invoiceID) throw new Error("Invoice ID not found") - - await Actor.provide("system", { workspaceID }, async () => { - const customer = await Billing.get() - if (customer?.customerID && customer.customerID !== customerID) - throw new Error("Customer ID mismatch") - - // set customer metadata - if (!customer?.customerID) { - await Billing.stripe().customers.update(customerID, { - metadata: { - workspaceID, - }, - }) - } - - // get payment method for the payment intent - const paymentIntent = await Billing.stripe().paymentIntents.retrieve(paymentID, { - expand: ["payment_method"], - }) - const paymentMethod = paymentIntent.payment_method - if (!paymentMethod || typeof paymentMethod === "string") - throw new Error("Payment method not expanded") - - const oldBillingInfo = await Database.use((tx) => - tx - .select({ - customerID: BillingTable.customerID, - }) - .from(BillingTable) - .where(eq(BillingTable.workspaceID, workspaceID)) - .then((rows) => rows[0]), - ) - - await Database.transaction(async (tx) => { + const paymentMethod = await Billing.stripe().paymentMethods.retrieve(paymentMethodID) + await Database.use(async (tx) => { await tx .update(BillingTable) .set({ - balance: sql`${BillingTable.balance} + ${centsToMicroCents(Billing.CHARGE_AMOUNT)}`, - customerID, - paymentMethodID: paymentMethod.id, + paymentMethodID, paymentMethodLast4: paymentMethod.card?.last4 ?? null, paymentMethodType: paymentMethod.type, - // enable reload if first time enabling billing - ...(oldBillingInfo?.customerID - ? {} - : { - reload: true, - reloadError: null, - timeReloadError: null, - }), }) - .where(eq(BillingTable.workspaceID, workspaceID)) - await tx.insert(PaymentTable).values({ - workspaceID, - id: Identifier.create("payment"), - amount: centsToMicroCents(Billing.CHARGE_AMOUNT), - paymentID, - invoiceID, - customerID, + .where(eq(BillingTable.customerID, customerID)) + }) + } + if (body.type === "checkout.session.completed") { + const workspaceID = body.data.object.metadata?.workspaceID + const amountInCents = + body.data.object.metadata?.amount && parseInt(body.data.object.metadata?.amount) + const customerID = body.data.object.customer as string + const paymentID = body.data.object.payment_intent as string + const invoiceID = body.data.object.invoice as string + + if (!workspaceID) throw new Error("Workspace ID not found") + if (!customerID) throw new Error("Customer ID not found") + if (!amountInCents) throw new Error("Amount not found") + if (!paymentID) throw new Error("Payment ID not found") + if (!invoiceID) throw new Error("Invoice ID not found") + + await Actor.provide("system", { workspaceID }, async () => { + const customer = await Billing.get() + if (customer?.customerID && customer.customerID !== customerID) + throw new Error("Customer ID mismatch") + + // set customer metadata + if (!customer?.customerID) { + await Billing.stripe().customers.update(customerID, { + metadata: { + workspaceID, + }, + }) + } + + // get payment method for the payment intent + const paymentIntent = await Billing.stripe().paymentIntents.retrieve(paymentID, { + expand: ["payment_method"], + }) + const paymentMethod = paymentIntent.payment_method + if (!paymentMethod || typeof paymentMethod === "string") + throw new Error("Payment method not expanded") + + await Database.transaction(async (tx) => { + await tx + .update(BillingTable) + .set({ + balance: sql`${BillingTable.balance} + ${centsToMicroCents(amountInCents)}`, + customerID, + paymentMethodID: paymentMethod.id, + paymentMethodLast4: paymentMethod.card?.last4 ?? null, + paymentMethodType: paymentMethod.type, + // enable reload if first time enabling billing + ...(customer?.customerID + ? {} + : { + reload: true, + reloadError: null, + timeReloadError: null, + }), + }) + .where(eq(BillingTable.workspaceID, workspaceID)) + await tx.insert(PaymentTable).values({ + workspaceID, + id: Identifier.create("payment"), + amount: centsToMicroCents(amountInCents), + paymentID, + invoiceID, + customerID, + }) }) }) + } + if (body.type === "charge.refunded") { + const customerID = body.data.object.customer as string + const paymentIntentID = body.data.object.payment_intent as string + if (!customerID) throw new Error("Customer ID not found") + if (!paymentIntentID) throw new Error("Payment ID not found") + + const workspaceID = await Database.use((tx) => + tx + .select({ + workspaceID: BillingTable.workspaceID, + }) + .from(BillingTable) + .where(eq(BillingTable.customerID, customerID)) + .then((rows) => rows[0]?.workspaceID), + ) + if (!workspaceID) throw new Error("Workspace ID not found") + + const amount = await Database.use((tx) => + tx + .select({ + amount: PaymentTable.amount, + }) + .from(PaymentTable) + .where( + and( + eq(PaymentTable.paymentID, paymentIntentID), + eq(PaymentTable.workspaceID, workspaceID), + ), + ) + .then((rows) => rows[0]?.amount), + ) + if (!amount) throw new Error("Payment not found") + + await Database.transaction(async (tx) => { + await tx + .update(PaymentTable) + .set({ + timeRefunded: new Date(body.created * 1000), + }) + .where( + and( + eq(PaymentTable.paymentID, paymentIntentID), + eq(PaymentTable.workspaceID, workspaceID), + ), + ) + + await tx + .update(BillingTable) + .set({ + balance: sql`${BillingTable.balance} - ${amount}`, + }) + .where(eq(BillingTable.workspaceID, workspaceID)) + }) + } + })() + .then((message) => { + return Response.json({ message: message ?? "done" }, { status: 200 }) }) - } - if (body.type === "charge.refunded") { - const customerID = body.data.object.customer as string - const paymentIntentID = body.data.object.payment_intent as string - if (!customerID) throw new Error("Customer ID not found") - if (!paymentIntentID) throw new Error("Payment ID not found") - - const workspaceID = await Database.use((tx) => - tx - .select({ - workspaceID: BillingTable.workspaceID, - }) - .from(BillingTable) - .where(eq(BillingTable.customerID, customerID)) - .then((rows) => rows[0]?.workspaceID), - ) - if (!workspaceID) throw new Error("Workspace ID not found") - - await Database.transaction(async (tx) => { - await tx - .update(PaymentTable) - .set({ - timeRefunded: new Date(body.created * 1000), - }) - .where( - and( - eq(PaymentTable.paymentID, paymentIntentID), - eq(PaymentTable.workspaceID, workspaceID), - ), - ) - - await tx - .update(BillingTable) - .set({ - balance: sql`${BillingTable.balance} - ${centsToMicroCents(Billing.CHARGE_AMOUNT)}`, - }) - .where(eq(BillingTable.workspaceID, workspaceID)) + .catch((error: any) => { + return Response.json({ message: error.message }, { status: 500 }) }) - } - - console.log("finished handling") - - return Response.json("ok", { status: 200 }) } diff --git a/packages/console/app/src/routes/workspace/[id]/billing/billing-section.module.css b/packages/console/app/src/routes/workspace/[id]/billing/billing-section.module.css index e0a80ef7..aef008a4 100644 --- a/packages/console/app/src/routes/workspace/[id]/billing/billing-section.module.css +++ b/packages/console/app/src/routes/workspace/[id]/billing/billing-section.module.css @@ -71,6 +71,57 @@ flex: 1; } + [data-slot="add-balance-form-container"] { + display: flex; + flex-direction: column; + gap: var(--space-2); + } + + [data-slot="add-balance-form"] { + display: flex; + flex-direction: row; + align-items: center; + gap: var(--space-3); + + label { + font-size: var(--font-size-sm); + font-weight: 500; + color: var(--color-text-muted); + white-space: nowrap; + } + + input[data-component="input"] { + padding: var(--space-2) var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-sm); + background-color: var(--color-bg); + color: var(--color-text); + font-size: var(--font-size-sm); + line-height: 1.5; + + &:focus { + outline: none; + border-color: var(--color-accent); + box-shadow: 0 0 0 3px var(--color-accent-alpha); + } + + &::placeholder { + color: var(--color-text-disabled); + } + } + + [data-slot="form-actions"] { + display: flex; + gap: var(--space-2); + } + } + + [data-slot="form-error"] { + color: var(--color-danger); + font-size: var(--font-size-sm); + line-height: 1.4; + } + [data-slot="credit-card"] { padding: var(--space-2) var(--space-4); background-color: var(--color-bg-surface); diff --git a/packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx index c0723136..9e51bbe1 100644 --- a/packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx @@ -1,24 +1,80 @@ -import { action, useParams, useAction, createAsync, useSubmission } from "@solidjs/router" -import { createMemo, Match, Show, Switch } from "solid-js" +import { action, useParams, useAction, createAsync, useSubmission, json } from "@solidjs/router" +import { createMemo, Match, Show, Switch, createEffect } from "solid-js" +import { createStore } from "solid-js/store" import { Billing } from "@opencode-ai/console-core/billing.js" import { withActor } from "~/context/auth.withActor" import { IconCreditCard, IconStripe } from "~/component/icon" import styles from "./billing-section.module.css" -import { createCheckoutUrl, queryBillingInfo } from "../../common" +import { createCheckoutUrl, formatBalance, queryBillingInfo } from "../../common" const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => { "use server" - return withActor(() => Billing.generateSessionUrl({ returnUrl }), workspaceID) + return json( + await withActor( + () => + Billing.generateSessionUrl({ returnUrl }) + .then((data) => ({ error: undefined, data })) + .catch((e) => ({ + error: e.message as string, + data: undefined, + })), + workspaceID, + ), + { revalidate: queryBillingInfo.key }, + ) }, "sessionUrl") export function BillingSection() { const params = useParams() // ORIGINAL CODE - COMMENTED OUT FOR TESTING - const balanceInfo = createAsync(() => queryBillingInfo(params.id)) - const createCheckoutUrlAction = useAction(createCheckoutUrl) - const createCheckoutUrlSubmission = useSubmission(createCheckoutUrl) - const createSessionUrlAction = useAction(createSessionUrl) - const createSessionUrlSubmission = useSubmission(createSessionUrl) + const billingInfo = createAsync(() => queryBillingInfo(params.id)) + const checkoutAction = useAction(createCheckoutUrl) + const checkoutSubmission = useSubmission(createCheckoutUrl) + const sessionAction = useAction(createSessionUrl) + const sessionSubmission = useSubmission(createSessionUrl) + const [store, setStore] = createStore({ + showAddBalanceForm: false, + addBalanceAmount: "", + checkoutRedirecting: false, + sessionRedirecting: false, + }) + const balance = createMemo(() => formatBalance(billingInfo()?.balance ?? 0)) + + async function onClickCheckout() { + const amount = parseInt(store.addBalanceAmount) + const baseUrl = window.location.href + + const checkout = await checkoutAction(params.id, amount, baseUrl, baseUrl) + if (checkout && checkout.data) { + setStore("checkoutRedirecting", true) + window.location.href = checkout.data + } + } + + async function onClickSession() { + const baseUrl = window.location.href + const sessionUrl = await sessionAction(params.id, baseUrl) + if (sessionUrl && sessionUrl.data) { + setStore("sessionRedirecting", true) + window.location.href = sessionUrl.data + } + } + + function showAddBalanceForm() { + while (true) { + checkoutSubmission.clear() + if (!checkoutSubmission.result) break + } + setStore({ + showAddBalanceForm: true, + addBalanceAmount: billingInfo()!.reloadAmount.toString(), + }) + } + + function hideAddBalanceForm() { + setStore("showAddBalanceForm", false) + checkoutSubmission.clear() + } // DUMMY DATA FOR TESTING - UNCOMMENT ONE OF THE SCENARIOS BELOW @@ -72,10 +128,6 @@ export function BillingSection() { // timeReloadError: null as Date | null // }) - const balanceAmount = createMemo(() => { - return ((balanceInfo()?.balance ?? 0) / 100000000).toFixed(2) - }) - return (
@@ -88,81 +140,110 @@ export function BillingSection() {
- - ${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()} - + ${balance()} Current Balance
- +
- + +
+
+ + {(err: any) =>
{err()}
} +
+
+ } > - {createCheckoutUrlSubmission.pending ? "Loading..." : "Add Balance"} - + +
}> - +
- + ----} > •••• - {balanceInfo()?.paymentMethodLast4} + {billingInfo()?.paymentMethodLast4} - + Linked to Stripe
- + diff --git a/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.tsx index dbeda115..b28b072d 100644 --- a/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.tsx @@ -1,16 +1,10 @@ -import { json, query, action, useParams, createAsync, useSubmission } from "@solidjs/router" +import { json, action, useParams, createAsync, useSubmission } from "@solidjs/router" import { createEffect, Show } from "solid-js" import { createStore } from "solid-js/store" import { withActor } from "~/context/auth.withActor" import { Billing } from "@opencode-ai/console-core/billing.js" import styles from "./monthly-limit-section.module.css" - -const getBillingInfo = query(async (workspaceID: string) => { - "use server" - return withActor(async () => { - return await Billing.get() - }, workspaceID) -}, "billing.get") +import { queryBillingInfo } from "../../common" const setMonthlyLimit = action(async (form: FormData) => { "use server" @@ -28,7 +22,7 @@ const setMonthlyLimit = action(async (form: FormData) => { .catch((e) => ({ error: e.message as string })), workspaceID, ), - { revalidate: getBillingInfo.key }, + { revalidate: queryBillingInfo.key }, ) }, "billing.setMonthlyLimit") @@ -36,7 +30,7 @@ export function MonthlyLimitSection() { const params = useParams() const submission = useSubmission(setMonthlyLimit) const [store, setStore] = createStore({ show: false }) - const balanceInfo = createAsync(() => getBillingInfo(params.id)) + const billingInfo = createAsync(() => queryBillingInfo(params.id)) let input: HTMLInputElement @@ -73,8 +67,8 @@ export function MonthlyLimitSection() {
- {balanceInfo()?.monthlyLimit ? $ : null} - {balanceInfo()?.monthlyLimit ?? "-"} + {billingInfo()?.monthlyLimit ? $ : null} + {billingInfo()?.monthlyLimit ?? "-"}
- No spending limit set.

}> + No spending limit set.

} + >

- Current usage for {new Date().toLocaleDateString("en-US", { month: "long", timeZone: "UTC" })} is $ + Current usage for{" "} + {new Date().toLocaleDateString("en-US", { month: "long", timeZone: "UTC" })} is $ {(() => { - const dateLastUsed = balanceInfo()?.timeMonthlyUsageUpdated + const dateLastUsed = billingInfo()?.timeMonthlyUsageUpdated if (!dateLastUsed) return "0" const current = new Date().toLocaleDateString("en-US", { @@ -128,7 +126,7 @@ export function MonthlyLimitSection() { timeZone: "UTC", }) if (current !== lastUsed) return "0" - return ((balanceInfo()?.monthlyUsage ?? 0) / 100000000).toFixed(2) + return ((billingInfo()?.monthlyUsage ?? 0) / 100000000).toFixed(2) })()} .

diff --git a/packages/console/app/src/routes/workspace/[id]/billing/reload-section.module.css b/packages/console/app/src/routes/workspace/[id]/billing/reload-section.module.css index 08fb8524..11ab789b 100644 --- a/packages/console/app/src/routes/workspace/[id]/billing/reload-section.module.css +++ b/packages/console/app/src/routes/workspace/[id]/billing/reload-section.module.css @@ -34,6 +34,206 @@ } } + [data-slot="create-form"] { + display: flex; + flex-direction: column; + gap: var(--space-3); + padding: var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-sm); + margin-top: var(--space-4); + + [data-slot="form-field"] { + display: flex; + flex-direction: column; + gap: var(--space-2); + + label { + display: flex; + flex-direction: column; + gap: var(--space-2); + } + + [data-slot="field-label"] { + font-size: var(--font-size-sm); + font-weight: 500; + color: var(--color-text-muted); + } + + [data-slot="toggle-container"] { + display: flex; + align-items: center; + } + + input[data-component="input"] { + flex: 1; + padding: var(--space-2) var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-sm); + background-color: var(--color-bg); + color: var(--color-text); + font-size: var(--font-size-sm); + font-family: var(--font-mono); + + &:focus { + outline: none; + border-color: var(--color-accent); + } + + &::placeholder { + color: var(--color-text-disabled); + } + } + } + + [data-slot="input-row"] { + display: flex; + flex-direction: row; + gap: var(--space-3); + + @media (max-width: 40rem) { + flex-direction: column; + gap: var(--space-2); + } + } + + [data-slot="input-field"] { + display: flex; + flex-direction: column; + gap: var(--space-1); + flex: 1; + + p { + line-height: 1.2; + margin: 0; + color: var(--color-text-muted); + font-size: var(--font-size-sm); + } + + input[data-component="input"] { + flex: 1; + padding: var(--space-2) var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-sm); + background-color: var(--color-bg); + color: var(--color-text); + font-size: var(--font-size-sm); + line-height: 1.5; + min-width: 0; + + &:focus { + outline: none; + border-color: var(--color-accent); + box-shadow: 0 0 0 3px var(--color-accent-alpha); + } + + &::placeholder { + color: var(--color-text-disabled); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + background-color: var(--color-bg-surface); + } + } + + [data-slot="field-with-connector"] { + display: flex; + align-items: center; + gap: var(--space-2); + + [data-slot="field-connector"] { + font-size: var(--font-size-sm); + color: var(--color-text-muted); + white-space: nowrap; + } + + input[data-component="input"] { + flex: 1; + min-width: 80px; + } + } + } + + [data-slot="form-actions"] { + display: flex; + gap: var(--space-2); + margin-top: var(--space-1); + } + + [data-slot="form-error"] { + color: var(--color-danger); + font-size: var(--font-size-sm); + line-height: 1.4; + margin-top: calc(var(--space-1) * -1); + } + + [data-slot="model-toggle-label"] { + position: relative; + display: inline-block; + width: 2.5rem; + height: 1.5rem; + cursor: pointer; + + input { + opacity: 0; + width: 0; + height: 0; + } + + span { + position: absolute; + inset: 0; + background-color: #ccc; + border: 1px solid #bbb; + border-radius: 1.5rem; + transition: all 0.3s ease; + cursor: pointer; + + &::before { + content: ""; + position: absolute; + top: 50%; + left: 0.125rem; + width: 1.25rem; + height: 1.25rem; + background-color: white; + border: 1px solid #ddd; + border-radius: 50%; + transform: translateY(-50%); + transition: all 0.3s ease; + } + } + + input:checked + span { + background-color: #21ad0e; + border-color: #148605; + + &::before { + transform: translateX(1rem) translateY(-50%); + } + } + + &:hover span { + box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2); + } + + input:checked:hover + span { + box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.3); + } + + &:has(input:disabled) { + cursor: not-allowed; + } + + input:disabled + span { + opacity: 0.5; + cursor: not-allowed; + } + } + } + [data-slot="reload-error"] { display: flex; align-items: center; @@ -54,6 +254,8 @@ gap: var(--space-2); margin: 0; flex-shrink: 0; + padding: 0; + border: none; } } } diff --git a/packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx index 6be6ddf3..57267a95 100644 --- a/packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx @@ -1,17 +1,19 @@ -import { json, query, action, useParams, createAsync, useSubmission } from "@solidjs/router" -import { Show } from "solid-js" +import { json, action, useParams, createAsync, useSubmission } from "@solidjs/router" +import { createEffect, Show } from "solid-js" +import { createStore } from "solid-js/store" import { withActor } from "~/context/auth.withActor" import { Billing } from "@opencode-ai/console-core/billing.js" import { Database, eq } from "@opencode-ai/console-core/drizzle/index.js" import { BillingTable } from "@opencode-ai/console-core/schema/billing.sql.js" import styles from "./reload-section.module.css" +import { queryBillingInfo } from "../../common" 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, + revalidate: queryBillingInfo.key, }) }, "billing.reload") @@ -20,12 +22,27 @@ const setReload = action(async (form: FormData) => { const workspaceID = form.get("workspaceID")?.toString() if (!workspaceID) return { error: "Workspace ID is required" } const reloadValue = form.get("reload")?.toString() === "true" + const amountStr = form.get("reloadAmount")?.toString() + const triggerStr = form.get("reloadTrigger")?.toString() + + const reloadAmount = amountStr && amountStr.trim() !== "" ? parseInt(amountStr) : null + const reloadTrigger = triggerStr && triggerStr.trim() !== "" ? parseInt(triggerStr) : null + + if (reloadValue) { + if (reloadAmount === null || reloadAmount < Billing.RELOAD_AMOUNT_MIN) + return { error: `Reload amount must be at least $${Billing.RELOAD_AMOUNT_MIN}` } + if (reloadTrigger === null || reloadTrigger < Billing.RELOAD_TRIGGER_MIN) + return { error: `Balance trigger must be at least $${Billing.RELOAD_TRIGGER_MIN}` } + } + return json( await Database.use((tx) => tx .update(BillingTable) .set({ reload: reloadValue, + ...(reloadAmount !== null ? { reloadAmount } : {}), + ...(reloadTrigger !== null ? { reloadTrigger } : {}), ...(reloadValue ? { reloadError: null, @@ -35,22 +52,47 @@ const setReload = action(async (form: FormData) => { }) .where(eq(BillingTable.workspaceID, workspaceID)), ), - { revalidate: getBillingInfo.key }, + { revalidate: queryBillingInfo.key }, ) }, "billing.setReload") -const getBillingInfo = query(async (workspaceID: string) => { - "use server" - return withActor(async () => { - return await Billing.get() - }, workspaceID) -}, "billing.get") - export function ReloadSection() { const params = useParams() - const balanceInfo = createAsync(() => getBillingInfo(params.id)) + const billingInfo = createAsync(() => queryBillingInfo(params.id)) const setReloadSubmission = useSubmission(setReload) const reloadSubmission = useSubmission(reload) + const [store, setStore] = createStore({ + show: false, + reload: false, + reloadAmount: "", + reloadTrigger: "", + }) + + createEffect(() => { + if ( + !setReloadSubmission.pending && + setReloadSubmission.result && + !(setReloadSubmission.result as any).error + ) { + setStore("show", false) + } + }) + + function show() { + while (true) { + setReloadSubmission.clear() + if (!setReloadSubmission.result) break + } + const info = billingInfo()! + setStore("show", true) + setStore("reload", info.reload ? true : true) + setStore("reloadAmount", info.reloadAmount.toString()) + setStore("reloadTrigger", info.reloadTrigger.toString()) + } + + function hide() { + setStore("show", false) + } return (
@@ -58,43 +100,101 @@ export function ReloadSection() {

Auto Reload

Auto reload is disabled. Enable to automatically reload when balance is low.

+

+ Auto reload is disabled. Enable to automatically reload when balance is low. +

} >

- We'll automatically reload $20 (+$1.23 processing fee) when it reaches{" "} - $5. + Auto reload is enabled. We'll reload ${billingInfo()?.reloadAmount}{" "} + (+$1.23 processing fee) when balance reaches ${billingInfo()?.reloadTrigger}.

-
- - - -
+
+ +
+
+ +
+ +
+
+

Reload $

+ setStore("reloadAmount", e.currentTarget.value)} + placeholder={billingInfo()?.reloadAmount.toString()} + disabled={!store.reload} + /> +
+
+

When balance reaches $

+ setStore("reloadTrigger", e.currentTarget.value)} + placeholder={billingInfo()?.reloadTrigger.toString()} + disabled={!store.reload} + /> +
+
+ + + {(err: any) =>
{err()}
} +
+ +
+ + +
+
+
- +

Reload failed at{" "} - {balanceInfo()?.timeReloadError!.toLocaleString("en-US", { + {billingInfo()?.timeReloadError!.toLocaleString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit", second: "2-digit", })} - . Reason: {balanceInfo()?.reloadError?.replace(/\.$/, "")}. Please update your payment + . Reason: {billingInfo()?.reloadError?.replace(/\.$/, "")}. Please update your payment method and try again.

diff --git a/packages/console/app/src/routes/workspace/[id]/index.tsx b/packages/console/app/src/routes/workspace/[id]/index.tsx index 8f7678f2..2e7f7d64 100644 --- a/packages/console/app/src/routes/workspace/[id]/index.tsx +++ b/packages/console/app/src/routes/workspace/[id]/index.tsx @@ -1,22 +1,32 @@ +import { Show, createMemo } from "solid-js" +import { createStore } from "solid-js/store" +import { createAsync, useParams, useAction, useSubmission } from "@solidjs/router" import { NewUserSection } from "./new-user-section" import { UsageSection } from "./usage-section" import { ModelSection } from "./model-section" import { ProviderSection } from "./provider-section" import { IconLogo } from "~/component/icon" -import { createAsync, useParams, useAction, useSubmission } from "@solidjs/router" -import { querySessionInfo, queryBillingInfo, createCheckoutUrl } from "../common" -import { Show, createMemo } from "solid-js" +import { querySessionInfo, queryBillingInfo, createCheckoutUrl, formatBalance } from "../common" export default function () { const params = useParams() const userInfo = createAsync(() => querySessionInfo(params.id)) const billingInfo = createAsync(() => queryBillingInfo(params.id)) - const createCheckoutUrlAction = useAction(createCheckoutUrl) - const createCheckoutUrlSubmission = useSubmission(createCheckoutUrl) - - const balanceAmount = createMemo(() => { - return ((billingInfo()?.balance ?? 0) / 100000000).toFixed(2) + const checkoutAction = useAction(createCheckoutUrl) + const checkoutSubmission = useSubmission(createCheckoutUrl) + const [store, setStore] = createStore({ + checkoutRedirecting: false, }) + const balance = createMemo(() => formatBalance(billingInfo()?.balance ?? 0)) + + async function onClickCheckout() { + const baseUrl = window.location.href + const checkout = await checkoutAction(params.id, billingInfo()!.reloadAmount, baseUrl, baseUrl) + if (checkout && checkout.data) { + setStore("checkoutRedirecting", true) + window.location.href = checkout.data + } + } return (
@@ -38,21 +48,17 @@ export default function () { } > - Current balance ${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()} + Current balance ${balance()} diff --git a/packages/console/app/src/routes/workspace/common.tsx b/packages/console/app/src/routes/workspace/common.tsx index 69bfebe9..5b638192 100644 --- a/packages/console/app/src/routes/workspace/common.tsx +++ b/packages/console/app/src/routes/workspace/common.tsx @@ -1,6 +1,6 @@ import { Resource } from "@opencode-ai/console-resource" import { Actor } from "@opencode-ai/console-core/actor.js" -import { action, query } from "@solidjs/router" +import { action, json, query } from "@solidjs/router" import { withActor } from "~/context/auth.withActor" import { Billing } from "@opencode-ai/console-core/billing.js" import { User } from "@opencode-ai/console-core/user.js" @@ -34,6 +34,11 @@ export function formatDateUTC(date: Date) { return date.toLocaleDateString("en-US", options) } +export function formatBalance(amount: number) { + const balance = ((amount ?? 0) / 100000000).toFixed(2) + return balance === "-0.00" ? "0.00" : balance +} + export async function getLastSeenWorkspaceID() { "use server" return withActor(async () => { @@ -71,14 +76,34 @@ export const querySessionInfo = query(async (workspaceID: string) => { }, "session.get") export const createCheckoutUrl = action( - async (workspaceID: string, successUrl: string, cancelUrl: string) => { + async (workspaceID: string, amount: number, successUrl: string, cancelUrl: string) => { "use server" - return withActor(() => Billing.generateCheckoutUrl({ successUrl, cancelUrl }), workspaceID) + return json( + await withActor( + () => + Billing.generateCheckoutUrl({ amount, successUrl, cancelUrl }) + .then((data) => ({ error: undefined, data })) + .catch((e) => ({ + error: e.message as string, + data: undefined, + })), + workspaceID, + ), + ) }, "checkoutUrl", ) export const queryBillingInfo = query(async (workspaceID: string) => { "use server" - return withActor(() => Billing.get(), workspaceID) + return withActor(async () => { + const billing = await Billing.get() + return { + ...billing, + reloadAmount: billing.reloadAmount ?? Billing.RELOAD_AMOUNT, + reloadAmountMin: Billing.RELOAD_AMOUNT_MIN, + reloadTrigger: billing.reloadTrigger ?? Billing.RELOAD_TRIGGER, + reloadTriggerMin: Billing.RELOAD_TRIGGER_MIN, + } + }, workspaceID) }, "billing.get") diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 0d46e858..deab7ded 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -281,6 +281,7 @@ export async function handler( monthlyLimit: BillingTable.monthlyLimit, monthlyUsage: BillingTable.monthlyUsage, timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated, + reloadTrigger: BillingTable.reloadTrigger, }, user: { id: UserTable.id, @@ -532,7 +533,10 @@ export async function handler( and( eq(BillingTable.workspaceID, authInfo.workspaceID), eq(BillingTable.reload, true), - lt(BillingTable.balance, centsToMicroCents(Billing.CHARGE_THRESHOLD)), + lt( + BillingTable.balance, + centsToMicroCents((authInfo.billing.reloadTrigger ?? Billing.RELOAD_TRIGGER) * 100), + ), or( isNull(BillingTable.timeReloadLockedTill), lt(BillingTable.timeReloadLockedTill, sql`now()`), diff --git a/packages/console/core/src/billing.ts b/packages/console/core/src/billing.ts index 70bf1bc3..34871814 100644 --- a/packages/console/core/src/billing.ts +++ b/packages/console/core/src/billing.ts @@ -10,13 +10,12 @@ import { centsToMicroCents } from "./util/price" import { User } from "./user" export namespace Billing { - export const CHARGE_NAME = "opencode credits" - export const CHARGE_FEE_NAME = "processing fee" - export const CHARGE_AMOUNT = 2000 // $20 - export const CHARGE_AMOUNT_DOLLAR = 20 - export const CHARGE_FEE = 123 // Stripe fee 4.4% + $0.30 - export const CHARGE_THRESHOLD_DOLLAR = 5 - export const CHARGE_THRESHOLD = 500 // $5 + export const ITEM_CREDIT_NAME = "opencode credits" + export const ITEM_FEE_NAME = "processing fee" + export const RELOAD_AMOUNT = 20 + export const RELOAD_AMOUNT_MIN = 10 + export const RELOAD_TRIGGER = 5 + export const RELOAD_TRIGGER_MIN = 5 export const stripe = () => new Stripe(Resource.STRIPE_SECRET_KEY.value, { apiVersion: "2025-03-31.basil", @@ -33,6 +32,8 @@ export namespace Billing { paymentMethodLast4: BillingTable.paymentMethodLast4, balance: BillingTable.balance, reload: BillingTable.reload, + reloadAmount: BillingTable.reloadAmount, + reloadTrigger: BillingTable.reloadTrigger, monthlyLimit: BillingTable.monthlyLimit, monthlyUsage: BillingTable.monthlyUsage, timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated, @@ -67,17 +68,28 @@ export namespace Billing { ) } + export const calculateFeeInCents = (x: number) => { + // math: x = total - (total * 0.044 + 0.30) + // math: x = total * (1-0.044) - 0.30 + // math: (x + 0.30) / 0.956 = total + return Math.round(((x + 30) / 0.956) * 0.044 + 30) + } + export const reload = async () => { - const { customerID, paymentMethodID } = await Database.use((tx) => + const billing = await Database.use((tx) => tx .select({ customerID: BillingTable.customerID, paymentMethodID: BillingTable.paymentMethodID, + reloadAmount: BillingTable.reloadAmount, }) .from(BillingTable) .where(eq(BillingTable.workspaceID, Actor.workspace())) .then((rows) => rows[0]), ) + const customerID = billing.customerID + const paymentMethodID = billing.paymentMethodID + const amountInCents = (billing.reloadAmount ?? Billing.RELOAD_AMOUNT) * 100 const paymentID = Identifier.create("payment") let invoice try { @@ -89,18 +101,18 @@ export namespace Billing { currency: "usd", }) await Billing.stripe().invoiceItems.create({ - amount: Billing.CHARGE_AMOUNT, + amount: amountInCents, currency: "usd", customer: customerID!, - description: CHARGE_NAME, invoice: draft.id!, + description: ITEM_CREDIT_NAME, }) await Billing.stripe().invoiceItems.create({ - amount: Billing.CHARGE_FEE, + amount: calculateFeeInCents(amountInCents), currency: "usd", customer: customerID!, - description: CHARGE_FEE_NAME, invoice: draft.id!, + description: ITEM_FEE_NAME, }) await Billing.stripe().invoices.finalizeInvoice(draft.id!) invoice = await Billing.stripe().invoices.pay(draft.id!, { @@ -128,7 +140,7 @@ export namespace Billing { await tx .update(BillingTable) .set({ - balance: sql`${BillingTable.balance} + ${centsToMicroCents(CHARGE_AMOUNT)}`, + balance: sql`${BillingTable.balance} + ${centsToMicroCents(amountInCents)}`, reloadError: null, timeReloadError: null, }) @@ -136,7 +148,7 @@ export namespace Billing { await tx.insert(PaymentTable).values({ workspaceID: Actor.workspace(), id: paymentID, - amount: centsToMicroCents(CHARGE_AMOUNT), + amount: centsToMicroCents(amountInCents), invoiceID: invoice.id!, paymentID: invoice.payments?.data[0].payment.payment_intent as string, customerID, @@ -159,13 +171,19 @@ export namespace Billing { z.object({ successUrl: z.string(), cancelUrl: z.string(), + amount: z.number().optional(), }), async (input) => { const user = Actor.assert("user") - const { successUrl, cancelUrl } = input + const { successUrl, cancelUrl, amount } = input + + if (amount !== undefined && amount < Billing.RELOAD_AMOUNT_MIN) { + throw new Error(`Amount must be at least $${Billing.RELOAD_AMOUNT_MIN}`) + } const email = await User.getAuthEmail(user.properties.userID) const customer = await Billing.get() + const amountInCents = (amount ?? customer.reloadAmount ?? Billing.RELOAD_AMOUNT) * 100 const session = await Billing.stripe().checkout.sessions.create({ mode: "payment", billing_address_collection: "required", @@ -173,20 +191,16 @@ export namespace Billing { { price_data: { currency: "usd", - product_data: { - name: CHARGE_NAME, - }, - unit_amount: CHARGE_AMOUNT, + product_data: { name: ITEM_CREDIT_NAME }, + unit_amount: amountInCents, }, quantity: 1, }, { price_data: { currency: "usd", - product_data: { - name: CHARGE_FEE_NAME, - }, - unit_amount: CHARGE_FEE, + product_data: { name: ITEM_FEE_NAME }, + unit_amount: calculateFeeInCents(amountInCents), }, quantity: 1, }, @@ -218,6 +232,7 @@ export namespace Billing { }, metadata: { workspaceID: Actor.workspace(), + amount: amountInCents.toString(), }, success_url: successUrl, cancel_url: cancelUrl,