From 164c4b7d45269bc135a9bc6a06e860272a4f67bf Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Fri, 7 Aug 2026 15:41:39 -0300 Subject: [PATCH] feat(admin): deployment-admin billing page with per-org task quota overrides Adds a Billing tab to the deployment-admin dashboard, shown only when the deployment enforces the task quota (GET /api/_admin/me now exposes taskQuotaEnforced for the gate). - GET /api/_admin/billing/orgs: per-org quota consumption, ordered by all-time live claims; the current bucket and effective limit are computed with the same taskQuotaState + LIVE_CLAIM_FILTER the enforcement path uses, so the view cannot disagree with what a dispatch would allow. - PATCH /api/_admin/billing/orgs/:orgId: sets/clears the per-org override columns from migration 164 (previously writer-less), via a new OrganizationBillingStorage.setQuotaOverrides. Validates positive int4, audits with previous values like the other privileged admin actions. - Web: admin billing page mirroring the orgs tab (search, usage, edit-quota dialog with blank-means-default), i18n en + pt-BR. - E2E: override round-trip, insert-branch self-heal, omitted-field semantics, trial and sub:/sub:pending buckets, released-claim refunds, validation 400s (incl. int4 overflow and JSON null body), and Billing-tab absence when the quota gate is off. --- apps/api/src/api/routes/admin.ts | 210 +++++++++++- apps/api/src/storage/organization-billing.ts | 79 ++++- apps/web/src/hooks/use-deployment-admin.ts | 27 +- apps/web/src/i18n/en/admin.ts | 31 ++ apps/web/src/i18n/pt-br/admin.ts | 32 ++ apps/web/src/lib/query-keys.ts | 4 + apps/web/src/router.tsx | 7 + apps/web/src/routes/admin/billing.tsx | 331 +++++++++++++++++++ apps/web/src/routes/admin/layout.tsx | 15 +- packages/e2e/tests/deployment-admin.spec.ts | 206 ++++++++++++ 10 files changed, 925 insertions(+), 17 deletions(-) create mode 100644 apps/web/src/routes/admin/billing.tsx diff --git a/apps/api/src/api/routes/admin.ts b/apps/api/src/api/routes/admin.ts index 468a251956..b9b39c082d 100644 --- a/apps/api/src/api/routes/admin.ts +++ b/apps/api/src/api/routes/admin.ts @@ -20,9 +20,12 @@ */ import { Hono } from "hono"; import type { Context } from "hono"; +import { sql } from "kysely"; import { auth, getTrustedOrigins, grantDeploymentAdmin } from "@/auth"; import { isAlreadyMemberError } from "@/auth/is-already-member-error"; import { BUILTIN_ROLES, type BuiltinRole } from "@decocms/shared/auth/roles"; +import { taskQuotaState } from "@/billing/task-quota"; +import { LIVE_CLAIM_FILTER } from "@/storage/organization-billing"; import { getDb } from "@/database"; import { posthog } from "@/posthog"; import { getSettings } from "@/settings"; @@ -145,10 +148,15 @@ export function createAdminRoutes(): Hono { app.use("*", requireDeploymentAdmin); // The middleware IS the check — the UI gate just probes this. + // `taskQuotaEnforced` gates the Billing tab: on deployments without the + // quota gate (self-hosted) there is nothing to administer there. app.get("/me", (c) => { const email = c.get("studioContext").auth.user?.email; if (!email) return c.json({ error: "Unauthorized" }, 401); - return c.json({ email }); + return c.json({ + email, + taskQuotaEnforced: getSettings().taskQuotaEnforced, + }); }); app.get("/users", async (c) => { @@ -267,7 +275,7 @@ export function createAdminRoutes(): Hono { // ~10^5 member rows; past that, switch to limit-first + LATERAL count and // add an index on member(organizationId). const search = c.req.query("search")?.trim(); - const requested = Number(c.req.query("limit")); + const requested = Math.floor(Number(c.req.query("limit"))); const limit = Math.min(requested > 0 ? requested : 100, 100); const db = getDb().db; @@ -378,5 +386,203 @@ export function createAdminRoutes(): Hono { return c.json({ ok: true }); }); + /** + * Per-org task-quota consumption. Same clamp+search contract as /orgs, but + * ordered by all-time live claims so the orgs actually consuming quota rank + * first. The current bucket (trial vs subscription cycle) and its ceiling + * are computed with the SAME `taskQuotaState` the enforcement path uses, + * and the counts share `LIVE_CLAIM_FILTER` with it, so this view can never + * disagree with what a dispatch would be allowed. + * + * Scale ceiling is the same ~10^5 orgs as /orgs (the ordering subquery is + * an index probe per org, pre-LIMIT). Past that, /orgs's limit-first + + * LATERAL trick does NOT apply here — the ORDER BY *is* the count — so the + * fix is inverted: aggregate task_quota_claims first (it only has rows for + * quota-consuming orgs), join orgs back, pad with zero-claim orgs. + */ + app.get("/billing/orgs", async (c) => { + const search = c.req.query("search")?.trim(); + const requested = Math.floor(Number(c.req.query("limit"))); + const limit = Math.min(requested > 0 ? requested : 100, 100); + const db = getDb().db; + const settings = getSettings(); + + let query = db + .selectFrom("organization") + .leftJoin( + "organization_billing", + "organization_billing.organization_id", + "organization.id", + ) + .select([ + "organization.id as id", + "organization.name as name", + "organization.slug as slug", + "organization_billing.status as status", + "organization_billing.current_period_end as currentPeriodEnd", + "organization_billing.free_task_executions as freeTaskExecutions", + "organization_billing.monthly_task_executions as monthlyTaskExecutions", + ]) + .select((eb) => + eb + .selectFrom("task_quota_claims") + .select((eb2) => eb2.fn.countAll().as("count")) + .whereRef("task_quota_claims.organization_id", "=", "organization.id") + .where(...LIVE_CLAIM_FILTER) + .as("totalClaims"), + ); + + if (search) { + query = query.where((eb) => + eb.or([ + eb("organization.name", "ilike", `%${search}%`), + eb("organization.slug", "ilike", `%${search}%`), + ]), + ); + } + + const rows = await query + .orderBy(sql.ref("totalClaims"), "desc") + .orderBy("organization.createdAt", "desc") + .limit(limit) + .execute(); + + // Used-in-current-bucket, one grouped query for the page (no N+1); the + // right bucket per org is picked in JS because it depends on each org's + // subscription state. + const counts = await c + .get("studioContext") + .storage.organizationBilling.liveClaimCountsByPeriod( + rows.map((row) => row.id), + ); + const usedByOrgPeriod = new Map( + counts.map( + (row) => [`${row.organizationId} ${row.periodKey}`, row.count] as const, + ), + ); + + return c.json({ + defaults: { + freeTaskExecutions: settings.freeTaskExecutions, + monthlyTaskExecutions: settings.monthlyTaskExecutions, + }, + organizations: rows.map((row) => { + const billing = + row.status == null + ? null + : { + status: row.status, + currentPeriodEnd: row.currentPeriodEnd, + freeTaskExecutions: row.freeTaskExecutions, + monthlyTaskExecutions: row.monthlyTaskExecutions, + }; + const quota = taskQuotaState(billing, settings); + return { + id: row.id, + name: row.name, + slug: row.slug, + status: row.status ?? "none", + currentPeriodEnd: row.currentPeriodEnd, + periodKey: quota.periodKey, + used: usedByOrgPeriod.get(`${row.id} ${quota.periodKey}`) ?? 0, + limit: quota.limit, + freeTaskExecutions: row.freeTaskExecutions, + monthlyTaskExecutions: row.monthlyTaskExecutions, + totalClaims: Number(row.totalClaims ?? 0), + }; + }), + }); + }); + + /** + * Set/clear an org's quota overrides (migration 164's operator action — + * deliberately not an MCP tool so an org admin can never raise their own + * limit). null resets a knob to the deployment default; an omitted field is + * left untouched. Deliberately usable while the quota gate is off: the + * columns are dormant then, and an override set ahead of enabling the gate + * should stick. + */ + app.patch("/billing/orgs/:orgId", async (c) => { + const orgId = c.req.param("orgId"); + // `?? {}`: a body of JSON `null` parses fine and would explode on the + // property reads below. + const body = ((await c.req.json().catch(() => ({}))) ?? {}) as { + freeTaskExecutions?: unknown; + monthlyTaskExecutions?: unknown; + }; + // undefined = don't touch, null = reset to default, else a positive int4 + // (the DB rejects 0/negative/overflow too, but with a 500). + const parse = (value: unknown): number | null | undefined | "invalid" => + value === undefined + ? undefined + : value === null + ? null + : typeof value === "number" && + Number.isInteger(value) && + value > 0 && + value <= 2_147_483_647 + ? value + : "invalid"; + const free = parse(body.freeTaskExecutions); + const monthly = parse(body.monthlyTaskExecutions); + if ( + free === "invalid" || + monthly === "invalid" || + (free === undefined && monthly === undefined) + ) { + return c.json( + { + error: + "freeTaskExecutions / monthlyTaskExecutions must be a positive integer or null (null = deployment default)", + }, + 400, + ); + } + + const org = await getDb() + .db.selectFrom("organization") + .select("id") + .where("id", "=", orgId) + .executeTakeFirst(); + if (!org) return c.json({ error: "Organization not found" }, 404); + + // Previous values first, so the audit line can reconstruct a lowered + // quota without DB history. + const billing = c.get("studioContext").storage.organizationBilling; + const previous = await billing.getBilling(orgId); + await billing.setQuotaOverrides(orgId, { + freeTaskExecutions: free, + monthlyTaskExecutions: monthly, + }); + + // Changing what a tenant is entitled to is a privileged action — audit it + // like /impersonate and member-add, attributed to the REAL actor. + const { actorId: effectiveActorId, impersonatedBy } = + await getAuditActor(c); + const actorId = impersonatedBy ?? effectiveActorId; + auditAdminAction("quota_update", { + actor_user_id: actorId, + ...(impersonatedBy ? { impersonated_user_id: effectiveActorId } : {}), + organization_id: orgId, + free_task_executions: free, + monthly_task_executions: monthly, + previous_free_task_executions: previous?.freeTaskExecutions ?? null, + previous_monthly_task_executions: previous?.monthlyTaskExecutions ?? null, + }); + posthog.capture({ + distinctId: actorId ?? orgId, + event: "deployment_admin_quota_updated", + groups: { organization: orgId }, + properties: { + actor_user_id: actorId, + organization_id: orgId, + free_task_executions: free, + monthly_task_executions: monthly, + }, + }); + + return c.json({ ok: true }); + }); + return app; } diff --git a/apps/api/src/storage/organization-billing.ts b/apps/api/src/storage/organization-billing.ts index 153c9e717b..e1a370e74e 100644 --- a/apps/api/src/storage/organization-billing.ts +++ b/apps/api/src/storage/organization-billing.ts @@ -1,6 +1,7 @@ /** * Organization Billing Storage — Stripe customer/subscription binding, - * status, period end. Platform-written only (the webhook is the writer). + * status, period end. Platform-written only — the Stripe webhook, plus + * `setQuotaOverrides` (deployment-admin) for the per-org override columns. */ import type { Kysely, Selectable } from "kysely"; @@ -36,6 +37,15 @@ export interface OrganizationBillingRow { monthlyTaskExecutions: number | null; } +/** + * The ONE definition of a claim that counts against quota (released ones were + * refunded) — spread into every counting site (`liveClaimCount`, + * `liveClaimCountsByPeriod`, and the deployment-admin listing's ordering + * subquery) so a new claim state can never make the admin view drift from + * what enforcement allows. + */ +export const LIVE_CLAIM_FILTER = ["state", "<>", "released"] as const; + export class OrganizationBillingStorage { constructor(private db: Kysely) {} @@ -113,9 +123,9 @@ export class OrganizationBillingStorage { return row ? { runCount: row.run_count, state: row.state } : null; } - /** Claims charged against a period — the ONE definition of "counts" - * (released ones were refunded), shared by the read and the claim - * transaction so the two can never drift. */ + /** Claims charged against a period — `LIVE_CLAIM_FILTER` is the shared + * definition of "counts", used by this read and the claim transaction so + * the two can never drift. */ private static liveClaimCount( db: Kysely, organizationId: string, @@ -126,11 +136,70 @@ export class OrganizationBillingStorage { .select((eb) => eb.fn.countAll().as("count")) .where("organization_id", "=", organizationId) .where("period_key", "=", periodKey) - .where("state", "<>", "released") + .where(...LIVE_CLAIM_FILTER) .executeTakeFirst() .then((row) => Number(row?.count ?? 0)); } + /** Live-claim counts per (org, period) for a page of orgs — the batched + * variant of `liveClaimCount` for the deployment-admin billing listing + * (the current bucket per org is picked by the caller, since it depends + * on each org's subscription state). */ + async liveClaimCountsByPeriod( + organizationIds: string[], + ): Promise< + Array<{ organizationId: string; periodKey: string; count: number }> + > { + if (!organizationIds.length) return []; + const rows = await this.db + .selectFrom("task_quota_claims") + .select(["organization_id", "period_key"]) + .select((eb) => eb.fn.countAll().as("count")) + .where("organization_id", "in", organizationIds) + .where(...LIVE_CLAIM_FILTER) + .groupBy(["organization_id", "period_key"]) + .execute(); + return rows.map((row) => ({ + organizationId: row.organization_id, + periodKey: row.period_key, + count: Number(row.count), + })); + } + + /** + * Operator-set per-org quota overrides (migration 164) — deliberately not + * an MCP tool, so an org admin can never raise their own limit; the only + * caller is the deployment-admin surface. `undefined` leaves a knob + * untouched, `null` resets it to the deployment default. Upsert because + * orgs whose creation-time billing seed failed have no row yet (same + * self-heal `claimTaskUnderLimit` does). + */ + async setQuotaOverrides( + organizationId: string, + overrides: { + freeTaskExecutions?: number | null; + monthlyTaskExecutions?: number | null; + }, + ): Promise { + const patch = { + ...(overrides.freeTaskExecutions !== undefined && { + free_task_executions: overrides.freeTaskExecutions, + }), + ...(overrides.monthlyTaskExecutions !== undefined && { + monthly_task_executions: overrides.monthlyTaskExecutions, + }), + }; + await this.db + .insertInto("organization_billing") + .values({ organization_id: organizationId, ...patch }) + .onConflict((oc) => + oc + .column("organization_id") + .doUpdateSet({ ...patch, updated_at: new Date() }), + ) + .execute(); + } + async countTaskClaims( organizationId: string, periodKey: string, diff --git a/apps/web/src/hooks/use-deployment-admin.ts b/apps/web/src/hooks/use-deployment-admin.ts index a4bebeeb50..27432c212d 100644 --- a/apps/web/src/hooks/use-deployment-admin.ts +++ b/apps/web/src/hooks/use-deployment-admin.ts @@ -21,25 +21,40 @@ export function useDeploymentAdmin(): { * the operator can fix themselves, so the UI says so instead of the * generic "restricted" message. */ needsEmailVerification: boolean; + /** Whether this deployment enforces the task quota (billing) — gates the + * Billing tab; a self-hosted deployment has nothing to administer there. */ + billingEnabled: boolean; } { const { data, isLoading } = useQuery({ queryKey: KEYS.deploymentAdminMe(), - queryFn: async (): Promise<"admin" | "unverified" | "denied"> => { + queryFn: async (): Promise<{ + status: "admin" | "unverified" | "denied"; + billingEnabled: boolean; + }> => { const res = await fetch("/api/_admin/me", { credentials: "include" }); if (res.status >= 500) { throw new Error(`admin/me request failed: ${res.status}`); } - if (res.ok) return "admin"; - const body = (await res.json().catch(() => ({}))) as { error?: string }; - return body.error === "email_not_verified" ? "unverified" : "denied"; + const body = (await res.json().catch(() => ({}))) as { + error?: string; + taskQuotaEnforced?: boolean; + }; + if (res.ok) { + return { status: "admin", billingEnabled: !!body.taskQuotaEnforced }; + } + return { + status: body.error === "email_not_verified" ? "unverified" : "denied", + billingEnabled: false, + }; }, staleTime: Infinity, retry: 2, }); return { - isAdmin: data === "admin", + isAdmin: data?.status === "admin", loading: isLoading, - needsEmailVerification: data === "unverified", + needsEmailVerification: data?.status === "unverified", + billingEnabled: data?.billingEnabled ?? false, }; } diff --git a/apps/web/src/i18n/en/admin.ts b/apps/web/src/i18n/en/admin.ts index 20b280d486..94d6c65152 100644 --- a/apps/web/src/i18n/en/admin.ts +++ b/apps/web/src/i18n/en/admin.ts @@ -1,8 +1,39 @@ export const admin = { + "admin.billing.allTimeClaims": "Claimed (all time)", + "admin.billing.blankUsesDefault": + "Leave a field blank to use the deployment default.", + "admin.billing.cancel": "Cancel", + "admin.billing.default": "default ({value})", + "admin.billing.editQuota": "Edit quota", + "admin.billing.editQuotaFor": "Edit quota for {org}", + "admin.billing.failedLoad": "Failed to load billing data", + "admin.billing.failedLoadDescription": + "Something went wrong. Refresh to try again.", + "admin.billing.failedUpdateQuota": "Failed to update quota", + "admin.billing.freeQuota": "Free quota", + "admin.billing.freeQuotaLabel": "Free task executions", + "admin.billing.invalidQuota": "Quotas must be positive whole numbers", + "admin.billing.monthlyQuota": "Monthly quota", + "admin.billing.monthlyQuotaLabel": "Monthly task executions", + "admin.billing.noOrgsFound": "No organizations found", + "admin.billing.noOrgsMatchSearch": 'No organizations match "{search}"', + "admin.billing.noOrgsYet": "No organizations exist yet.", + "admin.billing.organization": "Organization", + "admin.billing.pendingCycle": "Cycle pending", + "admin.billing.plan": "Plan", + "admin.billing.planFree": "Free", + "admin.billing.quotaUpdated": "Quota updated for {org}", + "admin.billing.renews": "Renews {date}", + "admin.billing.save": "Save", + "admin.billing.saving": "Saving...", + "admin.billing.searchPlaceholder": "Search organizations by name or slug...", + "admin.billing.trial": "Trial", + "admin.billing.usage": "Usage", "admin.layout.adminDashboard": "Admin Dashboard", "admin.layout.adminDashboardArea": "the admin dashboard", "admin.layout.emailVerificationRequired": "Verify your email address to access the admin dashboard.", + "admin.layout.billingTab": "Billing", "admin.layout.goHome": "Go home", "admin.layout.organizationsTab": "Organizations", "admin.layout.restrictedToDashboard": diff --git a/apps/web/src/i18n/pt-br/admin.ts b/apps/web/src/i18n/pt-br/admin.ts index 93cc9cbd1b..e0f6a25a71 100644 --- a/apps/web/src/i18n/pt-br/admin.ts +++ b/apps/web/src/i18n/pt-br/admin.ts @@ -1,10 +1,42 @@ import type { admin as adminEn } from "../en/admin.ts"; export const admin = { + "admin.billing.allTimeClaims": "Consumido (total)", + "admin.billing.blankUsesDefault": + "Deixe um campo em branco para usar o padrão da implantação.", + "admin.billing.cancel": "Cancelar", + "admin.billing.default": "padrão ({value})", + "admin.billing.editQuota": "Editar cota", + "admin.billing.editQuotaFor": "Editar cota de {org}", + "admin.billing.failedLoad": "Falha ao carregar dados de cobrança", + "admin.billing.failedLoadDescription": + "Algo deu errado. Atualize para tentar novamente.", + "admin.billing.failedUpdateQuota": "Falha ao atualizar cota", + "admin.billing.freeQuota": "Cota grátis", + "admin.billing.freeQuotaLabel": "Execuções de tarefas grátis", + "admin.billing.invalidQuota": "Cotas devem ser números inteiros positivos", + "admin.billing.monthlyQuota": "Cota mensal", + "admin.billing.monthlyQuotaLabel": "Execuções de tarefas mensais", + "admin.billing.noOrgsFound": "Nenhuma organização encontrada", + "admin.billing.noOrgsMatchSearch": + 'Nenhuma organização corresponde a "{search}"', + "admin.billing.noOrgsYet": "Nenhuma organização existe ainda.", + "admin.billing.organization": "Organização", + "admin.billing.pendingCycle": "Ciclo pendente", + "admin.billing.plan": "Plano", + "admin.billing.planFree": "Grátis", + "admin.billing.quotaUpdated": "Cota atualizada para {org}", + "admin.billing.renews": "Renova em {date}", + "admin.billing.save": "Salvar", + "admin.billing.saving": "Salvando...", + "admin.billing.searchPlaceholder": "Procure organizações por nome ou slug...", + "admin.billing.trial": "Trial", + "admin.billing.usage": "Uso", "admin.layout.adminDashboard": "Painel de Administração", "admin.layout.adminDashboardArea": "o painel de administração", "admin.layout.emailVerificationRequired": "Verifique seu endereço de e-mail para acessar o painel de administração.", + "admin.layout.billingTab": "Cobrança", "admin.layout.goHome": "Voltar para início", "admin.layout.organizationsTab": "Organizações", "admin.layout.restrictedToDashboard": diff --git a/apps/web/src/lib/query-keys.ts b/apps/web/src/lib/query-keys.ts index 21164a8999..c76ff05ae6 100644 --- a/apps/web/src/lib/query-keys.ts +++ b/apps/web/src/lib/query-keys.ts @@ -555,6 +555,10 @@ export const KEYS = { ["deployment-admin", "orgs", search] as const, // Prefix key: invalidates every orgs query regardless of the search term. deploymentAdminOrgsList: () => ["deployment-admin", "orgs"] as const, + deploymentAdminBilling: (search: string) => + ["deployment-admin", "billing", search] as const, + // Prefix key: invalidates every billing query regardless of the search term. + deploymentAdminBillingList: () => ["deployment-admin", "billing"] as const, // Brand context (scoped by organization) brandContext: (organizationId: string) => diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 541b158b57..94ab6859cd 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -133,10 +133,17 @@ const adminOrgsRoute = createRoute({ component: lazyRouteComponent(() => import("./routes/admin/orgs.tsx")), }); +const adminBillingRoute = createRoute({ + getParentRoute: () => adminLayout, + path: "/billing", + component: lazyRouteComponent(() => import("./routes/admin/billing.tsx")), +}); + const adminLayoutWithChildren = adminLayout.addChildren([ adminIndexRoute, adminUsersRoute, adminOrgsRoute, + adminBillingRoute, ]); // ============================================ diff --git a/apps/web/src/routes/admin/billing.tsx b/apps/web/src/routes/admin/billing.tsx new file mode 100644 index 0000000000..48c94e2f50 --- /dev/null +++ b/apps/web/src/routes/admin/billing.tsx @@ -0,0 +1,331 @@ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { Page } from "@/components/page"; +import { CollectionTableWrapper } from "@/components/collections/collection-table-wrapper.tsx"; +import type { TableColumn } from "@/components/collections/collection-table.tsx"; +import { EmptyState } from "@/components/empty-state.tsx"; +import { SearchInput } from "@deco/ui/components/search-input.tsx"; +import { Button } from "@deco/ui/components/button.tsx"; +import { Input } from "@deco/ui/components/input.tsx"; +import { Label } from "@deco/ui/components/label.tsx"; +import { useDebouncedValue } from "@/hooks/use-debounced-value"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@deco/ui/components/dialog.tsx"; +import { adminFetch } from "@/lib/admin-fetch"; +import { formatDate } from "@/lib/format-time"; +import { KEYS } from "@/lib/query-keys"; +import { useT } from "@/i18n/use-t.ts"; + +interface BillingOrg { + id: string; + name: string; + slug: string; + status: string; + currentPeriodEnd: string | null; + periodKey: string; + used: number; + limit: number; + /** Per-org overrides; null = deployment default. */ + freeTaskExecutions: number | null; + monthlyTaskExecutions: number | null; + totalClaims: number; +} + +interface BillingDefaults { + freeTaskExecutions: number; + monthlyTaskExecutions: number; +} + +/** Empty string = deployment default (sent as null); else a positive int4 + * (the server enforces the same bounds). `undefined` = invalid input. */ +function parseQuotaInput(value: string): number | null | undefined { + const trimmed = value.trim(); + if (!trimmed) return null; + const n = Number(trimmed); + return Number.isInteger(n) && n > 0 && n <= 2_147_483_647 ? n : undefined; +} + +function EditQuotaDialog({ + org, + defaults, +}: { + org: BillingOrg; + defaults: BillingDefaults; +}) { + const t = useT(); + const [open, setOpen] = useState(false); + const [free, setFree] = useState(org.freeTaskExecutions?.toString() ?? ""); + const [monthly, setMonthly] = useState( + org.monthlyTaskExecutions?.toString() ?? "", + ); + const queryClient = useQueryClient(); + + const mutation = useMutation({ + mutationFn: () => { + const freeValue = parseQuotaInput(free); + const monthlyValue = parseQuotaInput(monthly); + if (freeValue === undefined || monthlyValue === undefined) { + return Promise.reject(new Error(t("admin.billing.invalidQuota"))); + } + return adminFetch(`/api/_admin/billing/orgs/${org.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + freeTaskExecutions: freeValue, + monthlyTaskExecutions: monthlyValue, + }), + }); + }, + onSuccess: () => { + toast.success(t("admin.billing.quotaUpdated", { org: org.name })); + queryClient.invalidateQueries({ + queryKey: KEYS.deploymentAdminBillingList(), + }); + setOpen(false); + }, + onError: (error) => { + toast.error( + error instanceof Error + ? error.message + : t("admin.billing.failedUpdateQuota"), + ); + }, + }); + + return ( + + + + + + + + {t("admin.billing.editQuotaFor", { org: org.name })} + + +
+
+ + setFree(e.target.value)} + placeholder={t("admin.billing.default", { + value: defaults.freeTaskExecutions, + })} + disabled={mutation.isPending} + /> +
+
+ + setMonthly(e.target.value)} + placeholder={t("admin.billing.default", { + value: defaults.monthlyTaskExecutions, + })} + disabled={mutation.isPending} + /> +
+

