diff options
| author | Frank <[email protected]> | 2026-01-22 16:59:32 -0500 |
|---|---|---|
| committer | Frank <[email protected]> | 2026-01-22 17:02:46 -0500 |
| commit | 5f3ab9395fc8f8543724dcda914d38fba0809049 (patch) | |
| tree | 3be83aa5cc4e4556b62f9ff6b23933f8699eea63 /packages/console/app/src | |
| parent | fdac21688c9acc4087b83e71cb7e0fd1d2d57f00 (diff) | |
| download | opencode-5f3ab9395fc8f8543724dcda914d38fba0809049.tar.gz opencode-5f3ab9395fc8f8543724dcda914d38fba0809049.zip | |
wip: zen black
Diffstat (limited to 'packages/console/app/src')
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 [ |
