summaryrefslogtreecommitdiffhomepage
path: root/packages/console/core/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/console/core/src')
-rw-r--r--packages/console/core/src/billing.ts82
-rw-r--r--packages/console/core/src/black.ts8
-rw-r--r--packages/console/core/src/identifier.ts1
-rw-r--r--packages/console/core/src/lite.ts13
-rw-r--r--packages/console/core/src/schema/billing.sql.ts26
-rw-r--r--packages/console/core/src/subscription.ts40
-rw-r--r--packages/console/core/src/util/date.test.ts20
-rw-r--r--packages/console/core/src/util/date.ts29
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 }
+}