+ {t("admin.billing.blankUsesDefault")} +

+
+ + + + +
+
+ ); +} + +export default function AdminBillingPage() { + const t = useT(); + const [search, setSearch] = useState(""); + const debouncedSearch = useDebouncedValue(search.trim(), 300); + + const { data, isLoading, isError } = useQuery({ + queryKey: KEYS.deploymentAdminBilling(debouncedSearch), + queryFn: () => { + const params = new URLSearchParams({ limit: "100" }); + if (debouncedSearch) params.set("search", debouncedSearch); + return adminFetch<{ + defaults: BillingDefaults; + organizations: BillingOrg[]; + }>(`/api/_admin/billing/orgs?${params}`); + }, + }); + + const orgs = data?.organizations ?? []; + const defaults = data?.defaults ?? { + freeTaskExecutions: 0, + monthlyTaskExecutions: 0, + }; + + const quotaCell = (override: number | null, defaultValue: number) => + override != null ? ( + {override} + ) : ( + + {t("admin.billing.default", { value: defaultValue })} + + ); + + const columns: TableColumn[] = [ + { + id: "name", + header: t("admin.billing.organization"), + render: (org) => ( +
+
+ {org.name} +
+
+ {org.slug} +
+
+ ), + cellClassName: "flex-1 min-w-0", + }, + { + id: "plan", + header: t("admin.billing.plan"), + render: (org) => ( + + {org.status === "none" ? t("admin.billing.planFree") : org.status} + + ), + cellClassName: "w-24 shrink-0", + }, + { + id: "usage", + header: t("admin.billing.usage"), + render: (org) => ( +
+
+ {org.used} / {org.limit} +
+
+ {org.periodKey === "trial" + ? t("admin.billing.trial") + : org.currentPeriodEnd + ? t("admin.billing.renews", { + date: formatDate(org.currentPeriodEnd), + }) + : t("admin.billing.pendingCycle")} +
+
+ ), + cellClassName: "w-40 shrink-0", + }, + { + id: "totalClaims", + header: t("admin.billing.allTimeClaims"), + render: (org) => ( + {org.totalClaims} + ), + cellClassName: "w-32 shrink-0", + }, + { + id: "freeQuota", + header: t("admin.billing.freeQuota"), + render: (org) => + quotaCell(org.freeTaskExecutions, defaults.freeTaskExecutions), + cellClassName: "w-28 shrink-0", + }, + { + id: "monthlyQuota", + header: t("admin.billing.monthlyQuota"), + render: (org) => + quotaCell(org.monthlyTaskExecutions, defaults.monthlyTaskExecutions), + cellClassName: "w-28 shrink-0", + }, + { + id: "actions", + header: "", + render: (org) => ( + // Key remounts the dialog when fresh data lands, so its inputs re-seed + // from the row instead of keeping pre-save state. + + ), + cellClassName: "w-28 shrink-0", + }, + ]; + + return ( + + + +
+ + + ) : ( + + ) + } + /> +
+
+
+
+ ); +} diff --git a/apps/web/src/routes/admin/layout.tsx b/apps/web/src/routes/admin/layout.tsx index 6cc36315c9..cd91dada0e 100644 --- a/apps/web/src/routes/admin/layout.tsx +++ b/apps/web/src/routes/admin/layout.tsx @@ -12,13 +12,19 @@ const TABS = [ { to: "/_admin/orgs", labelKey: "admin.layout.organizationsTab" }, ] as const; -function AdminTabs() { +const BILLING_TAB = { + to: "/_admin/billing", + labelKey: "admin.layout.billingTab", +} as const; + +function AdminTabs({ showBilling }: { showBilling: boolean }) { const pathname = useRouterState({ select: (s) => s.location.pathname }); const t = useT(); + const tabs = showBilling ? [...TABS, BILLING_TAB] : TABS; return (