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 [

View File

@@ -0,0 +1 @@
ALTER TABLE `billing` ADD `time_subscription_selected` timestamp(3);

File diff suppressed because it is too large Load Diff

View File

@@ -386,6 +386,13 @@
"when": 1768603665356,
"tag": "0054_numerous_annihilus",
"breakpoints": true
},
{
"idx": 55,
"version": "5",
"when": 1769108945841,
"tag": "0055_moaning_karnak",
"breakpoints": true
}
]
}
}

View File

@@ -5,8 +5,11 @@ import { BillingTable, PaymentTable, SubscriptionTable } from "../src/schema/bil
import { Identifier } from "../src/identifier.js"
import { centsToMicroCents } from "../src/util/price.js"
import { AuthTable } from "../src/schema/auth.sql.js"
import { BlackData } from "../src/black.js"
import { Actor } from "../src/actor.js"
const plan = "200"
const couponID = "JAIr0Pe1"
const workspaceID = process.argv[2]
const seats = parseInt(process.argv[3])
@@ -61,16 +64,18 @@ const customerID =
.then((customer) => customer.id))())
console.log(`Customer ID: ${customerID}`)
const couponID = "JAIr0Pe1"
const subscription = await Billing.stripe().subscriptions.create({
customer: customerID!,
items: [
{
price: `price_1SmfyI2StuRr0lbXovxJNeZn`,
price: BlackData.planToPriceID({ plan }),
discounts: [{ coupon: couponID }],
quantity: seats,
},
],
metadata: {
workspaceID,
},
})
console.log(`Subscription ID: ${subscription.id}`)

View File

