summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorFrank <[email protected]>2025-11-04 16:51:38 -0500
committerFrank <[email protected]>2025-11-04 17:24:20 -0500
commit8d6a03cc898c0b982b7a05419d41a07e8db579f8 (patch)
treebb881e3aac7d5984dc8e99b78a08a4e353b283d7
parent71b04ffa99a3215316083dcf4d5e15afb3193958 (diff)
downloadopencode-8d6a03cc898c0b982b7a05419d41a07e8db579f8.tar.gz
opencode-8d6a03cc898c0b982b7a05419d41a07e8db579f8.zip
zen: custom reload amount
-rw-r--r--packages/console/app/src/routes/stripe/webhook.ts247
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/billing-section.module.css51
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx187
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.tsx32
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/reload-section.module.css202
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx162
-rw-r--r--packages/console/app/src/routes/workspace/[id]/index.tsx42
-rw-r--r--packages/console/app/src/routes/workspace/common.tsx33
-rw-r--r--packages/console/app/src/routes/zen/util/handler.ts6
-rw-r--r--packages/console/core/src/billing.ts61
10 files changed, 758 insertions, 265 deletions
diff --git a/packages/console/app/src/routes/stripe/webhook.ts b/packages/console/app/src/routes/stripe/webhook.ts
index cc44f8674..d8d857255 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")
+ 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 })
}
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 e0a80ef74..aef008a48 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 c0723136b..9e51bbe10 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 (
<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>
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 dbeda115c..b28b072d5 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() {
<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>
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 08fb8524b..11ab789b2 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 6be6ddf31..57267a95e 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 (
<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">
diff --git a/packages/console/app/src/routes/workspace/[id]/index.tsx b/packages/console/app/src/routes/workspace/[id]/index.tsx
index 8f7678f21..2e7f7d64b 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 (
<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>
diff --git a/packages/console/app/src/routes/workspace/common.tsx b/packages/console/app/src/routes/workspace/common.tsx
index 69bfebe97..5b638192c 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 0d46e8580..deab7ded2 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 70bf1bc36..348718146 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,