From 5cf9f517cfec3ef68d3e68a12a6a4b3163947f44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=91=E5=A2=A8=E6=B0=B4=E9=B1=BC?= Date: Sat, 5 Sep 2026 02:47:06 +0800 Subject: [PATCH] feat(console): add quota reset support action (#47313) --- .../routes/api/support/actions/reset-quota.ts | 24 +++++++ packages/console/core/src/quota.ts | 65 +++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 packages/console/app/src/routes/api/support/actions/reset-quota.ts create mode 100644 packages/console/core/src/quota.ts diff --git a/packages/console/app/src/routes/api/support/actions/reset-quota.ts b/packages/console/app/src/routes/api/support/actions/reset-quota.ts new file mode 100644 index 0000000000..6f5c2812cf --- /dev/null +++ b/packages/console/app/src/routes/api/support/actions/reset-quota.ts @@ -0,0 +1,24 @@ +import type { APIEvent } from "@solidjs/start/server" +import { Quota } from "@opencode-ai/console-core/quota.js" +import { safeEqual } from "@opencode-ai/console-core/util/crypto.js" +import { Resource } from "@opencode-ai/console-resource" +import z from "zod" + +const Body = z.object({ + workspaceID: z.string().startsWith("wrk_"), + plan: z.enum(["lite", "subscription"]), +}) + +export async function POST(event: APIEvent) { + if (!safeEqual(event.request.headers.get("authorization") ?? "", `Bearer ${Resource.SUPPORT_API_KEY.value}`)) { + return Response.json({ error: "Unauthorized" }, { status: 401 }) + } + + const body = Body.safeParse(await event.request.json().catch(() => undefined)) + if (!body.success) { + return Response.json({ error: "Invalid request", issues: body.error.issues }, { status: 400 }) + } + return Quota.reset(body.data) + .then((result) => Response.json({ success: true, message: "Usage counters reset to zero", result })) + .catch((error) => Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 400 })) +} diff --git a/packages/console/core/src/quota.ts b/packages/console/core/src/quota.ts new file mode 100644 index 0000000000..856cbaba6e --- /dev/null +++ b/packages/console/core/src/quota.ts @@ -0,0 +1,65 @@ +import { z } from "zod" +import { and, Database, eq, isNull } from "./drizzle" +import { LiteTable, SubscriptionTable } from "./schema/billing.sql" +import { Identifier } from "./identifier" +import { fn } from "./util/fn" + +export namespace Quota { + // Zero all usage counters of one plan for a workspace, using the same + // workspace-scoped addressing as unsubscribeLite/unsubscribeBlack and + // Billing.subtractLiteUsage. Timestamps are left untouched on purpose: a + // zeroed counter with a current-window stamp reads as empty and the next + // write accumulates on top of zero, so the active windows stay undisturbed. + export const reset = fn( + z.object({ + workspaceID: Identifier.schema("workspace"), + plan: z.enum(["lite", "subscription"]), + }), + async (input) => { + if (input.plan === "lite") { + return Database.transaction(async (db) => { + const where = and(eq(LiteTable.workspaceID, input.workspaceID), isNull(LiteTable.timeDeleted)) + const rows = await db + .select({ + rollingUsage: LiteTable.rollingUsage, + weeklyUsage: LiteTable.weeklyUsage, + monthlyUsage: LiteTable.monthlyUsage, + }) + .from(LiteTable) + .where(where) + if (rows.length === 0) throw new Error("No lite usage counters found for workspace") + + await db.update(LiteTable).set({ rollingUsage: 0, weeklyUsage: 0, monthlyUsage: 0 }).where(where) + return { + plan: "lite" as const, + before: { + rollingUsage: rows.reduce((sum, row) => sum + (row.rollingUsage ?? 0), 0), + weeklyUsage: rows.reduce((sum, row) => sum + (row.weeklyUsage ?? 0), 0), + monthlyUsage: rows.reduce((sum, row) => sum + (row.monthlyUsage ?? 0), 0), + }, + } + }) + } + if (input.plan === "subscription") { + return Database.transaction(async (db) => { + const where = and(eq(SubscriptionTable.workspaceID, input.workspaceID), isNull(SubscriptionTable.timeDeleted)) + const rows = await db + .select({ rollingUsage: SubscriptionTable.rollingUsage, fixedUsage: SubscriptionTable.fixedUsage }) + .from(SubscriptionTable) + .where(where) + if (rows.length === 0) throw new Error("No subscription usage counters found for workspace") + + await db.update(SubscriptionTable).set({ rollingUsage: 0, fixedUsage: 0 }).where(where) + return { + plan: "subscription" as const, + before: { + rollingUsage: rows.reduce((sum, row) => sum + (row.rollingUsage ?? 0), 0), + fixedUsage: rows.reduce((sum, row) => sum + (row.fixedUsage ?? 0), 0), + }, + } + }) + } + throw new Error(`Unknown plan: ${input.plan}`) + }, + ) +}