wip: zen black

This commit is contained in:
Frank
2026-01-22 16:59:32 -05:00
parent fdac21688c
commit 5f3ab9395f
17 changed files with 1697 additions and 418 deletions

View File

@@ -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

View File

@@ -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>
</>
)
}

View File

@@ -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);
}
}

View File

@@ -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>
)
}

View File

@@ -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 />

View File

@@ -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>

View File

@@ -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")

View File

@@ -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 [