Frank 3 месяцев назад
Родитель
Сommit
8d6a03cc89

+ 129 - 118
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")
+      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 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,
+            },
+          })
+        }
 
-      // set customer metadata
-      if (!customer?.customerID) {
-        await Billing.stripe().customers.update(customerID, {
-          metadata: {
+        // 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,
+          })
         })
-      }
-
-      // 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) =>
+    }
+    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({
-            customerID: BillingTable.customerID,
+            workspaceID: BillingTable.workspaceID,
           })
           .from(BillingTable)
-          .where(eq(BillingTable.workspaceID, workspaceID))
-          .then((rows) => rows[0]),
+          .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} + ${centsToMicroCents(Billing.CHARGE_AMOUNT)}`,
-            customerID,
-            paymentMethodID: paymentMethod.id,
-            paymentMethodLast4: paymentMethod.card?.last4 ?? null,
-            paymentMethodType: paymentMethod.type,
-            // enable reload if first time enabling billing
-            ...(oldBillingInfo?.customerID
-              ? {}
-              : {
-                  reload: true,
-                  reloadError: null,
-                  timeReloadError: null,
-                }),
+            balance: sql`${BillingTable.balance} - ${amount}`,
           })
           .where(eq(BillingTable.workspaceID, workspaceID))
-        await tx.insert(PaymentTable).values({
-          workspaceID,
-          id: Identifier.create("payment"),
-          amount: centsToMicroCents(Billing.CHARGE_AMOUNT),
-          paymentID,
-          invoiceID,
-          customerID,
-        })
       })
+    }
+  })()
+    .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 })
 }

+ 51 - 0
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);

+ 134 - 53
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 (
     <section class={styles.root}>
       <div data-slot="section-title">
@@ -88,81 +140,110 @@ export function BillingSection() {
       <div data-slot="section-content">
         <div data-slot="balance-display">
           <div data-slot="balance-amount">
-            <span data-slot="balance-value">
-              ${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()}
-            </span>
+            <span data-slot="balance-value">${balance()}</span>
             <span data-slot="balance-label">Current Balance</span>
           </div>
-          <Show when={balanceInfo()?.customerID}>
+          <Show when={billingInfo()?.customerID}>
             <div data-slot="balance-right-section">
-              <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={!store.showAddBalanceForm}
+                fallback={
+                  <div data-slot="add-balance-form-container">
+                    <div data-slot="add-balance-form">
+                      <label>Add $</label>
+                      <input
+                        data-component="input"
+                        type="number"
+                        min={billingInfo()?.reloadAmountMin.toString()}
+                        step="1"
+                        value={store.addBalanceAmount}
+                        onInput={(e) => {
+                          setStore("addBalanceAmount", e.currentTarget.value)
+                          checkoutSubmission.clear()
+                        }}
+                        placeholder="Enter amount"
+                      />
+                      <div data-slot="form-actions">
+                        <button
+                          data-color="ghost"
+                          type="button"
+                          onClick={() => hideAddBalanceForm()}
+                        >
+                          Cancel
+                        </button>
+                        <button
+                          data-color="primary"
+                          type="button"
+                          disabled={
+                            !store.addBalanceAmount ||
+                            checkoutSubmission.pending ||
+                            store.checkoutRedirecting
+                          }
+                          onClick={onClickCheckout}
+                        >
+                          {checkoutSubmission.pending || store.checkoutRedirecting
+                            ? "Loading..."
+                            : "Add"}
+                        </button>
+                      </div>
+                    </div>
+                    <Show
+                      when={checkoutSubmission.result && (checkoutSubmission.result as any).error}
+                    >
+                      {(err: any) => <div data-slot="form-error">{err()}</div>}
+                    </Show>
+                  </div>
+                }
               >
-                {createCheckoutUrlSubmission.pending ? "Loading..." : "Add Balance"}
-              </button>
+                <button data-color="primary" onClick={() => showAddBalanceForm()}>
+                  Add Balance
+                </button>
+              </Show>
               <div data-slot="credit-card">
                 <div data-slot="card-icon">
                   <Switch fallback={<IconCreditCard style={{ width: "24px", height: "24px" }} />}>
-                    <Match when={balanceInfo()?.paymentMethodType === "link"}>
+                    <Match when={billingInfo()?.paymentMethodType === "link"}>
                       <IconStripe style={{ width: "24px", height: "24px" }} />
                     </Match>
                   </Switch>
                 </div>
                 <div data-slot="card-details">
                   <Switch>
-                    <Match when={balanceInfo()?.paymentMethodType === "card"}>
+                    <Match when={billingInfo()?.paymentMethodType === "card"}>
                       <Show
-                        when={balanceInfo()?.paymentMethodLast4}
+                        when={billingInfo()?.paymentMethodLast4}
                         fallback={<span data-slot="number">----</span>}
                       >
                         <span data-slot="secret">••••</span>
-                        <span data-slot="number">{balanceInfo()?.paymentMethodLast4}</span>
+                        <span data-slot="number">{billingInfo()?.paymentMethodLast4}</span>
                       </Show>
                     </Match>
-                    <Match when={balanceInfo()?.paymentMethodType === "link"}>
+                    <Match when={billingInfo()?.paymentMethodType === "link"}>
                       <span data-slot="type">Linked to Stripe</span>
                     </Match>
                   </Switch>
                 </div>
                 <button
                   data-color="ghost"
-                  disabled={createSessionUrlSubmission.pending}
-                  onClick={async () => {
-                    const baseUrl = window.location.href
-                    const sessionUrl = await createSessionUrlAction(params.id, baseUrl)
-                    if (sessionUrl) {
-                      window.location.href = sessionUrl
-                    }
-                  }}
+                  disabled={sessionSubmission.pending || store.sessionRedirecting}
+                  onClick={onClickSession}
                 >
-                  {createSessionUrlSubmission.pending ? "Loading..." : "Manage"}
+                  {sessionSubmission.pending || store.sessionRedirecting ? "Loading..." : "Manage"}
                 </button>
               </div>
             </div>
           </Show>
         </div>
-        <Show when={!balanceInfo()?.customerID}>
+        <Show when={!billingInfo()?.customerID}>
           <button
             data-slot="enable-billing-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
-              }
-            }}
+            disabled={checkoutSubmission.pending || store.checkoutRedirecting}
+            onClick={onClickCheckout}
           >
-            {createCheckoutUrlSubmission.pending ? "Loading..." : "Enable Billing"}
+            {checkoutSubmission.pending || store.checkoutRedirecting
+              ? "Loading..."
+              : "Enable Billing"}
           </button>
         </Show>
       </div>

+ 15 - 17
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() {
       <div data-slot="section-content">
         <div data-slot="balance">
           <div data-slot="amount">
-            {balanceInfo()?.monthlyLimit ? <span data-slot="currency">$</span> : null}
-            <span data-slot="value">{balanceInfo()?.monthlyLimit ?? "-"}</span>
+            {billingInfo()?.monthlyLimit ? <span data-slot="currency">$</span> : null}
+            <span data-slot="value">{billingInfo()?.monthlyLimit ?? "-"}</span>
           </div>
           <Show
             when={!store.show}
@@ -106,15 +100,19 @@ export function MonthlyLimitSection() {
             }
           >
             <button data-color="primary" onClick={() => show()}>
-              {balanceInfo()?.monthlyLimit ? "Edit Limit" : "Set Limit"}
+              {billingInfo()?.monthlyLimit ? "Edit Limit" : "Set Limit"}
             </button>
           </Show>
         </div>
-        <Show when={balanceInfo()?.monthlyLimit} fallback={<p data-slot="usage-status">No spending limit set.</p>}>
+        <Show
+          when={billingInfo()?.monthlyLimit}
+          fallback={<p data-slot="usage-status">No spending limit set.</p>}
+        >
           <p data-slot="usage-status">
-            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)
             })()}
             .
           </p>

+ 202 - 0
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;
     }
   }
 }

+ 131 - 31
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 (
     <section class={styles.root}>
@@ -58,43 +100,101 @@ export function ReloadSection() {
         <h2>Auto Reload</h2>
         <div data-slot="title-row">
           <Show
-            when={balanceInfo()?.reload}
+            when={billingInfo()?.reload}
             fallback={
-              <p>Auto reload is disabled. Enable to automatically reload when balance is low.</p>
+              <p>
+                Auto reload is <b>disabled</b>. Enable to automatically reload when balance is low.
+              </p>
             }
           >
             <p>
-              We'll automatically reload <b>$20</b> (+$1.23 processing fee) when it reaches{" "}
-              <b>$5</b>.
+              Auto reload is <b>enabled</b>. We'll reload <b>${billingInfo()?.reloadAmount}</b>{" "}
+              (+$1.23 processing fee) when balance reaches <b>${billingInfo()?.reloadTrigger}</b>.
             </p>
           </Show>
-          <form action={setReload} method="post" data-slot="create-form">
-            <input type="hidden" name="workspaceID" value={params.id} />
-            <input type="hidden" name="reload" value={balanceInfo()?.reload ? "false" : "true"} />
-            <button data-color="primary" type="submit" disabled={setReloadSubmission.pending}>
-              <Show
-                when={balanceInfo()?.reload}
-                fallback={setReloadSubmission.pending ? "Enabling..." : "Enable"}
-              >
-                {setReloadSubmission.pending ? "Disabling..." : "Disable"}
-              </Show>
-            </button>
-          </form>
+          <button data-color="primary" type="button" onClick={() => show()}>
+            {billingInfo()?.reload ? "Edit" : "Enable"}
+          </button>
         </div>
       </div>
+      <Show when={store.show}>
+        <form action={setReload} method="post" data-slot="create-form">
+          <div data-slot="form-field">
+            <label>
+              <span data-slot="field-label">Enable Auto Reload</span>
+              <div data-slot="toggle-container">
+                <label data-slot="model-toggle-label">
+                  <input
+                    type="checkbox"
+                    name="reload"
+                    value="true"
+                    checked={store.reload}
+                    onChange={(e) => setStore("reload", e.currentTarget.checked)}
+                  />
+                  <span></span>
+                </label>
+              </div>
+            </label>
+          </div>
+
+          <div data-slot="input-row">
+            <div data-slot="input-field">
+              <p>Reload $</p>
+              <input
+                data-component="input"
+                name="reloadAmount"
+                type="number"
+                min={billingInfo()?.reloadAmountMin.toString()}
+                step="1"
+                value={store.reloadAmount}
+                onInput={(e) => setStore("reloadAmount", e.currentTarget.value)}
+                placeholder={billingInfo()?.reloadAmount.toString()}
+                disabled={!store.reload}
+              />
+            </div>
+            <div data-slot="input-field">
+              <p>When balance reaches $</p>
+              <input
+                data-component="input"
+                name="reloadTrigger"
+                type="number"
+                min={billingInfo()?.reloadTriggerMin.toString()}
+                step="1"
+                value={store.reloadTrigger}
+                onInput={(e) => setStore("reloadTrigger", e.currentTarget.value)}
+                placeholder={billingInfo()?.reloadTrigger.toString()}
+                disabled={!store.reload}
+              />
+            </div>
+          </div>
+
+          <Show when={setReloadSubmission.result && (setReloadSubmission.result as any).error}>
+            {(err: any) => <div data-slot="form-error">{err()}</div>}
+          </Show>
+          <input type="hidden" name="workspaceID" value={params.id} />
+          <div data-slot="form-actions">
+            <button type="button" data-color="ghost" onClick={() => hide()}>
+              Cancel
+            </button>
+            <button type="submit" data-color="primary" disabled={setReloadSubmission.pending}>
+              {setReloadSubmission.pending ? "Saving..." : "Save"}
+            </button>
+          </div>
+        </form>
+      </Show>
       <div data-slot="section-content">
-        <Show when={balanceInfo()?.reload && balanceInfo()?.reloadError}>
+        <Show when={billingInfo()?.reload && billingInfo()?.reloadError}>
           <div data-slot="reload-error">
             <p>
               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.
             </p>
             <form action={reload} method="post" data-slot="create-form">

+ 24 - 18
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 (
     <div data-page="workspace-[id]">
@@ -38,21 +48,17 @@ export default function () {
                   <button
                     data-color="primary"
                     data-size="sm"
-                    disabled={createCheckoutUrlSubmission.pending}
-                    onClick={async () => {
-                      const baseUrl = window.location.href
-                      const checkoutUrl = await createCheckoutUrlAction(params.id, baseUrl, baseUrl)
-                      if (checkoutUrl) {
-                        window.location.href = checkoutUrl
-                      }
-                    }}
+                    disabled={checkoutSubmission.pending || store.checkoutRedirecting}
+                    onClick={onClickCheckout}
                   >
-                    {createCheckoutUrlSubmission.pending ? "Loading..." : "Enable billing"}
+                    {checkoutSubmission.pending || store.checkoutRedirecting
+                      ? "Loading..."
+                      : "Enable billing"}
                   </button>
                 }
               >
                 <span data-slot="balance">
-                  Current balance <b>${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()}</b>
+                  Current balance <b>${balance()}</b>
                 </span>
               </Show>
             </span>

+ 29 - 4
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")

+ 5 - 1
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()`),

+ 38 - 23
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,