From a890d51bbc6a5608ad5992c74ee49153775aceb3 Mon Sep 17 00:00:00 2001 From: Frank Date: Thu, 22 Jan 2026 12:40:42 -0500 Subject: [PATCH] wip: zen black --- infra/console.ts | 19 ++++++++-- .../workspace/[id]/billing/black-section.tsx | 19 +++++++--- .../routes/workspace/[id]/billing/index.tsx | 2 +- .../app/src/routes/workspace/common.tsx | 2 + .../app/src/routes/zen/util/handler.ts | 11 ++++-- packages/console/core/script/lookup-user.ts | 11 ++++-- packages/console/core/script/promote-black.ts | 6 +-- packages/console/core/script/update-black.ts | 8 ++-- packages/console/core/src/black.ts | 37 ++++++++++++++----- .../console/core/src/schema/billing.sql.ts | 3 +- packages/console/core/sst-env.d.ts | 9 ++++- packages/console/function/sst-env.d.ts | 9 ++++- packages/console/resource/sst-env.d.ts | 9 ++++- packages/enterprise/sst-env.d.ts | 9 ++++- packages/function/sst-env.d.ts | 9 ++++- sst-env.d.ts | 9 ++++- 16 files changed, 130 insertions(+), 42 deletions(-) diff --git a/infra/console.ts b/infra/console.ts index 539b86f5d2..065166da92 100644 --- a/infra/console.ts +++ b/infra/console.ts @@ -101,15 +101,26 @@ export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint", const zenProduct = new stripe.Product("ZenBlack", { name: "OpenCode Black", }) -const zenPrice = new stripe.Price("ZenBlackPrice", { +const zenPriceProps = { product: zenProduct.id, - unitAmount: 20000, currency: "usd", recurring: { interval: "month", intervalCount: 1, }, +} +const zenPrice200 = new stripe.Price("ZenBlackPrice", { ...zenPriceProps, unitAmount: 20000, }) +const zenPrice100 = new stripe.Price("ZenBlack100Price", { ...zenPriceProps, unitAmount: 10000, }) +const zenPrice20 = new stripe.Price("ZenBlack20Price", { ...zenPriceProps, unitAmount: 2000, }) +const ZEN_BLACK_PRICE = new sst.Linkable("ZEN_BLACK_PRICE", { + properties: { + product: zenProduct.id, + plan200: zenPrice200.id, + plan100: zenPrice100.id, + plan20: zenPrice20.id, + }, }) +const ZEN_BLACK_LIMITS = new sst.Secret("ZEN_BLACK_LIMITS") const ZEN_MODELS = [ new sst.Secret("ZEN_MODELS1"), @@ -121,7 +132,6 @@ const ZEN_MODELS = [ new sst.Secret("ZEN_MODELS7"), new sst.Secret("ZEN_MODELS8"), ] -const ZEN_BLACK = new sst.Secret("ZEN_BLACK") const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY") const STRIPE_PUBLISHABLE_KEY = new sst.Secret("STRIPE_PUBLISHABLE_KEY") const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", { @@ -164,7 +174,8 @@ new sst.cloudflare.x.SolidStart("Console", { EMAILOCTOPUS_API_KEY, AWS_SES_ACCESS_KEY_ID, AWS_SES_SECRET_ACCESS_KEY, - ZEN_BLACK, + ZEN_BLACK_PRICE, + ZEN_BLACK_LIMITS, new sst.Secret("ZEN_SESSION_SECRET"), ...ZEN_MODELS, ...($dev 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 beb5adbfcc..448dafadca 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 @@ -3,7 +3,7 @@ import { createStore } from "solid-js/store" import { Show } from "solid-js" import { Billing } from "@opencode-ai/console-core/billing.js" import { Database, eq, and, isNull } from "@opencode-ai/console-core/drizzle/index.js" -import { SubscriptionTable } from "@opencode-ai/console-core/schema/billing.sql.js" +import { BillingTable, SubscriptionTable } from "@opencode-ai/console-core/schema/billing.sql.js" import { Actor } from "@opencode-ai/console-core/actor.js" import { Black } from "@opencode-ai/console-core/black.js" import { withActor } from "~/context/auth.withActor" @@ -20,19 +20,24 @@ const querySubscription = query(async (workspaceID: string) => { fixedUsage: SubscriptionTable.fixedUsage, timeRollingUpdated: SubscriptionTable.timeRollingUpdated, timeFixedUpdated: SubscriptionTable.timeFixedUpdated, + subscription: BillingTable.subscription, }) - .from(SubscriptionTable) + .from(BillingTable) + .innerJoin(SubscriptionTable, eq(SubscriptionTable.workspaceID, BillingTable.workspaceID)) .where(and(eq(SubscriptionTable.workspaceID, Actor.workspace()), isNull(SubscriptionTable.timeDeleted))) .then((r) => r[0]), ) - if (!row) return null + if (!row.subscription) return null return { + plan: row.subscription.plan, rollingUsage: Black.analyzeRollingUsage({ + plan: row.subscription.plan, usage: row.rollingUsage ?? 0, timeUpdated: row.timeRollingUpdated ?? new Date(), }), weeklyUsage: Black.analyzeWeeklyUsage({ + plan: row.subscription.plan, usage: row.fixedUsage ?? 0, timeUpdated: row.timeFixedUpdated ?? new Date(), }), @@ -89,10 +94,13 @@ export function BlackSection() { return (
+ + {(sub) => ( + <>

Subscription

-

You are subscribed to OpenCode Black for $200 per month.

+

You are subscribed to OpenCode Black for ${sub().plan} per month.

- - {(sub) => (
@@ -126,6 +132,7 @@ export function BlackSection() { Resets in {formatResetTime(sub().weeklyUsage.resetInSec)}
+ )}
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 9784e57abd..a252a02344 100644 --- a/packages/console/app/src/routes/workspace/[id]/billing/index.tsx +++ b/packages/console/app/src/routes/workspace/[id]/billing/index.tsx @@ -16,7 +16,7 @@ export default function () {
- + diff --git a/packages/console/app/src/routes/workspace/common.tsx b/packages/console/app/src/routes/workspace/common.tsx index d97bf9e604..ddd3fd3e63 100644 --- a/packages/console/app/src/routes/workspace/common.tsx +++ b/packages/console/app/src/routes/workspace/common.tsx @@ -111,6 +111,8 @@ export const queryBillingInfo = query(async (workspaceID: string) => { reloadError: billing.reloadError, timeReloadError: billing.timeReloadError, subscriptionID: billing.subscriptionID, + subscriptionPlan: billing.subscriptionPlan, + timeSubscriptionBooked: billing.timeSubscriptionBooked, } }, 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 0e848886fe..29a6bfb2ec 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -417,6 +417,7 @@ export async function handler( timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated, reloadTrigger: BillingTable.reloadTrigger, timeReloadLockedTill: BillingTable.timeReloadLockedTill, + subscription: BillingTable.subscription, }, user: { id: UserTable.id, @@ -488,10 +489,9 @@ export async function handler( if (modelInfo.allowAnonymous) return // Validate subscription billing - if (authInfo.subscription) { - const black = BlackData.get() + if (authInfo.billing.subscription && authInfo.subscription) { const sub = authInfo.subscription - const now = new Date() + const plan = authInfo.billing.subscription.plan const formatRetryTime = (seconds: number) => { const days = Math.floor(seconds / 86400) @@ -505,6 +505,7 @@ export async function handler( // Check weekly limit if (sub.fixedUsage && sub.timeFixedUpdated) { const result = Black.analyzeWeeklyUsage({ + plan, usage: sub.fixedUsage, timeUpdated: sub.timeFixedUpdated, }) @@ -518,6 +519,7 @@ export async function handler( // Check rolling limit if (sub.rollingUsage && sub.timeRollingUpdated) { const result = Black.analyzeRollingUsage({ + plan, usage: sub.rollingUsage, timeUpdated: sub.timeRollingUpdated, }) @@ -666,7 +668,8 @@ export async function handler( .where(and(eq(KeyTable.workspaceID, authInfo.workspaceID), eq(KeyTable.id, authInfo.apiKeyId))), ...(authInfo.subscription ? (() => { - const black = BlackData.get() + const plan = authInfo.billing.subscription!.plan + const black = BlackData.get({ plan }) const week = getWeekBounds(new Date()) const rollingWindowSeconds = black.rollingWindow * 3600 return [ diff --git a/packages/console/core/script/lookup-user.ts b/packages/console/core/script/lookup-user.ts index 3dc5e7a968..355716d1d9 100644 --- a/packages/console/core/script/lookup-user.ts +++ b/packages/console/core/script/lookup-user.ts @@ -1,7 +1,7 @@ import { Database, and, eq, sql } from "../src/drizzle/index.js" import { AuthTable } from "../src/schema/auth.sql.js" import { UserTable } from "../src/schema/user.sql.js" -import { BillingTable, PaymentTable, SubscriptionTable, UsageTable } from "../src/schema/billing.sql.js" +import { BillingTable, PaymentTable, SubscriptionTable, SubscriptionPlan, UsageTable } from "../src/schema/billing.sql.js" import { WorkspaceTable } from "../src/schema/workspace.sql.js" import { BlackData } from "../src/black.js" import { centsToMicroCents } from "../src/util/price.js" @@ -86,8 +86,10 @@ async function printWorkspace(workspaceID: string) { timeFixedUpdated: SubscriptionTable.timeFixedUpdated, timeRollingUpdated: SubscriptionTable.timeRollingUpdated, timeSubscriptionCreated: SubscriptionTable.timeCreated, + subscription: BillingTable.subscription, }) .from(UserTable) + .innerJoin(BillingTable, eq(BillingTable.workspaceID, workspace.id)) .leftJoin(AuthTable, and(eq(UserTable.accountID, AuthTable.accountID), eq(AuthTable.provider, "email"))) .leftJoin(SubscriptionTable, eq(SubscriptionTable.userID, UserTable.id)) .where(eq(UserTable.workspaceID, workspace.id)) @@ -223,17 +225,20 @@ function formatRetryTime(seconds: number) { } function getSubscriptionStatus(row: { + subscription: { + plan: typeof SubscriptionPlan[number] + } | null timeSubscriptionCreated: Date | null fixedUsage: number | null rollingUsage: number | null timeFixedUpdated: Date | null timeRollingUpdated: Date | null }) { - if (!row.timeSubscriptionCreated) { + if (!row.timeSubscriptionCreated || !row.subscription) { return { weekly: null, rolling: null, rateLimited: null, retryIn: null } } - const black = BlackData.get() + const black = BlackData.get({ plan: row.subscription.plan }) const now = new Date() const week = getWeekBounds(now) diff --git a/packages/console/core/script/promote-black.ts b/packages/console/core/script/promote-black.ts index bb3dcc6f7b..4338d0e421 100755 --- a/packages/console/core/script/promote-black.ts +++ b/packages/console/core/script/promote-black.ts @@ -12,11 +12,11 @@ const root = path.resolve(process.cwd(), "..", "..", "..") // read the secret const ret = await $`bun sst secret list`.cwd(root).text() const lines = ret.split("\n") -const value = lines.find((line) => line.startsWith("ZEN_BLACK"))?.split("=")[1] -if (!value) throw new Error("ZEN_BLACK not found") +const value = lines.find((line) => line.startsWith("ZEN_BLACK_LIMITS"))?.split("=")[1] +if (!value) throw new Error("ZEN_BLACK_LIMITS not found") // validate value BlackData.validate(JSON.parse(value)) // update the secret -await $`bun sst secret set ZEN_BLACK ${value} --stage ${stage}` +await $`bun sst secret set ZEN_BLACK_LIMITS ${value} --stage ${stage}` diff --git a/packages/console/core/script/update-black.ts b/packages/console/core/script/update-black.ts index 58923b457d..695a5d3ce4 100755 --- a/packages/console/core/script/update-black.ts +++ b/packages/console/core/script/update-black.ts @@ -8,10 +8,10 @@ import { BlackData } from "../src/black" const root = path.resolve(process.cwd(), "..", "..", "..") const secrets = await $`bun sst secret list`.cwd(root).text() -// read the line starting with "ZEN_BLACK" +// read value const lines = secrets.split("\n") -const oldValue = lines.find((line) => line.startsWith("ZEN_BLACK"))?.split("=")[1] -if (!oldValue) throw new Error("ZEN_BLACK not found") +const oldValue = lines.find((line) => line.startsWith("ZEN_BLACK_LIMITS"))?.split("=")[1] ?? "{}" +if (!oldValue) throw new Error("ZEN_BLACK_LIMITS not found") // store the prettified json to a temp file const filename = `black-${Date.now()}.json` @@ -25,4 +25,4 @@ const newValue = JSON.stringify(JSON.parse(await tempFile.text())) BlackData.validate(JSON.parse(newValue)) // update the secret -await $`bun sst secret set ZEN_BLACK ${newValue}` +await $`bun sst secret set ZEN_BLACK_LIMITS ${newValue}` diff --git a/packages/console/core/src/black.ts b/packages/console/core/src/black.ts index 753d25808f..85e16f1a1d 100644 --- a/packages/console/core/src/black.ts +++ b/packages/console/core/src/black.ts @@ -3,33 +3,49 @@ import { fn } from "./util/fn" import { Resource } from "@opencode-ai/console-resource" import { centsToMicroCents } from "./util/price" import { getWeekBounds } from "./util/date" +import { SubscriptionPlan } from "./schema/billing.sql" export namespace BlackData { const Schema = z.object({ - fixedLimit: z.number().int(), - rollingLimit: z.number().int(), - rollingWindow: z.number().int(), + "200": z.object({ + fixedLimit: z.number().int(), + rollingLimit: z.number().int(), + rollingWindow: z.number().int(), + }), + "100": z.object({ + fixedLimit: z.number().int(), + rollingLimit: z.number().int(), + rollingWindow: z.number().int(), + }), + "20": z.object({ + fixedLimit: z.number().int(), + rollingLimit: z.number().int(), + rollingWindow: z.number().int(), + }), }) export const validate = fn(Schema, (input) => { return input }) - export const get = fn(z.void(), () => { - const json = JSON.parse(Resource.ZEN_BLACK.value) - return Schema.parse(json) + export const get = fn(z.object({ + plan: z.enum(SubscriptionPlan), + }), ({ plan }) => { + const json = JSON.parse(Resource.ZEN_BLACK_LIMITS.value) + return Schema.parse(json)[plan] }) } export namespace Black { export const analyzeRollingUsage = fn( z.object({ + plan: z.enum(SubscriptionPlan), usage: z.number().int(), timeUpdated: z.date(), }), - ({ usage, timeUpdated }) => { + ({ plan, usage, timeUpdated }) => { const now = new Date() - const black = BlackData.get() + const black = BlackData.get({ plan }) const rollingWindowMs = black.rollingWindow * 3600 * 1000 const rollingLimitInMicroCents = centsToMicroCents(black.rollingLimit * 100) const windowStart = new Date(now.getTime() - rollingWindowMs) @@ -59,11 +75,12 @@ export namespace Black { export const analyzeWeeklyUsage = fn( z.object({ + plan:z.enum(SubscriptionPlan), usage: z.number().int(), timeUpdated: z.date(), }), - ({ usage, timeUpdated }) => { - const black = BlackData.get() + ({ plan, usage, timeUpdated }) => { + const black = BlackData.get({ plan }) const now = new Date() const week = getWeekBounds(now) const fixedLimitInMicroCents = centsToMicroCents(black.fixedLimit * 100) diff --git a/packages/console/core/src/schema/billing.sql.ts b/packages/console/core/src/schema/billing.sql.ts index 9f05919f24..ae32ed5cea 100644 --- a/packages/console/core/src/schema/billing.sql.ts +++ b/packages/console/core/src/schema/billing.sql.ts @@ -2,6 +2,7 @@ import { bigint, boolean, index, int, json, mysqlEnum, mysqlTable, uniqueIndex, import { timestamps, ulid, utc, workspaceColumns } from "../drizzle/types" import { workspaceIndexes } from "./workspace.sql" +export const SubscriptionPlan = ["20", "100", "200"] as const export const BillingTable = mysqlTable( "billing", { @@ -28,7 +29,7 @@ export const BillingTable = mysqlTable( plan: "20" | "100" | "200" }>(), subscriptionID: varchar("subscription_id", { length: 28 }), - subscriptionPlan: mysqlEnum("subscription_plan", ["20", "100", "200"] as const), + subscriptionPlan: mysqlEnum("subscription_plan", SubscriptionPlan), timeSubscriptionBooked: utc("time_subscription_booked"), }, (table) => [ diff --git a/packages/console/core/sst-env.d.ts b/packages/console/core/sst-env.d.ts index 66c06a824a..cb49028130 100644 --- a/packages/console/core/sst-env.d.ts +++ b/packages/console/core/sst-env.d.ts @@ -118,10 +118,17 @@ declare module "sst" { "type": "sst.cloudflare.StaticSite" "url": string } - "ZEN_BLACK": { + "ZEN_BLACK_LIMITS": { "type": "sst.sst.Secret" "value": string } + "ZEN_BLACK_PRICE": { + "plan100": string + "plan20": string + "plan200": string + "product": string + "type": "sst.sst.Linkable" + } "ZEN_MODELS1": { "type": "sst.sst.Secret" "value": string diff --git a/packages/console/function/sst-env.d.ts b/packages/console/function/sst-env.d.ts index 66c06a824a..cb49028130 100644 --- a/packages/console/function/sst-env.d.ts +++ b/packages/console/function/sst-env.d.ts @@ -118,10 +118,17 @@ declare module "sst" { "type": "sst.cloudflare.StaticSite" "url": string } - "ZEN_BLACK": { + "ZEN_BLACK_LIMITS": { "type": "sst.sst.Secret" "value": string } + "ZEN_BLACK_PRICE": { + "plan100": string + "plan20": string + "plan200": string + "product": string + "type": "sst.sst.Linkable" + } "ZEN_MODELS1": { "type": "sst.sst.Secret" "value": string diff --git a/packages/console/resource/sst-env.d.ts b/packages/console/resource/sst-env.d.ts index 66c06a824a..cb49028130 100644 --- a/packages/console/resource/sst-env.d.ts +++ b/packages/console/resource/sst-env.d.ts @@ -118,10 +118,17 @@ declare module "sst" { "type": "sst.cloudflare.StaticSite" "url": string } - "ZEN_BLACK": { + "ZEN_BLACK_LIMITS": { "type": "sst.sst.Secret" "value": string } + "ZEN_BLACK_PRICE": { + "plan100": string + "plan20": string + "plan200": string + "product": string + "type": "sst.sst.Linkable" + } "ZEN_MODELS1": { "type": "sst.sst.Secret" "value": string diff --git a/packages/enterprise/sst-env.d.ts b/packages/enterprise/sst-env.d.ts index 66c06a824a..cb49028130 100644 --- a/packages/enterprise/sst-env.d.ts +++ b/packages/enterprise/sst-env.d.ts @@ -118,10 +118,17 @@ declare module "sst" { "type": "sst.cloudflare.StaticSite" "url": string } - "ZEN_BLACK": { + "ZEN_BLACK_LIMITS": { "type": "sst.sst.Secret" "value": string } + "ZEN_BLACK_PRICE": { + "plan100": string + "plan20": string + "plan200": string + "product": string + "type": "sst.sst.Linkable" + } "ZEN_MODELS1": { "type": "sst.sst.Secret" "value": string diff --git a/packages/function/sst-env.d.ts b/packages/function/sst-env.d.ts index 66c06a824a..cb49028130 100644 --- a/packages/function/sst-env.d.ts +++ b/packages/function/sst-env.d.ts @@ -118,10 +118,17 @@ declare module "sst" { "type": "sst.cloudflare.StaticSite" "url": string } - "ZEN_BLACK": { + "ZEN_BLACK_LIMITS": { "type": "sst.sst.Secret" "value": string } + "ZEN_BLACK_PRICE": { + "plan100": string + "plan20": string + "plan200": string + "product": string + "type": "sst.sst.Linkable" + } "ZEN_MODELS1": { "type": "sst.sst.Secret" "value": string diff --git a/sst-env.d.ts b/sst-env.d.ts index b767ef1b30..0b4cbffe2e 100644 --- a/sst-env.d.ts +++ b/sst-env.d.ts @@ -144,10 +144,17 @@ declare module "sst" { "type": "sst.cloudflare.StaticSite" "url": string } - "ZEN_BLACK": { + "ZEN_BLACK_LIMITS": { "type": "sst.sst.Secret" "value": string } + "ZEN_BLACK_PRICE": { + "plan100": string + "plan20": string + "plan200": string + "product": string + "type": "sst.sst.Linkable" + } "ZEN_MODELS1": { "type": "sst.sst.Secret" "value": string