@@ -1,173 +0,0 @@
import { Billing } from "../src/billing.js"
import { and, Database, eq, isNull, sql } from "../src/drizzle/index.js"
import { UserTable } from "../src/schema/user.sql.js"
import { BillingTable, PaymentTable, SubscriptionTable } from "../src/schema/billing.sql.js"
import { Identifier } from "../src/identifier.js"
import { centsToMicroCents } from "../src/util/price.js"
import { AuthTable } from "../src/schema/auth.sql.js"
const workspaceID = process.argv[2]
const email = process.argv[3]
console.log(`Onboarding workspace ${workspaceID} for email ${email}`)
if (!workspaceID || !email) {
console.error("Usage: bun foo.ts <workspaceID> <email>")
process.exit(1)
}
// Look up the Stripe customer by email
const customers = await Billing.stripe().customers.list({ email, limit: 10, expand: ["data.subscriptions"] })
if (!customers.data) {
console.error(`Error: No Stripe customer found for email ${email}`)
process.exit(1)
}
const customer = customers.data.find((c) => c.subscriptions?.data[0]?.items.data[0]?.price.unit_amount === 20000)
if (!customer) {
console.error(`Error: No Stripe customer found for email ${email} with $200 subscription`)
process.exit(1)
}
const customerID = customer.id
const subscription = customer.subscriptions!.data[0]
const subscriptionID = subscription.id
// Validate the subscription is $200
const amountInCents = subscription.items.data[0]?.price.unit_amount ?? 0
if (amountInCents !== 20000) {
console.error(`Error: Subscription amount is $${amountInCents / 100}, expected $200`)
process.exit(1)
}
const subscriptionData = await Billing.stripe().subscriptions.retrieve(subscription.id, { expand: ["discounts"] })
const couponID =
typeof subscriptionData.discounts[0] === "string"
? subscriptionData.discounts[0]
: subscriptionData.discounts[0]?.coupon?.id
// Check if subscription is already tied to another workspace
const existingSubscription = await Database.use((tx) =>
tx
.select({ workspaceID: BillingTable.workspaceID })
.from(BillingTable)
.where(sql`JSON_EXTRACT(${BillingTable.subscription}, '$.id') = ${subscriptionID}`)
.then((rows) => rows[0]),
)
if (existingSubscription) {
console.error(
`Error: Subscription ${subscriptionID} is already tied to workspace ${existingSubscription.workspaceID}`,
)
process.exit(1)
}
// Look up the workspace billing and check if it already has a customer id or subscription
const billing = await Database.use((tx) =>
tx
.select({ customerID: BillingTable.customerID, subscriptionID: BillingTable.subscriptionID })
.from(BillingTable)
.where(eq(BillingTable.workspaceID, workspaceID))
.then((rows) => rows[0]),
)
if (billing?.subscriptionID) {
console.error(`Error: Workspace ${workspaceID} already has a subscription: ${billing.subscriptionID}`)
process.exit(1)
}
if (billing?.customerID) {
console.warn(
`Warning: Workspace ${workspaceID} already has a customer id: ${billing.customerID}, replacing with ${customerID}`,
)
}
// Get the latest invoice and payment from the subscription
const invoices = await Billing.stripe().invoices.list({
subscription: subscriptionID,
limit: 1,
expand: ["data.payments"],
})
const invoice = invoices.data[0]
const invoiceID = invoice?.id
const paymentID = invoice?.payments?.data[0]?.payment.payment_intent as string | undefined
// Get the default payment method from the customer
const paymentMethodID = (customer.invoice_settings.default_payment_method ?? subscription.default_payment_method) as
| string
| null
const paymentMethod = paymentMethodID ? await Billing.stripe().paymentMethods.retrieve(paymentMethodID) : null
const paymentMethodLast4 = paymentMethod?.card?.last4 ?? null
const paymentMethodType = paymentMethod?.type ?? null
// Look up the user in the workspace
const users = await Database.use((tx) =>
tx
.select({ id: UserTable.id, email: AuthTable.subject })
.from(UserTable)
.innerJoin(AuthTable, and(eq(AuthTable.accountID, UserTable.accountID), eq(AuthTable.provider, "email")))
.where(and(eq(UserTable.workspaceID, workspaceID), isNull(UserTable.timeDeleted))),
)
if (users.length === 0) {
console.error(`Error: No users found in workspace ${workspaceID}`)
process.exit(1)
}
const user = users.length === 1 ? users[0] : users.find((u) => u.email === email)
if (!user) {
console.error(`Error: User with email ${email} not found in workspace ${workspaceID}`)
process.exit(1)
}
// Set workspaceID in Stripe customer metadata
await Billing.stripe().customers.update(customerID, {
metadata: {
workspaceID,
},
})
await Database.transaction(async (tx) => {
// Set customer id, subscription id, and payment method on workspace billing
await tx
.update(BillingTable)
.set({
customerID,
subscriptionID,
paymentMethodID,
paymentMethodLast4,
paymentMethodType,
subscription: {
status: "subscribed",
coupon: couponID,
seats: 1,
plan: "200",
},
})
.where(eq(BillingTable.workspaceID, workspaceID))
// Create a row in subscription table
await tx.insert(SubscriptionTable).values({
workspaceID,
id: Identifier.create("subscription"),
userID: user.id,
})
// Create a row in payments table
await tx.insert(PaymentTable).values({
workspaceID,
id: Identifier.create("payment"),
amount: centsToMicroCents(amountInCents),
customerID,
invoiceID,
paymentID,
enrichment: {
type: "subscription",
couponID,
},
})
})
console.log(`Successfully onboarded workspace ${workspaceID}`)
console.log(` Customer ID: ${customerID}`)
console.log(` Subscription ID: ${subscriptionID}`)
console.log(
` Payment Method: ${paymentMethodID ?? "(none)"} (${paymentMethodType ?? "unknown"} ending in ${paymentMethodLast4 ?? "????"})`,
)
console.log(` User ID: ${user.id}`)
console.log(` Invoice ID: ${invoiceID ?? "(none)"}`)
console.log(` Payment ID: ${paymentID ?? "(none)"}`)

View File

