diff --git a/packages/console/app/src/routes/workspace/[id]/billing/black-section.module.css b/packages/console/app/src/routes/workspace/[id]/billing/black-section.module.css index c189f0d64..0147afecc 100644 --- a/packages/console/app/src/routes/workspace/[id]/billing/black-section.module.css +++ b/packages/console/app/src/routes/workspace/[id]/billing/black-section.module.css @@ -5,4 +5,58 @@ align-items: center; gap: var(--space-4); } -} + + [data-slot="usage"] { + display: flex; + gap: var(--space-6); + margin-top: var(--space-4); + + @media (max-width: 40rem) { + flex-direction: column; + gap: var(--space-4); + } + } + + [data-slot="usage-item"] { + flex: 1; + display: flex; + flex-direction: column; + gap: var(--space-2); + } + + [data-slot="usage-header"] { + display: flex; + justify-content: space-between; + align-items: baseline; + } + + [data-slot="usage-label"] { + font-size: var(--font-size-md); + font-weight: 500; + color: var(--color-text); + } + + [data-slot="usage-value"] { + font-size: var(--font-size-sm); + color: var(--color-text-muted); + } + + [data-slot="progress"] { + height: 8px; + background-color: var(--color-bg-surface); + border-radius: var(--border-radius-sm); + overflow: hidden; + } + + [data-slot="progress-bar"] { + height: 100%; + background-color: var(--color-accent); + border-radius: var(--border-radius-sm); + transition: width 0.3s ease; + } + + [data-slot="reset-time"] { + font-size: var(--font-size-sm); + color: var(--color-text-muted); + } +} \ No newline at end of file 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 2eece1b62..beb5adbfc 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 @@ -1,10 +1,58 @@ -import { action, useParams, useAction, useSubmission, json } from "@solidjs/router" +import { action, useParams, useAction, useSubmission, json, query, createAsync } from "@solidjs/router" 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 { Actor } from "@opencode-ai/console-core/actor.js" +import { Black } from "@opencode-ai/console-core/black.js" import { withActor } from "~/context/auth.withActor" import { queryBillingInfo } from "../../common" import styles from "./black-section.module.css" +const querySubscription = query(async (workspaceID: string) => { + "use server" + return withActor(async () => { + const row = await Database.use((tx) => + tx + .select({ + rollingUsage: SubscriptionTable.rollingUsage, + fixedUsage: SubscriptionTable.fixedUsage, + timeRollingUpdated: SubscriptionTable.timeRollingUpdated, + timeFixedUpdated: SubscriptionTable.timeFixedUpdated, + }) + .from(SubscriptionTable) + .where(and(eq(SubscriptionTable.workspaceID, Actor.workspace()), isNull(SubscriptionTable.timeDeleted))) + .then((r) => r[0]), + ) + if (!row) return null + + return { + rollingUsage: Black.analyzeRollingUsage({ + usage: row.rollingUsage ?? 0, + timeUpdated: row.timeRollingUpdated ?? new Date(), + }), + weeklyUsage: Black.analyzeWeeklyUsage({ + usage: row.fixedUsage ?? 0, + timeUpdated: row.timeFixedUpdated ?? new Date(), + }), + } + }, workspaceID) +}, "subscription.get") + +function formatResetTime(seconds: number) { + const days = Math.floor(seconds / 86400) + if (days >= 1) { + const hours = Math.floor((seconds % 86400) / 3600) + return `${days} ${days === 1 ? "day" : "days"} ${hours} ${hours === 1 ? "hour" : "hours"}` + } + const hours = Math.floor(seconds / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + if (hours >= 1) return `${hours} ${hours === 1 ? "hour" : "hours"} ${minutes} ${minutes === 1 ? "minute" : "minutes"}` + if (minutes === 0) return "a few seconds" + return `${minutes} ${minutes === 1 ? "minute" : "minutes"}` +} + const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => { "use server" return json( @@ -26,6 +74,7 @@ export function BlackSection() { const params = useParams() const sessionAction = useAction(createSessionUrl) const sessionSubmission = useSubmission(createSessionUrl) + const subscription = createAsync(() => querySubscription(params.id!)) const [store, setStore] = createStore({ sessionRedirecting: false, }) @@ -53,6 +102,32 @@ export function BlackSection() { + + {(sub) => ( +
+
+
+ 5-hour Usage + {sub().rollingUsage.usagePercent}% +
+
+
+
+ Resets in {formatResetTime(sub().rollingUsage.resetInSec)} +
+
+
+ Weekly Usage + {sub().weeklyUsage.usagePercent}% +
+
+
+
+ Resets in {formatResetTime(sub().weeklyUsage.resetInSec)} +
+
+ )} + ) } diff --git a/packages/console/app/src/routes/workspace/common.tsx b/packages/console/app/src/routes/workspace/common.tsx index a6eaaeb1e..d97bf9e60 100644 --- a/packages/console/app/src/routes/workspace/common.tsx +++ b/packages/console/app/src/routes/workspace/common.tsx @@ -3,7 +3,6 @@ import { Actor } from "@opencode-ai/console-core/actor.js" import { action, json, query } from "@solidjs/router" import { withActor } from "~/context/auth.withActor" import { Billing } from "@opencode-ai/console-core/billing.js" -import { User } from "@opencode-ai/console-core/user.js" import { and, Database, desc, eq, isNull } from "@opencode-ai/console-core/drizzle/index.js" import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js" import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js" @@ -96,11 +95,22 @@ export const queryBillingInfo = query(async (workspaceID: string) => { return withActor(async () => { const billing = await Billing.get() return { - ...billing, + customerID: billing.customerID, + paymentMethodID: billing.paymentMethodID, + paymentMethodType: billing.paymentMethodType, + paymentMethodLast4: billing.paymentMethodLast4, + balance: billing.balance, + reload: billing.reload, reloadAmount: billing.reloadAmount ?? Billing.RELOAD_AMOUNT, reloadAmountMin: Billing.RELOAD_AMOUNT_MIN, reloadTrigger: billing.reloadTrigger ?? Billing.RELOAD_TRIGGER, reloadTriggerMin: Billing.RELOAD_TRIGGER_MIN, + monthlyLimit: billing.monthlyLimit, + monthlyUsage: billing.monthlyUsage, + timeMonthlyUsageUpdated: billing.timeMonthlyUsageUpdated, + reloadError: billing.reloadError, + timeReloadError: billing.timeReloadError, + subscriptionID: billing.subscriptionID, } }, 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 56efe451c..2ecc4220a 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -9,7 +9,7 @@ import { Billing } from "@opencode-ai/console-core/billing.js" import { Actor } from "@opencode-ai/console-core/actor.js" import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js" import { ZenData } from "@opencode-ai/console-core/model.js" -import { BlackData } from "@opencode-ai/console-core/black.js" +import { Black, BlackData } from "@opencode-ai/console-core/black.js" import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js" import { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js" import { ProviderTable } from "@opencode-ai/console-core/schema/provider.sql.js" @@ -495,27 +495,28 @@ export async function handler( // Check weekly limit if (sub.fixedUsage && sub.timeFixedUpdated) { - const week = getWeekBounds(now) - if (sub.timeFixedUpdated >= week.start && sub.fixedUsage >= centsToMicroCents(black.fixedLimit * 100)) { - const retryAfter = Math.ceil((week.end.getTime() - now.getTime()) / 1000) + const result = Black.analyzeWeeklyUsage({ + usage: sub.fixedUsage, + timeUpdated: sub.timeFixedUpdated, + }) + if (result.status === "rate-limited") throw new SubscriptionError( - `Subscription quota exceeded. Retry in ${formatRetryTime(retryAfter)}.`, - retryAfter, + `Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`, + result.resetInSec, ) - } } // Check rolling limit if (sub.rollingUsage && sub.timeRollingUpdated) { - const rollingWindowMs = black.rollingWindow * 3600 * 1000 - const windowStart = new Date(now.getTime() - rollingWindowMs) - if (sub.timeRollingUpdated >= windowStart && sub.rollingUsage >= centsToMicroCents(black.rollingLimit * 100)) { - const retryAfter = Math.ceil((sub.timeRollingUpdated.getTime() + rollingWindowMs - now.getTime()) / 1000) + const result = Black.analyzeRollingUsage({ + usage: sub.rollingUsage, + timeUpdated: sub.timeRollingUpdated, + }) + if (result.status === "rate-limited") throw new SubscriptionError( - `Subscription quota exceeded. Retry in ${formatRetryTime(retryAfter)}.`, - retryAfter, + `Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`, + result.resetInSec, ) - } } return diff --git a/packages/console/core/src/billing.ts b/packages/console/core/src/billing.ts index 181055c4e..f052e6fc6 100644 --- a/packages/console/core/src/billing.ts +++ b/packages/console/core/src/billing.ts @@ -25,22 +25,7 @@ export namespace Billing { export const get = async () => { return Database.use(async (tx) => tx - .select({ - customerID: BillingTable.customerID, - subscriptionID: BillingTable.subscriptionID, - paymentMethodID: BillingTable.paymentMethodID, - paymentMethodType: BillingTable.paymentMethodType, - paymentMethodLast4: BillingTable.paymentMethodLast4, - balance: BillingTable.balance, - reload: BillingTable.reload, - reloadAmount: BillingTable.reloadAmount, - reloadTrigger: BillingTable.reloadTrigger, - monthlyLimit: BillingTable.monthlyLimit, - monthlyUsage: BillingTable.monthlyUsage, - timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated, - reloadError: BillingTable.reloadError, - timeReloadError: BillingTable.timeReloadError, - }) + .select() .from(BillingTable) .where(eq(BillingTable.workspaceID, Actor.workspace())) .then((r) => r[0]), diff --git a/packages/console/core/src/black.ts b/packages/console/core/src/black.ts index 0ecbc94a4..753d25808 100644 --- a/packages/console/core/src/black.ts +++ b/packages/console/core/src/black.ts @@ -1,6 +1,8 @@ import { z } from "zod" import { fn } from "./util/fn" import { Resource } from "@opencode-ai/console-resource" +import { centsToMicroCents } from "./util/price" +import { getWeekBounds } from "./util/date" export namespace BlackData { const Schema = z.object({ @@ -18,3 +20,73 @@ export namespace BlackData { return Schema.parse(json) }) } + +export namespace Black { + export const analyzeRollingUsage = fn( + z.object({ + usage: z.number().int(), + timeUpdated: z.date(), + }), + ({ usage, timeUpdated }) => { + const now = new Date() + const black = BlackData.get() + const rollingWindowMs = black.rollingWindow * 3600 * 1000 + const rollingLimitInMicroCents = centsToMicroCents(black.rollingLimit * 100) + const windowStart = new Date(now.getTime() - rollingWindowMs) + if (timeUpdated < windowStart) { + return { + status: "ok" as const, + resetInSec: black.rollingWindow * 3600, + usagePercent: 0, + } + } + + const windowEnd = new Date(timeUpdated.getTime() + rollingWindowMs) + if (usage < rollingLimitInMicroCents) { + return { + status: "ok" as const, + resetInSec: Math.ceil((windowEnd.getTime() - now.getTime()) / 1000), + usagePercent: Math.ceil(Math.min(100, (usage / rollingLimitInMicroCents) * 100)), + } + } + return { + status: "rate-limited" as const, + resetInSec: Math.ceil((windowEnd.getTime() - now.getTime()) / 1000), + usagePercent: 100, + } + }, + ) + + export const analyzeWeeklyUsage = fn( + z.object({ + usage: z.number().int(), + timeUpdated: z.date(), + }), + ({ usage, timeUpdated }) => { + const black = BlackData.get() + const now = new Date() + const week = getWeekBounds(now) + const fixedLimitInMicroCents = centsToMicroCents(black.fixedLimit * 100) + if (timeUpdated < week.start) { + return { + status: "ok" as const, + resetInSec: Math.ceil((week.end.getTime() - now.getTime()) / 1000), + usagePercent: 0, + } + } + if (usage < fixedLimitInMicroCents) { + return { + status: "ok" as const, + resetInSec: Math.ceil((week.end.getTime() - now.getTime()) / 1000), + usagePercent: Math.ceil(Math.min(100, (usage / fixedLimitInMicroCents) * 100)), + } + } + + return { + status: "rate-limited" as const, + resetInSec: Math.ceil((week.end.getTime() - now.getTime()) / 1000), + usagePercent: 100, + } + }, + ) +}