diff options
| author | Frank <[email protected]> | 2026-02-24 04:45:39 -0500 |
|---|---|---|
| committer | Frank <[email protected]> | 2026-02-24 04:45:41 -0500 |
| commit | fb6d201ee03d73967c554394742be360e2ff782d (patch) | |
| tree | 25cbda2e9ec598dfa5a5493f67cb432c5287d624 /packages/console/core/src | |
| parent | cda2af2589ddef9265ca2db379ecd4ab556f6be8 (diff) | |
| download | opencode-fb6d201ee03d73967c554394742be360e2ff782d.tar.gz opencode-fb6d201ee03d73967c554394742be360e2ff782d.zip | |
wip: zen lite
Diffstat (limited to 'packages/console/core/src')
| -rw-r--r-- | packages/console/core/src/billing.ts | 82 | ||||
| -rw-r--r-- | packages/console/core/src/black.ts | 8 | ||||
| -rw-r--r-- | packages/console/core/src/identifier.ts | 1 | ||||
| -rw-r--r-- | packages/console/core/src/lite.ts | 13 | ||||
| -rw-r--r-- | packages/console/core/src/schema/billing.sql.ts | 26 | ||||
| -rw-r--r-- | packages/console/core/src/subscription.ts | 40 | ||||
| -rw-r--r-- | packages/console/core/src/util/date.test.ts | 20 | ||||
| -rw-r--r-- | packages/console/core/src/util/date.ts | 29 |
8 files changed, 179 insertions, 40 deletions
diff --git a/packages/console/core/src/billing.ts b/packages/console/core/src/billing.ts index 2c1cdb068..fcf238a35 100644 --- a/packages/console/core/src/billing.ts +++ b/packages/console/core/src/billing.ts @@ -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)) + }) + }, + ) } diff --git a/packages/console/core/src/black.ts b/packages/console/core/src/black.ts index b4cc27064..a18c5258d 100644 --- a/packages/console/core/src/black.ts +++ b/packages/console/core/src/black.ts @@ -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 diff --git a/packages/console/core/src/identifier.ts b/packages/console/core/src/identifier.ts index b10bf32f6..8aa324ba0 100644 --- a/packages/console/core/src/identifier.ts +++ b/packages/console/core/src/identifier.ts @@ -8,6 +8,7 @@ export namespace Identifier { benchmark: "ben", billing: "bil", key: "key", + lite: "lit", model: "mod", payment: "pay", provider: "prv", diff --git a/packages/console/core/src/lite.ts b/packages/console/core/src/lite.ts index d6679208d..49d23e59e 100644 --- a/packages/console/core/src/lite.ts +++ b/packages/console/core/src/lite.ts @@ -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") } diff --git a/packages/console/core/src/schema/billing.sql.ts b/packages/console/core/src/schema/billing.sql.ts index 6d96fc7eb..a5c70c211 100644 --- a/packages/console/core/src/schema/billing.sql.ts +++ b/packages/console/core/src/schema/billing.sql.ts @@ -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", { diff --git a/packages/console/core/src/subscription.ts b/packages/console/core/src/subscription.ts index ca3b17042..879f940e0 100644 --- a/packages/console/core/src/subscription.ts +++ b/packages/console/core/src/subscription.ts @@ -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, + } + }, + ) } diff --git a/packages/console/core/src/util/date.test.ts b/packages/console/core/src/util/date.test.ts deleted file mode 100644 index 074df8a2f..000000000 --- a/packages/console/core/src/util/date.test.ts +++ /dev/null @@ -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) - }) -}) diff --git a/packages/console/core/src/util/date.ts b/packages/console/core/src/util/date.ts index 9c1ab12d2..dea9c390e 100644 --- a/packages/console/core/src/util/date.ts +++ b/packages/console/core/src/util/date.ts @@ -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 } +} |