@@ -244,7 +244,7 @@ function getSubscriptionStatus(row: {
return { weekly: null, rolling: null, rateLimited: null, retryIn: null }
}
const black = BlackData.get({ plan: row.subscription.plan })
const black = BlackData.getLimits({ plan: row.subscription.plan })
const now = new Date()
const week = getWeekBounds(now)

View File

@@ -1,6 +1,6 @@
import { Stripe } from "stripe"
import { Database, eq, sql } from "./drizzle"
import { BillingTable, PaymentTable, UsageTable } from "./schema/billing.sql"
import { BillingTable, PaymentTable, SubscriptionTable, UsageTable } from "./schema/billing.sql"
import { Actor } from "./actor"
import { fn } from "./util/fn"
import { z } from "zod"
@@ -8,6 +8,7 @@ import { Resource } from "@opencode-ai/console-resource"
import { Identifier } from "./identifier"
import { centsToMicroCents } from "./util/price"
import { User } from "./user"
import { BlackData } from "./black"
export namespace Billing {
export const ITEM_CREDIT_NAME = "opencode credits"
@@ -288,4 +289,66 @@ export namespace Billing {
return charge.receipt_url
},
)
export const subscribe = fn(z.object({
seats: z.number(),
coupon: z.string().optional(),
}), async ({ seats, coupon }) => {
const user = Actor.assert("user")
const billing = await Database.use((tx) =>
tx
.select({
customerID: BillingTable.customerID,
paymentMethodID: BillingTable.paymentMethodID,
subscriptionID: BillingTable.subscriptionID,
subscriptionPlan: BillingTable.subscriptionPlan,
timeSubscriptionSelected: BillingTable.timeSubscriptionSelected,
})
.from(BillingTable)
.where(eq(BillingTable.workspaceID, Actor.workspace()))
.then((rows) => rows[0]),
)
if (!billing) throw new Error("Billing record not found")
if (!billing.timeSubscriptionSelected) throw new Error("Not selected for subscription")
if (billing.subscriptionID) throw new Error("Already subscribed")
if (!billing.customerID) throw new Error("No customer ID")
if (!billing.paymentMethodID) throw new Error("No payment method")
if (!billing.subscriptionPlan) throw new Error("No subscription plan")
const subscription = await Billing.stripe().subscriptions.create({
customer: billing.customerID,
default_payment_method: billing.paymentMethodID,
items: [{ price: BlackData.planToPriceID({ plan: billing.subscriptionPlan }) }],
metadata: {
workspaceID: Actor.workspace(),
},
})
await Database.transaction(async (tx) => {
await tx
.update(BillingTable)
.set({
subscriptionID: subscription.id,
subscription: {
status: "subscribed",
coupon,
seats,
plan: billing.subscriptionPlan!,
},
subscriptionPlan: null,
timeSubscriptionBooked: null,
timeSubscriptionSelected: null,
})
.where(eq(BillingTable.workspaceID, Actor.workspace()))
await tx.insert(SubscriptionTable).values({
workspaceID: Actor.workspace(),
id: Identifier.create("subscription"),
userID: user.properties.userID,
})
})
return subscription.id
})
}

View File

@@ -28,15 +28,28 @@ export namespace BlackData {
return input
})
export const get = fn(
z.object({
export const getLimits = fn(z.object({
plan: z.enum(SubscriptionPlan),
}),
({ plan }) => {
const json = JSON.parse(Resource.ZEN_BLACK_LIMITS.value)
return Schema.parse(json)[plan]
},
)
}), ({ plan }) => {
const json = JSON.parse(Resource.ZEN_BLACK_LIMITS.value)
return Schema.parse(json)[plan]
})
export const planToPriceID = fn(z.object({
plan: z.enum(SubscriptionPlan),
}), ({ plan }) => {
if (plan === "200") return Resource.ZEN_BLACK_PRICE.plan200
if (plan === "100") return Resource.ZEN_BLACK_PRICE.plan100
return Resource.ZEN_BLACK_PRICE.plan20
})
export const priceIDToPlan = fn(z.object({
priceID: z.string(),
}), ({ priceID }) => {
if (priceID === Resource.ZEN_BLACK_PRICE.plan200) return "200"
if (priceID === Resource.ZEN_BLACK_PRICE.plan100) return "100"
return "20"
})
}
export namespace Black {
@@ -48,7 +61,7 @@ export namespace Black {
}),
({ plan, usage, timeUpdated }) => {
const now = new Date()
const black = BlackData.get({ plan })
const black = BlackData.getLimits({ plan })
const rollingWindowMs = black.rollingWindow * 3600 * 1000
const rollingLimitInMicroCents = centsToMicroCents(black.rollingLimit * 100)
const windowStart = new Date(now.getTime() - rollingWindowMs)
@@ -83,7 +96,7 @@ export namespace Black {
timeUpdated: z.date(),
}),
({ plan, usage, timeUpdated }) => {
const black = BlackData.get({ plan })
const black = BlackData.getLimits({ plan })
const now = new Date()
const week = getWeekBounds(now)
const fixedLimitInMicroCents = centsToMicroCents(black.fixedLimit * 100)

View File

@@ -31,6 +31,7 @@ export const BillingTable = mysqlTable(
subscriptionID: varchar("subscription_id", { length: 28 }),
subscriptionPlan: mysqlEnum("subscription_plan", SubscriptionPlan),
timeSubscriptionBooked: utc("time_subscription_booked"),
timeSubscriptionSelected: utc("time_subscription_selected"),
},
(table) => [
...workspaceIndexes(table),