wip: zen lite

This commit is contained in:
Frank
2026-02-24 04:45:39 -05:00
parent cda2af2589
commit fb6d201ee0
41 changed files with 4127 additions and 432 deletions

View File

@@ -1,6 +1,6 @@
import { Stripe } from "stripe"
import { Database, eq, sql } from "./drizzle"
import { BillingTable, PaymentTable, SubscriptionTable, UsageTable } from "./schema/billing.sql"
import { BillingTable, LiteTable, PaymentTable, SubscriptionTable, UsageTable } from "./schema/billing.sql"
import { Actor } from "./actor"
import { fn } from "./util/fn"
import { z } from "zod"
@@ -9,6 +9,7 @@ import { Identifier } from "./identifier"
import { centsToMicroCents } from "./util/price"
import { User } from "./user"
import { BlackData } from "./black"
import { LiteData } from "./lite"
export namespace Billing {
export const ITEM_CREDIT_NAME = "opencode credits"
@@ -233,6 +234,56 @@ export namespace Billing {
},
)
export const generateLiteCheckoutUrl = fn(
z.object({
successUrl: z.string(),
cancelUrl: z.string(),
}),
async (input) => {
const user = Actor.assert("user")
const { successUrl, cancelUrl } = input
const email = await User.getAuthEmail(user.properties.userID)
const billing = await Billing.get()
if (billing.subscriptionID) throw new Error("Already subscribed to Black")
if (billing.liteSubscriptionID) throw new Error("Already subscribed to Lite")
const session = await Billing.stripe().checkout.sessions.create({
mode: "subscription",
billing_address_collection: "required",
line_items: [{ price: LiteData.priceID(), quantity: 1 }],
...(billing.customerID
? {
customer: billing.customerID,
customer_update: {
name: "auto",
address: "auto",
},
}
: {
customer_email: email!,
}),
currency: "usd",
payment_method_types: ["card"],
tax_id_collection: {
enabled: true,
},
success_url: successUrl,
cancel_url: cancelUrl,
subscription_data: {
metadata: {
workspaceID: Actor.workspace(),
userID: user.properties.userID,
type: "lite",
},
},
})
return session.url
},
)
export const generateSessionUrl = fn(
z.object({
returnUrl: z.string(),
@@ -271,7 +322,7 @@ export namespace Billing {
},
)
export const subscribe = fn(
export const subscribeBlack = fn(
z.object({
seats: z.number(),
coupon: z.string().optional(),
@@ -336,7 +387,7 @@ export namespace Billing {
},
)
export const unsubscribe = fn(
export const unsubscribeBlack = fn(
z.object({
subscriptionID: z.string(),
}),
@@ -360,4 +411,29 @@ export namespace Billing {
})
},
)
export const unsubscribeLite = fn(
z.object({
subscriptionID: z.string(),
}),
async ({ subscriptionID }) => {
const workspaceID = await Database.use((tx) =>
tx
.select({ workspaceID: BillingTable.workspaceID })
.from(BillingTable)
.where(eq(BillingTable.liteSubscriptionID, subscriptionID))
.then((rows) => rows[0]?.workspaceID),
)
if (!workspaceID) throw new Error("Workspace ID not found for subscription")
await Database.transaction(async (tx) => {
await tx
.update(BillingTable)
.set({ liteSubscriptionID: null, lite: null })
.where(eq(BillingTable.workspaceID, workspaceID))
await tx.delete(LiteTable).where(eq(LiteTable.workspaceID, workspaceID))
})
},
)
}

View File

@@ -1,7 +1,7 @@
import { z } from "zod"
import { fn } from "./util/fn"
import { Resource } from "@opencode-ai/console-resource"
import { SubscriptionPlan } from "./schema/billing.sql"
import { BlackPlans } from "./schema/billing.sql"
export namespace BlackData {
const Schema = z.object({
@@ -28,7 +28,7 @@ export namespace BlackData {
export const getLimits = fn(
z.object({
plan: z.enum(SubscriptionPlan),
plan: z.enum(BlackPlans),
}),
({ plan }) => {
const json = JSON.parse(Resource.ZEN_BLACK_LIMITS.value)
@@ -36,9 +36,11 @@ export namespace BlackData {
},
)
export const productID = fn(z.void(), () => Resource.ZEN_BLACK_PRICE.product)
export const planToPriceID = fn(
z.object({
plan: z.enum(SubscriptionPlan),
plan: z.enum(BlackPlans),
}),
({ plan }) => {
if (plan === "200") return Resource.ZEN_BLACK_PRICE.plan200

View File

@@ -8,6 +8,7 @@ export namespace Identifier {
benchmark: "ben",
billing: "bil",
key: "key",
lite: "lit",
model: "mod",
payment: "pay",
provider: "prv",

View File

@@ -4,9 +4,10 @@ import { Resource } from "@opencode-ai/console-resource"
export namespace LiteData {
const Schema = z.object({
fixedLimit: z.number().int(),
rollingLimit: z.number().int(),
rollingWindow: z.number().int(),
weeklyLimit: z.number().int(),
monthlyLimit: z.number().int(),
})
export const validate = fn(Schema, (input) => {
@@ -18,11 +19,7 @@ export namespace LiteData {
return Schema.parse(json)
})
export const planToPriceID = fn(z.void(), () => {
return Resource.ZEN_LITE_PRICE.price
})
export const priceIDToPlan = fn(z.void(), () => {
return "lite"
})
export const productID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.product)
export const priceID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.price)
export const planName = fn(z.void(), () => "lite")
}

View File

@@ -2,7 +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 BlackPlans = ["20", "100", "200"] as const
export const BillingTable = mysqlTable(
"billing",
{
@@ -25,14 +25,18 @@ export const BillingTable = mysqlTable(
subscription: json("subscription").$type<{
status: "subscribed"
seats: number
plan: "20" | "100" | "200"
plan: (typeof BlackPlans)[number]
useBalance?: boolean
coupon?: string
}>(),
subscriptionID: varchar("subscription_id", { length: 28 }),
subscriptionPlan: mysqlEnum("subscription_plan", SubscriptionPlan),
subscriptionPlan: mysqlEnum("subscription_plan", BlackPlans),
timeSubscriptionBooked: utc("time_subscription_booked"),
timeSubscriptionSelected: utc("time_subscription_selected"),
liteSubscriptionID: varchar("lite_subscription_id", { length: 28 }),
lite: json("lite").$type<{
useBalance?: boolean
}>(),
},
(table) => [
...workspaceIndexes(table),
@@ -55,6 +59,22 @@ export const SubscriptionTable = mysqlTable(
(table) => [...workspaceIndexes(table), uniqueIndex("workspace_user_id").on(table.workspaceID, table.userID)],
)
export const LiteTable = mysqlTable(
"lite",
{
...workspaceColumns,
...timestamps,
userID: ulid("user_id").notNull(),
rollingUsage: bigint("rolling_usage", { mode: "number" }),
weeklyUsage: bigint("weekly_usage", { mode: "number" }),
monthlyUsage: bigint("monthly_usage", { mode: "number" }),
timeRollingUpdated: utc("time_rolling_updated"),
timeWeeklyUpdated: utc("time_weekly_updated"),
timeMonthlyUpdated: utc("time_monthly_updated"),
},
(table) => [...workspaceIndexes(table), uniqueIndex("workspace_user_id").on(table.workspaceID, table.userID)],
)
export const PaymentTable = mysqlTable(
"payment",
{

View File

@@ -1,7 +1,7 @@
import { z } from "zod"
import { fn } from "./util/fn"
import { centsToMicroCents } from "./util/price"
import { getWeekBounds } from "./util/date"
import { getWeekBounds, getMonthlyBounds } from "./util/date"
export namespace Subscription {
export const analyzeRollingUsage = fn(
@@ -29,7 +29,7 @@ export namespace Subscription {
return {
status: "ok" as const,
resetInSec: Math.ceil((windowEnd.getTime() - now.getTime()) / 1000),
usagePercent: Math.ceil(Math.min(100, (usage / rollingLimitInMicroCents) * 100)),
usagePercent: Math.floor(Math.min(100, (usage / rollingLimitInMicroCents) * 100)),
}
}
return {
@@ -61,7 +61,7 @@ export namespace Subscription {
return {
status: "ok" as const,
resetInSec: Math.ceil((week.end.getTime() - now.getTime()) / 1000),
usagePercent: Math.ceil(Math.min(100, (usage / fixedLimitInMicroCents) * 100)),
usagePercent: Math.floor(Math.min(100, (usage / fixedLimitInMicroCents) * 100)),
}
}
@@ -72,4 +72,38 @@ export namespace Subscription {
}
},
)
export const analyzeMonthlyUsage = fn(
z.object({
limit: z.number().int(),
usage: z.number().int(),
timeUpdated: z.date(),
timeSubscribed: z.date(),
}),
({ limit, usage, timeUpdated, timeSubscribed }) => {
const now = new Date()
const month = getMonthlyBounds(now, timeSubscribed)
const fixedLimitInMicroCents = centsToMicroCents(limit * 100)
if (timeUpdated < month.start) {
return {
status: "ok" as const,
resetInSec: Math.ceil((month.end.getTime() - now.getTime()) / 1000),
usagePercent: 0,
}
}
if (usage < fixedLimitInMicroCents) {
return {
status: "ok" as const,
resetInSec: Math.ceil((month.end.getTime() - now.getTime()) / 1000),
usagePercent: Math.floor(Math.min(100, (usage / fixedLimitInMicroCents) * 100)),
}
}
return {
status: "rate-limited" as const,
resetInSec: Math.ceil((month.end.getTime() - now.getTime()) / 1000),
usagePercent: 100,
}
},
)
}

View File

@@ -1,20 +0,0 @@
import { describe, expect, test } from "bun:test"
import { getWeekBounds } from "./date"
describe("util.date.getWeekBounds", () => {
test("returns a Monday-based week for Sunday dates", () => {
const date = new Date("2026-01-18T12:00:00Z")
const bounds = getWeekBounds(date)
expect(bounds.start.toISOString()).toBe("2026-01-12T00:00:00.000Z")
expect(bounds.end.toISOString()).toBe("2026-01-19T00:00:00.000Z")
})
test("returns a seven day window", () => {
const date = new Date("2026-01-14T12:00:00Z")
const bounds = getWeekBounds(date)
const span = bounds.end.getTime() - bounds.start.getTime()
expect(span).toBe(7 * 24 * 60 * 60 * 1000)
})
})

View File

@@ -7,3 +7,32 @@ export function getWeekBounds(date: Date) {
end.setUTCDate(start.getUTCDate() + 7)
return { start, end }
}
export function getMonthlyBounds(now: Date, subscribed: Date) {
const day = subscribed.getUTCDate()
const hh = subscribed.getUTCHours()
const mm = subscribed.getUTCMinutes()
const ss = subscribed.getUTCSeconds()
const ms = subscribed.getUTCMilliseconds()
function anchor(year: number, month: number) {
const max = new Date(Date.UTC(year, month + 1, 0)).getUTCDate()
return new Date(Date.UTC(year, month, Math.min(day, max), hh, mm, ss, ms))
}
function shift(year: number, month: number, delta: number) {
const total = year * 12 + month + delta
return [Math.floor(total / 12), ((total % 12) + 12) % 12] as const
}
let y = now.getUTCFullYear()
let m = now.getUTCMonth()
let start = anchor(y, m)
if (start > now) {
;[y, m] = shift(y, m, -1)
start = anchor(y, m)
}
const [ny, nm] = shift(y, m, 1)
const end = anchor(ny, nm)
return { start, end }
}