summaryrefslogtreecommitdiffhomepage
path: root/packages/console/app/src
diff options
context:
space:
mode:
authorFrank <[email protected]>2026-01-22 16:59:32 -0500
committerFrank <[email protected]>2026-01-22 17:02:46 -0500
commit5f3ab9395fc8f8543724dcda914d38fba0809049 (patch)
tree3be83aa5cc4e4556b62f9ff6b23933f8699eea63 /packages/console/app/src
parentfdac21688c9acc4087b83e71cb7e0fd1d2d57f00 (diff)
downloadopencode-5f3ab9395fc8f8543724dcda914d38fba0809049.tar.gz
opencode-5f3ab9395fc8f8543724dcda914d38fba0809049.zip
wip: zen black
Diffstat (limited to 'packages/console/app/src')
-rw-r--r--packages/console/app/src/routes/stripe/webhook.ts302
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/black-section.tsx103
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/black-waitlist-section.module.css15
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/black-waitlist-section.tsx57
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/index.tsx6
-rw-r--r--packages/console/app/src/routes/workspace/[id]/index.tsx20
-rw-r--r--packages/console/app/src/routes/workspace/common.tsx1
-rw-r--r--packages/console/app/src/routes/zen/util/handler.ts2
8 files changed, 276 insertions, 230 deletions
diff --git a/packages/console/app/src/routes/stripe/webhook.ts b/packages/console/app/src/routes/stripe/webhook.ts
index c7f752327..fc7b18fbc 100644
--- a/packages/console/app/src/routes/stripe/webhook.ts
+++ b/packages/console/app/src/routes/stripe/webhook.ts
@@ -216,141 +216,71 @@ export async function POST(input: APIEvent) {
})
}
if (body.type === "customer.subscription.created") {
- const data = {
- id: "evt_1Smq802SrMQ2Fneksse5FMNV",
- object: "event",
- api_version: "2025-07-30.basil",
- created: 1767766916,
- data: {
- object: {
- id: "sub_1Smq7x2SrMQ2Fnek8F1yf3ZD",
- object: "subscription",
- application: null,
- application_fee_percent: null,
- automatic_tax: {
- disabled_reason: null,
- enabled: false,
- liability: null,
- },
- billing_cycle_anchor: 1770445200,
- billing_cycle_anchor_config: null,
- billing_mode: {
- flexible: {
- proration_discounts: "included",
- },
- type: "flexible",
- updated_at: 1770445200,
- },
+ /*
+{
+ id: "evt_1Smq802SrMQ2Fneksse5FMNV",
+ object: "event",
+ api_version: "2025-07-30.basil",
+ created: 1767766916,
+ data: {
+ object: {
+ id: "sub_1Smq7x2SrMQ2Fnek8F1yf3ZD",
+ object: "subscription",
+ application: null,
+ application_fee_percent: null,
+ automatic_tax: {
+ disabled_reason: null,
+ enabled: false,
+ liability: null,
+ },
+ billing_cycle_anchor: 1770445200,
+ billing_cycle_anchor_config: null,
+ billing_mode: {
+ flexible: {
+ proration_discounts: "included",
+ },
+ type: "flexible",
+ updated_at: 1770445200,
+ },
+ billing_thresholds: null,
+ cancel_at: null,
+ cancel_at_period_end: false,
+ canceled_at: null,
+ cancellation_details: {
+ comment: null,
+ feedback: null,
+ reason: null,
+ },
+ collection_method: "charge_automatically",
+ created: 1770445200,
+ currency: "usd",
+ customer: "cus_TkKmZZvysJ2wej",
+ customer_account: null,
+ days_until_due: null,
+ default_payment_method: null,
+ default_source: "card_1Smq7u2SrMQ2FneknjyOa7sq",
+ default_tax_rates: [],
+ description: null,
+ discounts: [],
+ ended_at: null,
+ invoice_settings: {
+ account_tax_ids: null,
+ issuer: {
+ type: "self",
+ },
+ },
+ items: {
+ object: "list",
+ data: [
+ {
+ id: "si_TkKnBKXFX76t0O",
+ object: "subscription_item",
billing_thresholds: null,
- cancel_at: null,
- cancel_at_period_end: false,
- canceled_at: null,
- cancellation_details: {
- comment: null,
- feedback: null,
- reason: null,
- },
- collection_method: "charge_automatically",
created: 1770445200,
- currency: "usd",
- customer: "cus_TkKmZZvysJ2wej",
- customer_account: null,
- days_until_due: null,
- default_payment_method: null,
- default_source: "card_1Smq7u2SrMQ2FneknjyOa7sq",
- default_tax_rates: [],
- description: null,
+ current_period_end: 1772864400,
+ current_period_start: 1770445200,
discounts: [],
- ended_at: null,
- invoice_settings: {
- account_tax_ids: null,
- issuer: {
- type: "self",
- },
- },
- items: {
- object: "list",
- data: [
- {
- id: "si_TkKnBKXFX76t0O",
- object: "subscription_item",
- billing_thresholds: null,
- created: 1770445200,
- current_period_end: 1772864400,
- current_period_start: 1770445200,
- discounts: [],
- metadata: {},
- plan: {
- id: "price_1SmfFG2SrMQ2FnekJuzwHMea",
- object: "plan",
- active: true,
- amount: 20000,
- amount_decimal: "20000",
- billing_scheme: "per_unit",
- created: 1767725082,
- currency: "usd",
- interval: "month",
- interval_count: 1,
- livemode: false,
- metadata: {},
- meter: null,
- nickname: null,
- product: "prod_Tk9LjWT1n0DgYm",
- tiers_mode: null,
- transform_usage: null,
- trial_period_days: null,
- usage_type: "licensed",
- },
- price: {
- id: "price_1SmfFG2SrMQ2FnekJuzwHMea",
- object: "price",
- active: true,
- billing_scheme: "per_unit",
- created: 1767725082,
- currency: "usd",
- custom_unit_amount: null,
- livemode: false,
- lookup_key: null,
- metadata: {},
- nickname: null,
- product: "prod_Tk9LjWT1n0DgYm",
- recurring: {
- interval: "month",
- interval_count: 1,
- meter: null,
- trial_period_days: null,
- usage_type: "licensed",
- },
- tax_behavior: "unspecified",
- tiers_mode: null,
- transform_quantity: null,
- type: "recurring",
- unit_amount: 20000,
- unit_amount_decimal: "20000",
- },
- quantity: 1,
- subscription: "sub_1Smq7x2SrMQ2Fnek8F1yf3ZD",
- tax_rates: [],
- },
- ],
- has_more: false,
- total_count: 1,
- url: "/v1/subscription_items?subscription=sub_1Smq7x2SrMQ2Fnek8F1yf3ZD",
- },
- latest_invoice: "in_1Smq7x2SrMQ2FnekSJesfPwE",
- livemode: false,
metadata: {},
- next_pending_invoice_item_invoice: null,
- on_behalf_of: null,
- pause_collection: null,
- payment_settings: {
- payment_method_options: null,
- payment_method_types: null,
- save_default_payment_method: "off",
- },
- pending_invoice_item_interval: null,
- pending_setup_intent: null,
- pending_update: null,
plan: {
id: "price_1SmfFG2SrMQ2FnekJuzwHMea",
object: "plan",
@@ -372,29 +302,101 @@ export async function POST(input: APIEvent) {
trial_period_days: null,
usage_type: "licensed",
},
- quantity: 1,
- schedule: null,
- start_date: 1770445200,
- status: "active",
- test_clock: "clock_1Smq6n2SrMQ2FnekQw4yt2PZ",
- transfer_data: null,
- trial_end: null,
- trial_settings: {
- end_behavior: {
- missing_payment_method: "create_invoice",
+ price: {
+ id: "price_1SmfFG2SrMQ2FnekJuzwHMea",
+ object: "price",
+ active: true,
+ billing_scheme: "per_unit",
+ created: 1767725082,
+ currency: "usd",
+ custom_unit_amount: null,
+ livemode: false,
+ lookup_key: null,
+ metadata: {},
+ nickname: null,
+ product: "prod_Tk9LjWT1n0DgYm",
+ recurring: {
+ interval: "month",
+ interval_count: 1,
+ meter: null,
+ trial_period_days: null,
+ usage_type: "licensed",
},
+ tax_behavior: "unspecified",
+ tiers_mode: null,
+ transform_quantity: null,
+ type: "recurring",
+ unit_amount: 20000,
+ unit_amount_decimal: "20000",
},
- trial_start: null,
+ quantity: 1,
+ subscription: "sub_1Smq7x2SrMQ2Fnek8F1yf3ZD",
+ tax_rates: [],
},
- },
+ ],
+ has_more: false,
+ total_count: 1,
+ url: "/v1/subscription_items?subscription=sub_1Smq7x2SrMQ2Fnek8F1yf3ZD",
+ },
+ latest_invoice: "in_1Smq7x2SrMQ2FnekSJesfPwE",
+ livemode: false,
+ metadata: {},
+ next_pending_invoice_item_invoice: null,
+ on_behalf_of: null,
+ pause_collection: null,
+ payment_settings: {
+ payment_method_options: null,
+ payment_method_types: null,
+ save_default_payment_method: "off",
+ },
+ pending_invoice_item_interval: null,
+ pending_setup_intent: null,
+ pending_update: null,
+ plan: {
+ id: "price_1SmfFG2SrMQ2FnekJuzwHMea",
+ object: "plan",
+ active: true,
+ amount: 20000,
+ amount_decimal: "20000",
+ billing_scheme: "per_unit",
+ created: 1767725082,
+ currency: "usd",
+ interval: "month",
+ interval_count: 1,
livemode: false,
- pending_webhooks: 0,
- request: {
- id: "req_6YO9stvB155WJD",
- idempotency_key: "581ba059-6f86-49b2-9c49-0d8450255322",
+ metadata: {},
+ meter: null,
+ nickname: null,
+ product: "prod_Tk9LjWT1n0DgYm",
+ tiers_mode: null,
+ transform_usage: null,
+ trial_period_days: null,
+ usage_type: "licensed",
+ },
+ quantity: 1,
+ schedule: null,
+ start_date: 1770445200,
+ status: "active",
+ test_clock: "clock_1Smq6n2SrMQ2FnekQw4yt2PZ",
+ transfer_data: null,
+ trial_end: null,
+ trial_settings: {
+ end_behavior: {
+ missing_payment_method: "create_invoice",
},
- type: "customer.subscription.created",
- }
+ },
+ trial_start: null,
+ },
+ },
+ livemode: false,
+ pending_webhooks: 0,
+ request: {
+ id: "req_6YO9stvB155WJD",
+ idempotency_key: "581ba059-6f86-49b2-9c49-0d8450255322",
+ },
+ type: "customer.subscription.created",
+}
+ */
}
if (body.type === "customer.subscription.deleted") {
const subscriptionID = body.data.object.id
@@ -419,7 +421,7 @@ export async function POST(input: APIEvent) {
})
}
if (body.type === "invoice.payment_succeeded") {
- if (body.data.object.billing_reason === "subscription_cycle") {
+ if (body.data.object.billing_reason === "subscription_cycle" || body.data.object.billing_reason === "subscription_create") {
const invoiceID = body.data.object.id as string
const amountInCents = body.data.object.amount_paid
const customerID = body.data.object.customer as string
diff --git a/packages/console/app/src/routes/workspace/[id]/billing/black-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/black-section.tsx
index 98d4debbb..03c75387a 100644
--- a/packages/console/app/src/routes/workspace/[id]/billing/black-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/billing/black-section.tsx
@@ -9,6 +9,7 @@ import { Black } from "@opencode-ai/console-core/black.js"
import { withActor } from "~/context/auth.withActor"
import { queryBillingInfo } from "../../common"
import styles from "./black-section.module.css"
+import waitlistStyles from "./black-waitlist-section.module.css"
const querySubscription = query(async (workspaceID: string) => {
"use server"
@@ -27,7 +28,7 @@ const querySubscription = query(async (workspaceID: string) => {
.where(and(eq(SubscriptionTable.workspaceID, Actor.workspace()), isNull(SubscriptionTable.timeDeleted)))
.then((r) => r[0]),
)
- if (!row.subscription) return null
+ if (!row?.subscription) return null
return {
plan: row.subscription.plan,
@@ -58,6 +59,37 @@ function formatResetTime(seconds: number) {
return `${minutes} ${minutes === 1 ? "minute" : "minutes"}`
}
+const cancelWaitlist = action(async (workspaceID: string) => {
+ "use server"
+ return json(
+ await withActor(async () => {
+ await Database.use((tx) =>
+ tx
+ .update(BillingTable)
+ .set({
+ subscriptionPlan: null,
+ timeSubscriptionBooked: null,
+ timeSubscriptionSelected: null,
+ })
+ .where(eq(BillingTable.workspaceID, workspaceID)),
+ )
+ return { error: undefined }
+ }, workspaceID).catch((e) => ({ error: e.message as string })),
+ { revalidate: [queryBillingInfo.key, querySubscription.key] },
+ )
+}, "cancelWaitlist")
+
+const enroll = action(async (workspaceID: string) => {
+ "use server"
+ return json(
+ await withActor(async () => {
+ await Billing.subscribe({ seats: 1 })
+ return { error: undefined }
+ }, workspaceID).catch((e) => ({ error: e.message as string })),
+ { revalidate: [queryBillingInfo.key, querySubscription.key] },
+ )
+}, "enroll")
+
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
"use server"
return json(
@@ -71,17 +103,24 @@ const createSessionUrl = action(async (workspaceID: string, returnUrl: string) =
})),
workspaceID,
),
- { revalidate: queryBillingInfo.key },
+ { revalidate: [queryBillingInfo.key, querySubscription.key] },
)
}, "sessionUrl")
export function BlackSection() {
const params = useParams()
+ const billing = createAsync(() => queryBillingInfo(params.id!))
+ const subscription = createAsync(() => querySubscription(params.id!))
const sessionAction = useAction(createSessionUrl)
const sessionSubmission = useSubmission(createSessionUrl)
- const subscription = createAsync(() => querySubscription(params.id!))
+ const cancelAction = useAction(cancelWaitlist)
+ const cancelSubmission = useSubmission(cancelWaitlist)
+ const enrollAction = useAction(enroll)
+ const enrollSubmission = useSubmission(enroll)
const [store, setStore] = createStore({
sessionRedirecting: false,
+ cancelled: false,
+ enrolled: false,
})
async function onClickSession() {
@@ -92,11 +131,25 @@ export function BlackSection() {
}
}
+ async function onClickCancel() {
+ const result = await cancelAction(params.id!)
+ if (!result.error) {
+ setStore("cancelled", true)
+ }
+ }
+
+ async function onClickEnroll() {
+ const result = await enrollAction(params.id!)
+ if (!result.error) {
+ setStore("enrolled", true)
+ }
+ }
+
return (
- <section class={styles.root}>
+ <>
<Show when={subscription()}>
{(sub) => (
- <>
+ <section class={styles.root}>
<div data-slot="section-title">
<h2>Subscription</h2>
<div data-slot="title-row">
@@ -132,9 +185,45 @@ export function BlackSection() {
<span data-slot="reset-time">Resets in {formatResetTime(sub().weeklyUsage.resetInSec)}</span>
</div>
</div>
- </>
+ </section>
)}
</Show>
- </section>
+ <Show when={billing()?.timeSubscriptionBooked}>
+ <section class={waitlistStyles.root}>
+ <div data-slot="section-title">
+ <h2>Waitlist</h2>
+ <div data-slot="title-row">
+ <p>
+ {billing()?.timeSubscriptionSelected
+ ? `We're ready to enroll you into the $${billing()?.subscriptionPlan} per month OpenCode Black plan.`
+ : `You are on the waitlist for the $${billing()?.subscriptionPlan} per month OpenCode Black plan.`}
+ </p>
+ <button
+ data-color="danger"
+ disabled={cancelSubmission.pending || store.cancelled}
+ onClick={onClickCancel}
+ >
+ {cancelSubmission.pending ? "Leaving..." : store.cancelled ? "Left" : "Leave Waitlist"}
+ </button>
+ </div>
+ </div>
+ <Show when={billing()?.timeSubscriptionSelected}>
+ <div data-slot="enroll-section">
+ <button
+ data-slot="enroll-button"
+ data-color="primary"
+ disabled={enrollSubmission.pending || store.enrolled}
+ onClick={onClickEnroll}
+ >
+ {enrollSubmission.pending ? "Enrolling..." : store.enrolled ? "Enrolled" : "Enroll"}
+ </button>
+ <p data-slot="enroll-note">
+ When you click Enroll, your subscription starts immediately and your card will be charged.
+ </p>
+ </div>
+ </Show>
+ </section>
+ </Show>
+ </>
)
}
diff --git a/packages/console/app/src/routes/workspace/[id]/billing/black-waitlist-section.module.css b/packages/console/app/src/routes/workspace/[id]/billing/black-waitlist-section.module.css
index c189f0d64..685d62c93 100644
--- a/packages/console/app/src/routes/workspace/[id]/billing/black-waitlist-section.module.css
+++ b/packages/console/app/src/routes/workspace/[id]/billing/black-waitlist-section.module.css
@@ -5,4 +5,19 @@
align-items: center;
gap: var(--space-4);
}
+
+ [data-slot="enroll-section"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+ }
+
+ [data-slot="enroll-button"] {
+ align-self: flex-start;
+ }
+
+ [data-slot="enroll-note"] {
+ font-size: var(--font-size-sm);
+ color: var(--color-text-muted);
+ }
}
diff --git a/packages/console/app/src/routes/workspace/[id]/billing/black-waitlist-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/black-waitlist-section.tsx
deleted file mode 100644
index 3ec9be395..000000000
--- a/packages/console/app/src/routes/workspace/[id]/billing/black-waitlist-section.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import { action, useParams, useAction, useSubmission, json, createAsync } from "@solidjs/router"
-import { createStore } from "solid-js/store"
-import { Database, eq } from "@opencode-ai/console-core/drizzle/index.js"
-import { BillingTable } from "@opencode-ai/console-core/schema/billing.sql.js"
-import { withActor } from "~/context/auth.withActor"
-import { queryBillingInfo } from "../../common"
-import styles from "./black-waitlist-section.module.css"
-
-const cancelWaitlist = action(async (workspaceID: string) => {
- "use server"
- return json(
- await withActor(async () => {
- await Database.use((tx) =>
- tx
- .update(BillingTable)
- .set({
- subscriptionPlan: null,
- timeSubscriptionBooked: null,
- })
- .where(eq(BillingTable.workspaceID, workspaceID)),
- )
- return { error: undefined }
- }, workspaceID).catch((e) => ({ error: e.message as string })),
- { revalidate: queryBillingInfo.key },
- )
-}, "cancelWaitlist")
-
-export function BlackWaitlistSection() {
- const params = useParams()
- const billingInfo = createAsync(() => queryBillingInfo(params.id!))
- const cancelAction = useAction(cancelWaitlist)
- const cancelSubmission = useSubmission(cancelWaitlist)
- const [store, setStore] = createStore({
- cancelled: false,
- })
-
- async function onClickCancel() {
- const result = await cancelAction(params.id!)
- if (!result.error) {
- setStore("cancelled", true)
- }
- }
-
- return (
- <section class={styles.root}>
- <div data-slot="section-title">
- <h2>Waitlist</h2>
- <div data-slot="title-row">
- <p>You are on the waitlist for the ${billingInfo()?.subscriptionPlan} per month OpenCode Black plan.</p>
- <button data-color="danger" disabled={cancelSubmission.pending || store.cancelled} onClick={onClickCancel}>
- {cancelSubmission.pending ? "Leaving..." : store.cancelled ? "Left" : "Leave Waitlist"}
- </button>
- </div>
- </div>
- </section>
- )
-}
diff --git a/packages/console/app/src/routes/workspace/[id]/billing/index.tsx b/packages/console/app/src/routes/workspace/[id]/billing/index.tsx
index 7fe4092be..a252a0234 100644
--- a/packages/console/app/src/routes/workspace/[id]/billing/index.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/billing/index.tsx
@@ -3,7 +3,6 @@ import { BillingSection } from "./billing-section"
import { ReloadSection } from "./reload-section"
import { PaymentSection } from "./payment-section"
import { BlackSection } from "./black-section"
-import { BlackWaitlistSection } from "./black-waitlist-section"
import { Show } from "solid-js"
import { createAsync, useParams } from "@solidjs/router"
import { queryBillingInfo, querySessionInfo } from "../../common"
@@ -17,12 +16,9 @@ export default function () {
<div data-page="workspace-[id]">
<div data-slot="sections">
<Show when={sessionInfo()?.isAdmin}>
- <Show when={billingInfo()?.subscriptionID}>
+ <Show when={billingInfo()?.subscriptionID || billingInfo()?.timeSubscriptionBooked}>
<BlackSection />
</Show>
- <Show when={billingInfo()?.timeSubscriptionBooked}>
- <BlackWaitlistSection />
- </Show>
<BillingSection />
<Show when={billingInfo()?.customerID}>
<ReloadSection />
diff --git a/packages/console/app/src/routes/workspace/[id]/index.tsx b/packages/console/app/src/routes/workspace/[id]/index.tsx
index e25e09645..fe308dbb0 100644
--- a/packages/console/app/src/routes/workspace/[id]/index.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/index.tsx
@@ -1,4 +1,4 @@
-import { Show, createMemo } from "solid-js"
+import { Match, Show, Switch, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { createAsync, useParams, useAction, useSubmission } from "@solidjs/router"
import { NewUserSection } from "./new-user-section"
@@ -43,9 +43,8 @@ export default function () {
</span>
<Show when={userInfo()?.isAdmin}>
<span data-slot="billing-info">
- <Show
- when={billingInfo()?.reload}
- fallback={
+ <Switch>
+ <Match when={!billingInfo()?.customerID}>
<button
data-color="primary"
data-size="sm"
@@ -54,12 +53,13 @@ export default function () {
>
{checkoutSubmission.pending || store.checkoutRedirecting ? "Loading..." : "Enable billing"}
</button>
- }
- >
- <span data-slot="balance">
- Current balance <b>${balance()}</b>
- </span>
- </Show>
+ </Match>
+ <Match when={!billingInfo()?.subscriptionID}>
+ <span data-slot="balance">
+ Current balance <b>${balance()}</b>
+ </span>
+ </Match>
+ </Switch>
</span>
</Show>
</p>
diff --git a/packages/console/app/src/routes/workspace/common.tsx b/packages/console/app/src/routes/workspace/common.tsx
index ddd3fd3e6..b5a90f749 100644
--- a/packages/console/app/src/routes/workspace/common.tsx
+++ b/packages/console/app/src/routes/workspace/common.tsx
@@ -113,6 +113,7 @@ export const queryBillingInfo = query(async (workspaceID: string) => {
subscriptionID: billing.subscriptionID,
subscriptionPlan: billing.subscriptionPlan,
timeSubscriptionBooked: billing.timeSubscriptionBooked,
+ timeSubscriptionSelected: billing.timeSubscriptionSelected,
}
}, 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 29a6bfb2e..783df8128 100644
--- a/packages/console/app/src/routes/zen/util/handler.ts
+++ b/packages/console/app/src/routes/zen/util/handler.ts
@@ -669,7 +669,7 @@ export async function handler(
...(authInfo.subscription
? (() => {
const plan = authInfo.billing.subscription!.plan
- const black = BlackData.get({ plan })
+ const black = BlackData.getLimits({ plan })
const week = getWeekBounds(new Date())
const rollingWindowSeconds = black.rollingWindow * 3600
return [