فهرست منبع

wip: zen black

Frank 2 ماه پیش
والد
کامیت
a890d51bbc

+ 15 - 4
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

+ 13 - 6
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 (
     <section class={styles.root}>
+      <Show when={subscription()}>
+        {(sub) => (
+          <>
       <div data-slot="section-title">
         <h2>Subscription</h2>
         <div data-slot="title-row">
-          <p>You are subscribed to OpenCode Black for $200 per month.</p>
+          <p>You are subscribed to OpenCode Black for ${sub().plan} per month.</p>
           <button
             data-color="primary"
             disabled={sessionSubmission.pending || store.sessionRedirecting}
@@ -102,8 +110,6 @@ export function BlackSection() {
           </button>
         </div>
       </div>
-      <Show when={subscription()}>
-        {(sub) => (
           <div data-slot="usage">
             <div data-slot="usage-item">
               <div data-slot="usage-header">
@@ -126,6 +132,7 @@ export function BlackSection() {
               <span data-slot="reset-time">Resets in {formatResetTime(sub().weeklyUsage.resetInSec)}</span>
             </div>
           </div>
+          </>
         )}
       </Show>
     </section>

+ 1 - 1
packages/console/app/src/routes/workspace/[id]/billing/index.tsx

@@ -16,7 +16,7 @@ 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>
           <BillingSection />

+ 2 - 0
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")

+ 7 - 4
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 [

+ 8 - 3
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)
 

+ 3 - 3
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}`

+ 4 - 4
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}`

+ 27 - 10
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)

+ 2 - 1
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) => [

+ 8 - 1
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

+ 8 - 1
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

+ 8 - 1
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

+ 8 - 1
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

+ 8 - 1
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

+ 8 - 1
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