From 80ba110e78f18e4e90cfa44ff2e6bbf344048df5 Mon Sep 17 00:00:00 2001 From: Armaan Jain <84474476+Developing-Gamer@users.noreply.github.com> Date: Tue, 23 Jun 2026 10:32:07 -0700 Subject: [PATCH] Usage page in settings (#1595) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds a Usage page under Project Settings showing the owner team's plan, billing period, and resource consumption (dashboard admins, auth users, emails, analytics events, session replays) with progress bars and an upgrade CTA. Backend aggregates usage across the team via `sumTenancyUsage` (parallelized with `Promise.all`) and serves it through `GET /internal/plan-usage`. Shared types in `@hexclave/shared` define the contract consumed by the SDK and dashboard. ## Screenshots ![Usage page — light mode](https://app.devin.ai/api/presigned_proxy?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJvcmdfaWQiOiJvcmdfaGwzT2d1STVWMXBYcTUwUCIsInVzZXJfaWQiOm51bGwsImJ1Y2tldF9uYW1lIjoiZGV2aW5hdHRhY2htZW50cyIsImJ1Y2tldF9rZXkiOiJhdHRhY2htZW50c19wcml2YXRlL29yZ19obDNPZ3VJNVYxcFhxNTBQLzE2ZTAyM2NkLTQzZjgtNDkyZS1hNDFkLTVmZjc1ZDg5NTQ3MSIsImlhdCI6MTc4MTU1MzY4OSwiZXhwIjoxNzgyMTU4NDg5LCJmaWxlbmFtZSI6InNjcmVlbnNob3RfZWE1YWU3YTJkNWQwNGM2NmFmYmM4NTY0YjQ2OTMxMDMucG5nIn0.O5H-gvyZ5an3wM7-CRcuyb6uFgg86cSftnAKnWh57VA) Link to Devin session: https://app.devin.ai/sessions/1bc3344126a442adb4f29ae373d346be Requested by: @Developing-Gamer ## Summary by CodeRabbit * **New Features** * Added Usage page in project settings displaying comprehensive plan usage metrics including dashboard seats, authentication users, emails sent, analytics events, and session replays * Shows current usage against plan limits with visual progress indicators * Displays upgrade recommendations when plan limits are exceeded with one-click upgrade functionality * Added Usage menu item to project settings navigation --------- Co-authored-by: Cursor Co-authored-by: armaan Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../api/latest/internal/plan-usage/route.tsx | 28 ++ apps/backend/src/lib/plan-usage.test.ts | 165 ++++++++ apps/backend/src/lib/plan-usage.ts | 370 +++++++++++++++++ .../usage/page-client.test.tsx | 163 ++++++++ .../project-settings/usage/page-client.tsx | 376 ++++++++++++++++++ .../project-settings/usage/page.tsx | 5 + .../projects/[projectId]/sidebar-layout.tsx | 7 +- .../shared/src/interface/admin-interface.ts | 14 + packages/shared/src/interface/plan-usage.ts | 34 ++ .../apps/implementations/admin-app-impl.ts | 38 ++ .../hexclave-app/apps/interfaces/admin-app.ts | 2 + .../template/src/lib/hexclave-app/index.ts | 8 + .../src/lib/hexclave-app/plan-usage/index.ts | 25 ++ 13 files changed, 1234 insertions(+), 1 deletion(-) create mode 100644 apps/backend/src/app/api/latest/internal/plan-usage/route.tsx create mode 100644 apps/backend/src/lib/plan-usage.test.ts create mode 100644 apps/backend/src/lib/plan-usage.ts create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-settings/usage/page-client.test.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-settings/usage/page-client.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-settings/usage/page.tsx create mode 100644 packages/shared/src/interface/plan-usage.ts create mode 100644 packages/template/src/lib/hexclave-app/plan-usage/index.ts diff --git a/apps/backend/src/app/api/latest/internal/plan-usage/route.tsx b/apps/backend/src/app/api/latest/internal/plan-usage/route.tsx new file mode 100644 index 000000000..e0e27db96 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/plan-usage/route.tsx @@ -0,0 +1,28 @@ +import { getPlanUsageForProject } from "@/lib/plan-usage"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { planUsageResponseSchema } from "@hexclave/shared/dist/interface/plan-usage"; +import { adaptSchema, adminAuthTypeSchema, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields"; + +export const GET = createSmartRouteHandler({ + metadata: { + hidden: true, + }, + request: yupObject({ + auth: yupObject({ + type: adminAuthTypeSchema.defined(), + tenancy: adaptSchema.defined(), + }), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: planUsageResponseSchema, + }), + handler: async (req) => { + return { + statusCode: 200, + bodyType: "json", + body: await getPlanUsageForProject(req.auth.tenancy.project), + }; + }, +}); diff --git a/apps/backend/src/lib/plan-usage.test.ts b/apps/backend/src/lib/plan-usage.test.ts new file mode 100644 index 000000000..8216e508d --- /dev/null +++ b/apps/backend/src/lib/plan-usage.test.ts @@ -0,0 +1,165 @@ +import { ITEM_IDS, UNLIMITED } from "@hexclave/shared/dist/plans"; +import type { SubscriptionRow } from "./payments/schema/types"; +import { buildUsageRow, getNextPlanId, getPlanUsagePeriod } from "./plan-usage"; +import { describe, expect, it } from "vitest"; + +function createSubscriptionPeriod(startMillis: number, endMillis: number): SubscriptionRow { + return { + id: "sub_1", + tenancyId: "tenancy_1", + customerId: "team_1", + customerType: "team", + productId: "team", + priceId: "monthly", + product: { + displayName: "Team", + customerType: "team", + prices: {}, + includedItems: {}, + }, + quantity: 1, + stripeSubscriptionId: null, + status: "active", + currentPeriodStartMillis: startMillis, + currentPeriodEndMillis: endMillis, + cancelAtPeriodEnd: false, + canceledAtMillis: null, + endedAtMillis: null, + refundedAtMillis: null, + productRevokedAtMillis: null, + creationSource: "TEST_MODE", + createdAtMillis: startMillis, + }; +} + +describe("buildUsageRow", () => { + it("calculates remaining usage under the limit", () => { + expect(buildUsageRow({ + itemId: ITEM_IDS.emailsPerMonth, + displayName: "Emails per month", + kind: "metered", + used: 25, + limit: 100, + })).toMatchInlineSnapshot(` + { + "display_name": "Emails per month", + "is_unlimited": false, + "item_id": "emails_per_month", + "kind": "metered", + "limit": 100, + "overage": 0, + "remaining": 75, + "used": 25, + } + `); + }); + + it("treats exact limit as no overage", () => { + expect(buildUsageRow({ + itemId: ITEM_IDS.analyticsEvents, + displayName: "Analytics events", + kind: "metered", + used: 100, + limit: 100, + })).toMatchInlineSnapshot(` + { + "display_name": "Analytics events", + "is_unlimited": false, + "item_id": "analytics_events", + "kind": "metered", + "limit": 100, + "overage": 0, + "remaining": 0, + "used": 100, + } + `); + }); + + it("calculates overage when usage exceeds the limit", () => { + expect(buildUsageRow({ + itemId: ITEM_IDS.sessionReplays, + displayName: "Session replays", + kind: "metered", + used: 125, + limit: 100, + })).toMatchInlineSnapshot(` + { + "display_name": "Session replays", + "is_unlimited": false, + "item_id": "session_replays", + "kind": "metered", + "limit": 100, + "overage": 25, + "remaining": 0, + "used": 125, + } + `); + }); + + it("represents unlimited auth users without remaining or overage", () => { + expect(buildUsageRow({ + itemId: ITEM_IDS.authUsers, + displayName: "Auth users", + kind: "current", + used: 250_000, + limit: UNLIMITED, + })).toMatchInlineSnapshot(` + { + "display_name": "Auth users", + "is_unlimited": true, + "item_id": "auth_users", + "kind": "current", + "limit": null, + "overage": 0, + "remaining": null, + "used": 250000, + } + `); + }); +}); + +describe("plan upgrade targets", () => { + it("selects the next paid tier", () => { + expect({ + free: getNextPlanId("free"), + team: getNextPlanId("team"), + growth: getNextPlanId("growth"), + }).toMatchInlineSnapshot(` + { + "free": "team", + "growth": null, + "team": "growth", + } + `); + }); +}); + +describe("billing period selection", () => { + it("uses the subscription period when available", () => { + const start = Date.UTC(2026, 4, 15); + const end = Date.UTC(2026, 5, 15); + const period = getPlanUsagePeriod(createSubscriptionPeriod(start, end), new Date(Date.UTC(2026, 5, 11))); + expect({ + start: period.start.toISOString(), + end: period.end.toISOString(), + }).toMatchInlineSnapshot(` + { + "end": "2026-06-15T00:00:00.000Z", + "start": "2026-05-15T00:00:00.000Z", + } + `); + }); + + it("falls back to the current calendar month", () => { + const period = getPlanUsagePeriod(null, new Date(Date.UTC(2026, 5, 11, 12))); + expect({ + start: period.start.toISOString(), + end: period.end.toISOString(), + }).toMatchInlineSnapshot(` + { + "end": "2026-07-01T00:00:00.000Z", + "start": "2026-06-01T00:00:00.000Z", + } + `); + }); +}); diff --git a/apps/backend/src/lib/plan-usage.ts b/apps/backend/src/lib/plan-usage.ts new file mode 100644 index 000000000..b53c01391 --- /dev/null +++ b/apps/backend/src/lib/plan-usage.ts @@ -0,0 +1,370 @@ +import { VerificationCodeType } from "@/generated/prisma/client"; +import { getClickhouseAdminClientForMetrics } from "@/lib/clickhouse"; +import { getSubscriptionMapForCustomer } from "@/lib/payments/customer-data"; +import { isActiveSubscription } from "@/lib/payments"; +import { + getBillingTeamId, + getOwnedProjectIdsForBillingTeam, + getOwnedTenancyIdsForBillingTeam, + getTeamWideNonAnonymousUserCount, +} from "@/lib/plan-entitlements"; +import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch, getTenancy, type Tenancy } from "@/lib/tenancies"; +import { getPrismaClientForTenancy, getPrismaSchemaForTenancy, globalPrismaClient, sqlQuoteIdent } from "@/prisma-client"; +import { BASE_PLAN_IDS_BY_TIER, ITEM_IDS, PLAN_LIMITS, UNLIMITED, type ItemId, type PlanId } from "@hexclave/shared/dist/plans"; +import type { PlanUsageResponse } from "@hexclave/shared/dist/interface/admin-interface"; +import { HexclaveAssertionError, throwErr } from "@hexclave/shared/dist/utils/errors"; +import type { SubscriptionRow } from "./payments/schema/types"; + +type PlanUsageKind = PlanUsageResponse["rows"][number]["kind"]; +type PlanUsageRow = PlanUsageResponse["rows"][number]; +type UsageLimit = number | null; + +type UsagePeriod = { + start: Date, + end: Date, +}; + +type UsageSourceProject = { + id: string, + ownerTeamId?: string | null, + owner_team_id?: string | null, +}; + +const USAGE_ITEM_LABELS = new Map([ + [ITEM_IDS.seats, "Dashboard admins"], + [ITEM_IDS.authUsers, "Auth users"], + [ITEM_IDS.emailsPerMonth, "Emails per month"], + [ITEM_IDS.analyticsEvents, "Analytics events"], + [ITEM_IDS.sessionReplays, "Session replays"], + [ITEM_IDS.analyticsTimeoutSeconds, "Analytics timeout"], + [ITEM_IDS.onboardingCall, "Onboarding call"], +]); + +const PLAN_LABELS = new Map([ + ["free", "Free"], + ["team", "Team"], + ["growth", "Growth"], +]); + +export function getNextPlanId(planId: PlanId): "team" | "growth" | null { + if (planId === "free") { + return "team"; + } + if (planId === "team") { + return "growth"; + } + return null; +} + +export function buildUsageRow(options: { + itemId: ItemId, + displayName: string, + kind: PlanUsageKind, + used: number | null, + limit: UsageLimit, +}): PlanUsageRow { + if (options.kind === "capability") { + return { + item_id: options.itemId, + display_name: options.displayName, + kind: options.kind, + used: null, + limit: options.limit, + remaining: null, + overage: null, + is_unlimited: options.limit != null && options.limit >= UNLIMITED, + }; + } + + const used = options.used ?? throwErr(`Used value is required for ${options.itemId}`); + const isUnlimited = options.limit != null && options.limit >= UNLIMITED; + const remaining = isUnlimited || options.limit == null ? null : Math.max(0, options.limit - used); + const overage = isUnlimited || options.limit == null ? 0 : Math.max(0, used - options.limit); + + return { + item_id: options.itemId, + display_name: options.displayName, + kind: options.kind, + used, + limit: isUnlimited ? null : options.limit, + remaining, + overage, + is_unlimited: isUnlimited, + }; +} + +export function getCurrentCalendarMonthPeriod(now: Date): UsagePeriod { + const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); + const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)); + return { start, end }; +} + +export function getPlanUsagePeriod(activeSubscription: SubscriptionRow | null, now: Date): UsagePeriod { + if (activeSubscription?.currentPeriodEndMillis != null) { + const end = new Date(activeSubscription.currentPeriodEndMillis); + if (Number.isFinite(activeSubscription.currentPeriodStartMillis)) { + return { + start: new Date(activeSubscription.currentPeriodStartMillis), + end, + }; + } + + const start = new Date(end); + start.setUTCMonth(start.getUTCMonth() - 1); + return { start, end }; + } + + return getCurrentCalendarMonthPeriod(now); +} + +function formatClickhouseDateTimeParam(date: Date): string { + return date.toISOString().slice(0, 19); +} + +function getPlanLabel(planId: PlanId): string { + return PLAN_LABELS.get(planId) ?? throwErr(`Missing plan label for ${planId}`); +} + +function getUsageItemLabel(itemId: ItemId): string { + return USAGE_ITEM_LABELS.get(itemId) ?? throwErr(`Missing usage item label for ${itemId}`); +} + +function resolveActivePlanSubscription(subscriptions: Record): SubscriptionRow | null { + const activeSubscriptions = Object.values(subscriptions).filter(isActiveSubscription); + for (const planId of BASE_PLAN_IDS_BY_TIER) { + const subscription = activeSubscriptions.find((candidate) => candidate.productId === planId); + if (subscription != null) { + return subscription; + } + } + return null; +} + +function resolveActivePlanId(subscription: SubscriptionRow | null): PlanId { + for (const planId of BASE_PLAN_IDS_BY_TIER) { + if (subscription?.productId === planId) { + return planId; + } + } + return "free"; +} + +async function getInternalBillingTenancy(): Promise { + const tenancy = await getSoleTenancyFromProjectBranch("internal", DEFAULT_BRANCH_ID, true); + if (tenancy == null) { + throw new HexclaveAssertionError("Internal billing tenancy not found", { + billingProjectId: "internal", + branchId: DEFAULT_BRANCH_ID, + }); + } + return tenancy; +} + +async function countDashboardAdmins(internalTenancy: Tenancy, ownerTeamId: string, now: Date): Promise { + const internalPrisma = await getPrismaClientForTenancy(internalTenancy); + const [acceptedMembers, pendingInvitations] = await Promise.all([ + internalPrisma.teamMember.count({ + where: { + tenancyId: internalTenancy.id, + teamId: ownerTeamId, + }, + }), + globalPrismaClient.verificationCode.count({ + where: { + projectId: internalTenancy.project.id, + branchId: internalTenancy.branchId, + type: VerificationCodeType.TEAM_INVITATION, + usedAt: null, + expiresAt: { gt: now }, + data: { + path: ["team_id"], + equals: ownerTeamId, + }, + }, + }), + ]); + return acceptedMembers + pendingInvitations; +} + +async function getOwnerTeamDisplayName(internalTenancy: Tenancy, ownerTeamId: string): Promise { + const internalPrisma = await getPrismaClientForTenancy(internalTenancy); + const team = await internalPrisma.team.findUnique({ + where: { + tenancyId_teamId: { + tenancyId: internalTenancy.id, + teamId: ownerTeamId, + }, + }, + select: { + displayName: true, + }, + }); + return team?.displayName ?? throwErr(`Owner team ${ownerTeamId} not found in the internal tenancy`); +} + +async function countEmailsForTenancy(tenancyId: string, period: UsagePeriod): Promise { + const tenancy = await getTenancy(tenancyId) ?? throwErr(`Tenancy ${tenancyId} not found while counting email usage`); + const schema = await getPrismaSchemaForTenancy(tenancy); + const prisma = await getPrismaClientForTenancy(tenancy); + const rows = await prisma.$replica().$queryRaw<[{ count: number }]>` + SELECT COUNT(*)::int AS count + FROM ${sqlQuoteIdent(schema)}."EmailOutbox" + WHERE "tenancyId" = ${tenancy.id}::uuid + AND "startedSendingAt" IS NOT NULL + AND "startedSendingAt" >= ${period.start} + AND "startedSendingAt" < ${period.end} + `; + return Number(rows[0].count); +} + +async function countSessionReplaysForTenancy(tenancyId: string, period: UsagePeriod): Promise { + const tenancy = await getTenancy(tenancyId) ?? throwErr(`Tenancy ${tenancyId} not found while counting session replay usage`); + const schema = await getPrismaSchemaForTenancy(tenancy); + const prisma = await getPrismaClientForTenancy(tenancy); + const rows = await prisma.$replica().$queryRaw<[{ count: number }]>` + SELECT COUNT(*)::int AS count + FROM ${sqlQuoteIdent(schema)}."SessionReplay" + WHERE "tenancyId" = ${tenancy.id}::uuid + AND "startedAt" >= ${period.start} + AND "startedAt" < ${period.end} + `; + return Number(rows[0].count); +} + +async function sumTenancyUsage(tenancyIds: string[], counter: (tenancyId: string) => Promise): Promise { + const counts = await Promise.all(tenancyIds.map(counter)); + return counts.reduce((sum, count) => sum + count, 0); +} + +async function countAnalyticsEventsForProjects(projectIds: string[], period: UsagePeriod): Promise { + if (projectIds.length === 0) { + return 0; + } + + const clickhouseClient = getClickhouseAdminClientForMetrics(); + const result = await clickhouseClient.query({ + query: ` + SELECT count() AS total + FROM analytics_internal.events + WHERE project_id IN {projectIds:Array(String)} + AND event_at >= {periodStart:DateTime} + AND event_at < {periodEnd:DateTime} + `, + query_params: { + projectIds, + periodStart: formatClickhouseDateTimeParam(period.start), + periodEnd: formatClickhouseDateTimeParam(period.end), + }, + format: "JSONEachRow", + }); + const rows: { total: string | number }[] = await result.json(); + return Number(rows[0]?.total ?? 0); +} + +function buildRows(options: { + planId: PlanId, + dashboardAdmins: number, + authUsers: number, + emails: number, + analyticsEvents: number, + sessionReplays: number, +}): PlanUsageRow[] { + const limits = PLAN_LIMITS[options.planId]; + return [ + buildUsageRow({ + itemId: ITEM_IDS.seats, + displayName: getUsageItemLabel(ITEM_IDS.seats), + kind: "current", + used: options.dashboardAdmins, + limit: limits.seats, + }), + buildUsageRow({ + itemId: ITEM_IDS.authUsers, + displayName: getUsageItemLabel(ITEM_IDS.authUsers), + kind: "current", + used: options.authUsers, + limit: limits.authUsers, + }), + buildUsageRow({ + itemId: ITEM_IDS.emailsPerMonth, + displayName: getUsageItemLabel(ITEM_IDS.emailsPerMonth), + kind: "metered", + used: options.emails, + limit: limits.emailsPerMonth, + }), + buildUsageRow({ + itemId: ITEM_IDS.analyticsEvents, + displayName: getUsageItemLabel(ITEM_IDS.analyticsEvents), + kind: "metered", + used: options.analyticsEvents, + limit: limits.analyticsEvents, + }), + buildUsageRow({ + itemId: ITEM_IDS.sessionReplays, + displayName: getUsageItemLabel(ITEM_IDS.sessionReplays), + kind: "metered", + used: options.sessionReplays, + limit: limits.sessionReplays, + }), + buildUsageRow({ + itemId: ITEM_IDS.analyticsTimeoutSeconds, + displayName: getUsageItemLabel(ITEM_IDS.analyticsTimeoutSeconds), + kind: "capability", + used: null, + limit: limits.analyticsTimeoutSeconds, + }), + ]; +} + +export async function getPlanUsageForProject(project: UsageSourceProject, now: Date = new Date()): Promise { + const ownerTeamId = getBillingTeamId(project); + if (ownerTeamId == null) { + throw new HexclaveAssertionError("Project does not have an owner team for plan usage", { + projectId: project.id, + }); + } + + const internalTenancy = await getInternalBillingTenancy(); + const internalPrisma = await getPrismaClientForTenancy(internalTenancy); + const subscriptions = await getSubscriptionMapForCustomer({ + prisma: internalPrisma, + tenancyId: internalTenancy.id, + customerType: "team", + customerId: ownerTeamId, + }); + const activePlanSubscription = resolveActivePlanSubscription(subscriptions); + const planId = resolveActivePlanId(activePlanSubscription); + const period = getPlanUsagePeriod(activePlanSubscription, now); + + const [ownerTeamDisplayName, ownedProjectIds, ownedTenancyIds, dashboardAdmins, authUsers] = await Promise.all([ + getOwnerTeamDisplayName(internalTenancy, ownerTeamId), + getOwnedProjectIdsForBillingTeam(ownerTeamId), + getOwnedTenancyIdsForBillingTeam(ownerTeamId), + countDashboardAdmins(internalTenancy, ownerTeamId, now), + getTeamWideNonAnonymousUserCount(ownerTeamId), + ]); + + const [emails, analyticsEvents, sessionReplays] = await Promise.all([ + sumTenancyUsage(ownedTenancyIds, async (tenancyId) => await countEmailsForTenancy(tenancyId, period)), + countAnalyticsEventsForProjects(ownedProjectIds, period), + sumTenancyUsage(ownedTenancyIds, async (tenancyId) => await countSessionReplaysForTenancy(tenancyId, period)), + ]); + + return { + owner_team_id: ownerTeamId, + owner_team_display_name: ownerTeamDisplayName, + plan_id: planId, + plan_display_name: activePlanSubscription?.product.displayName ?? getPlanLabel(planId), + period_start_millis: period.start.getTime(), + period_end_millis: period.end.getTime(), + next_plan_id: getNextPlanId(planId), + rows: buildRows({ + planId, + dashboardAdmins, + authUsers, + emails, + analyticsEvents, + sessionReplays, + }), + }; +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-settings/usage/page-client.test.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-settings/usage/page-client.test.tsx new file mode 100644 index 000000000..48a999652 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-settings/usage/page-client.test.tsx @@ -0,0 +1,163 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import PageClient from "./page-client"; + +const createCheckoutUrlMock = vi.fn(); + +function createPlanUsageState() { + return { + ownerTeamId: "00000000-0000-4000-8000-000000000001", + ownerTeamDisplayName: "Acme", + planId: "free", + planDisplayName: "Free", + periodStart: new Date(Date.UTC(2026, 5, 1)), + periodEnd: new Date(Date.UTC(2026, 6, 1)), + nextPlanId: "team", + rows: [ + { + itemId: "dashboard_admins", + displayName: "Dashboard admins", + kind: "current", + used: 1, + limit: 1, + remaining: 0, + overage: 0, + isUnlimited: false, + }, + { + itemId: "auth_users", + displayName: "Auth users", + kind: "current", + used: 45, + limit: null, + remaining: null, + overage: 0, + isUnlimited: true, + }, + { + itemId: "analytics_events", + displayName: "Analytics events", + kind: "metered", + used: 120, + limit: 100, + remaining: 0, + overage: 20, + isUnlimited: false, + }, + { + itemId: "session_replays", + displayName: "Session replays", + kind: "metered", + used: 24, + limit: 2500, + remaining: 2476, + overage: 0, + isUnlimited: false, + }, + { + itemId: "analytics_timeout_seconds", + displayName: "Analytics timeout", + kind: "capability", + used: null, + limit: 10, + remaining: null, + overage: null, + isUnlimited: false, + }, + ], + }; +} + +let planUsageState = createPlanUsageState(); + +vi.mock("../../use-admin-app", () => ({ + useAdminApp: () => ({ + useProject: () => ({ + id: "project-1", + }), + usePlanUsage: () => planUsageState, + }), +})); + +vi.mock("@/lib/dashboard-user", () => ({ + useDashboardInternalUser: () => ({ + useTeams: () => [ + { + id: planUsageState.ownerTeamId, + createCheckoutUrl: createCheckoutUrlMock, + }, + ], + }), +})); + +describe("Usage settings page", () => { + beforeEach(() => { + planUsageState = createPlanUsageState(); + createCheckoutUrlMock.mockReturnValue(new Promise(() => {})); + }); + + afterEach(() => { + cleanup(); + createCheckoutUrlMock.mockReset(); + }); + + it("renders the plan, usage rows, and overage state", () => { + render(); + + // The page title and usage card share this label. + expect(screen.getAllByText("Usage").length).toBeGreaterThan(0); + expect(screen.getAllByText("Free").length).toBeGreaterThan(0); + expect(screen.getAllByText("Owner").length).toBeGreaterThan(0); + expect(screen.getAllByText("Dashboard admins").length).toBeGreaterThan(0); + expect(screen.getAllByText("Authentication").length).toBeGreaterThan(0); + expect(screen.getAllByText("Auth users").length).toBeGreaterThan(0); + expect(screen.getByText("45 users · Unlimited")).toBeTruthy(); + expect(screen.getByText("100% · 20 over")).toBeTruthy(); + expect(screen.getAllByLabelText("Auth users usage").length).toBeGreaterThan(0); + expect(screen.getAllByText("Analytics").length).toBeGreaterThan(0); + expect(screen.getByText("Analytics events")).toBeTruthy(); + expect(screen.getByText("Session replays")).toBeTruthy(); + expect(screen.getAllByText("Analytics query timeout").length).toBeGreaterThan(0); + expect(screen.getAllByText("10s").length).toBeGreaterThan(0); + expect(screen.getAllByText("You exceeded your limits. Upgrade to the Team or Growth plan to get higher quotas.").length).toBeGreaterThan(0); + }); + + it("renders zero capped auth users with an empty progress bar", () => { + planUsageState = { + ...createPlanUsageState(), + rows: createPlanUsageState().rows.map((row) => row.itemId === "auth_users" ? { + ...row, + used: 0, + limit: 10000, + remaining: 10000, + overage: 0, + isUnlimited: false, + } : row), + }; + + render(); + + expect(screen.getByText("0% · 10,000 left")).toBeTruthy(); + const authUsageBar = screen.getAllByLabelText("Auth users usage")[0]; + const authUsageFill = authUsageBar.firstElementChild; + if (!(authUsageFill instanceof HTMLElement)) { + throw new Error("Expected Auth users progress fill element"); + } + expect(authUsageFill.style.width).toBe("0%"); + }); + + it("starts checkout for the next plan from the upgrade CTA", async () => { + render(); + + fireEvent.click(screen.getAllByRole("button", { name: /^upgrade$/i })[0]); + + await waitFor(() => { + expect(createCheckoutUrlMock).toHaveBeenCalledWith({ + productId: "team", + returnUrl: window.location.href, + }); + }); + }); +}); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-settings/usage/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-settings/usage/page-client.tsx new file mode 100644 index 000000000..d7780dbb5 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-settings/usage/page-client.tsx @@ -0,0 +1,376 @@ +"use client"; + +import { DesignButton } from "@/components/design-components"; +import { ALL_APPS_FRONTEND } from "@/lib/apps-frontend"; +import { useDashboardInternalUser } from "@/lib/dashboard-user"; +import { cn } from "@/lib/utils"; +import { throwErr } from "@hexclave/shared/dist/utils/errors"; +import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises"; +import { UsersIcon, WarningCircleIcon } from "@phosphor-icons/react"; +import { useMemo } from "react"; +import type { ComponentType, SVGProps } from "react"; +import { PageLayout } from "../../page-layout"; +import { useAdminApp } from "../../use-admin-app"; + +type UsageRow = { + itemId: string, + displayName: string, + kind: "current" | "metered" | "capability", + used: number | null, + limit: number | null, + remaining: number | null, + overage: number | null, + isUnlimited: boolean, +}; + +type PlanUsageData = { + planDisplayName: string, + ownerTeamDisplayName: string, + periodStart: Date, + periodEnd: Date, + rows: UsageRow[], +}; + +const AUTH_USERS_ITEM_ID = "auth_users"; +const DASHBOARD_ADMINS_ITEM_ID = "dashboard_admins"; +const EMAILS_PER_MONTH_ITEM_ID = "emails_per_month"; +const ANALYTICS_EVENTS_ITEM_ID = "analytics_events"; +const SESSION_REPLAYS_ITEM_ID = "session_replays"; +const ANALYTICS_TIMEOUT_SECONDS_ITEM_ID = "analytics_timeout_seconds"; + +type UsageSectionInfo = { + id: string, + title: string, + icon: ComponentType>, +}; + +type UsageSection = UsageSectionInfo & { + rows: UsageRow[], +}; + +const DEFAULT_USAGE_SECTION_INFO: UsageSectionInfo = { + id: "other", + title: "Other", + icon: UsersIcon, +}; + +const USAGE_SECTION_INFO_BY_ITEM_ID = new Map([ + [DASHBOARD_ADMINS_ITEM_ID, { + id: "dashboard", + title: "Dashboard admins", + icon: UsersIcon, + }], + [AUTH_USERS_ITEM_ID, { + id: "authentication", + title: "Authentication", + icon: ALL_APPS_FRONTEND.authentication.icon, + }], + [EMAILS_PER_MONTH_ITEM_ID, { + id: "emails", + title: "Emails", + icon: ALL_APPS_FRONTEND.emails.icon, + }], + [ANALYTICS_EVENTS_ITEM_ID, { + id: "analytics", + title: "Analytics", + icon: ALL_APPS_FRONTEND.analytics.icon, + }], + [SESSION_REPLAYS_ITEM_ID, { + id: "analytics", + title: "Analytics", + icon: ALL_APPS_FRONTEND.analytics.icon, + }], +]); + +function formatNumber(value: number): string { + return new Intl.NumberFormat().format(value); +} + +function formatDate(date: Date): string { + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }).format(date); +} + +function formatAnalyticsTimeout(row: UsageRow | undefined): string { + if (row == null || row.limit == null) { + return "Not included"; + } + return `${formatNumber(row.limit)}s`; +} + +function getUsagePercent(row: UsageRow): number | null { + if (row.used == null || row.limit == null || row.limit <= 0) { + return null; + } + return Math.min(100, (row.used / row.limit) * 100); +} + +function getUsageSummaryText(row: UsageRow, percent: number | null): string { + if (percent != null) { + return `${Math.round(percent)}% · ${getRemainingText(row)}`; + } + if (row.itemId === AUTH_USERS_ITEM_ID && row.used != null) { + const usedText = `${formatNumber(row.used)} ${row.used === 1 ? "user" : "users"}`; + return row.isUnlimited ? `${usedText} · Unlimited` : usedText; + } + return getRemainingText(row); +} + +function getRemainingText(row: UsageRow): string { + if (row.kind === "capability") { + return "Plan capability"; + } + if (row.isUnlimited) { + return "Unlimited"; + } + if ((row.overage ?? 0) > 0) { + return `${formatNumber(row.overage ?? 0)} over`; + } + return `${formatNumber(row.remaining ?? 0)} left`; +} + +function isOverLimit(row: UsageRow): boolean { + return (row.overage ?? 0) > 0; +} + +function progressBarColor(row: UsageRow, percent: number): string { + if (isOverLimit(row)) { + return "bg-red-500"; + } + if (percent >= 80) { + return "bg-amber-500"; + } + return "bg-emerald-500"; +} + +function getProgressWidth(row: UsageRow, percent: number | null): string | null { + if (percent != null) { + const minimumVisiblePercent = row.itemId === AUTH_USERS_ITEM_ID && row.isUnlimited ? 2 : 0; + return `${Math.max(minimumVisiblePercent, percent)}%`; + } + if (row.itemId === AUTH_USERS_ITEM_ID && row.used != null) { + return row.isUnlimited ? "1%" : "2%"; + } + return null; +} + +function shouldShowUsageRow(row: UsageRow): boolean { + return row.itemId !== ANALYTICS_TIMEOUT_SECONDS_ITEM_ID; +} + +function getOverageRows(rows: UsageRow[]): UsageRow[] { + return rows.filter((row) => (row.overage ?? 0) > 0); +} + +function getUsageSectionInfo(row: UsageRow): UsageSectionInfo { + return USAGE_SECTION_INFO_BY_ITEM_ID.get(row.itemId) ?? DEFAULT_USAGE_SECTION_INFO; +} + +function getUsageSections(rows: UsageRow[]): UsageSection[] { + const sections = new Map(); + for (const row of rows) { + const sectionInfo = getUsageSectionInfo(row); + const existingSection = sections.get(sectionInfo.id); + if (existingSection != null) { + existingSection.rows.push(row); + } else { + sections.set(sectionInfo.id, { ...sectionInfo, rows: [row] }); + } + } + const usageSections = [...sections.values()]; + const dashboardSection = usageSections.find((section) => section.id === "dashboard"); + if (dashboardSection == null) { + return usageSections; + } + return [dashboardSection, ...usageSections.filter((section) => section.id !== "dashboard")]; +} + +function UsageMetricLine({ row }: { row: UsageRow }) { + const percent = getUsagePercent(row); + const progressWidth = getProgressWidth(row, percent); + + return ( +
+
+ {row.displayName} + + {getUsageSummaryText(row, percent)} + +
+ {progressWidth != null && ( +
+
+
+ )} +
+ ); +} + +function UsageSectionBlock({ + section, + analyticsTimeoutRow, +}: { + section: UsageSection, + analyticsTimeoutRow: UsageRow | undefined, +}) { + const isAnalytics = section.id === "analytics"; + const isDashboard = section.id === "dashboard"; + const Icon = section.icon; + + return ( +
+ {!isDashboard && ( +
+ +

{section.title}

+
+ )} +
+ {section.rows.map((row) => ( + + ))} + {isAnalytics && ( +
+ Analytics query timeout + {formatAnalyticsTimeout(analyticsTimeoutRow)} +
+ )} +
+
+ ); +} + +function UsageContent({ + planUsage, + analyticsTimeoutRow, +}: { + planUsage: PlanUsageData, + analyticsTimeoutRow: UsageRow | undefined, +}) { + const sections = getUsageSections(planUsage.rows); + const statItems = [ + { label: "Plan", value: planUsage.planDisplayName }, + { label: "Billing period", value: `${formatDate(planUsage.periodStart)} – ${formatDate(planUsage.periodEnd)}` }, + { label: "Owner", value: planUsage.ownerTeamDisplayName }, + ]; + + return ( +
+
+ {statItems.map((stat) => ( +
+
{stat.label}
+
{stat.value}
+
+ ))} +
+ +
+ {sections.map((section) => ( + + ))} +
+
+ ); +} + +function UsageBody({ + planUsage, + analyticsTimeoutRow, + onUpgrade, +}: { + planUsage: PlanUsageData, + analyticsTimeoutRow: UsageRow | undefined, + onUpgrade: (() => void) | undefined, +}) { + const overageRows = getOverageRows(planUsage.rows); + + return ( +
+ {overageRows.length > 0 && ( +
+
+ +
+
+ Plan limit exceeded +
+
+ You exceeded your limits. Upgrade to the Team or Growth plan to get higher quotas. +
+
+
+ {onUpgrade != null && ( +
+ + Upgrade + +
+ )} +
+ )} + +
+ ); +} + +export default function PageClient() { + const adminApp = useAdminApp(); + const project = adminApp.useProject(); + const planUsage = adminApp.usePlanUsage(); + const user = useDashboardInternalUser(); + const teams = user.useTeams(); + const ownerTeam = useMemo( + () => teams.find((team) => team.id === planUsage.ownerTeamId) ?? throwErr(`Owner team ${planUsage.ownerTeamId} not found in user's teams?`, { projectId: project.id, teamIds: teams.map((team) => team.id) }), + [planUsage.ownerTeamId, project.id, teams], + ); + const visibleRows = useMemo( + () => planUsage.rows.filter(shouldShowUsageRow), + [planUsage.rows], + ); + const analyticsTimeoutRow = useMemo( + () => planUsage.rows.find((row) => row.itemId === ANALYTICS_TIMEOUT_SECONDS_ITEM_ID), + [planUsage.rows], + ); + const planUsageForDisplay = useMemo( + () => ({ ...planUsage, rows: visibleRows }), + [planUsage, visibleRows], + ); + + const handleUpgrade = planUsage.nextPlanId == null ? undefined : () => { + runAsynchronouslyWithAlert(async () => { + const checkoutUrl = await ownerTeam.createCheckoutUrl({ + productId: planUsage.nextPlanId ?? throwErr("nextPlanId became null unexpectedly"), + returnUrl: window.location.href, + }); + window.location.assign(checkoutUrl); + }); + }; + + return ( + + + + ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-settings/usage/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-settings/usage/page.tsx new file mode 100644 index 000000000..84cdebde1 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-settings/usage/page.tsx @@ -0,0 +1,5 @@ +import PageClient from "./page-client"; + +export default function Page() { + return ; +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx index a617b6ac0..4b4059751 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx @@ -136,7 +136,12 @@ const projectSettingsItem: AppSection = { { name: "General", href: "/project-settings", - match: (fullUrl: URL) => /^\/projects\/[^\/]+\/project-settings(\/.*)?$/.test(fullUrl.pathname), + match: (fullUrl: URL) => /^\/projects\/[^\/]+\/project-settings\/?$/.test(fullUrl.pathname), + }, + { + name: "Usage", + href: "/project-settings/usage", + match: (fullUrl: URL) => /^\/projects\/[^\/]+\/project-settings\/usage(\/.*)?$/.test(fullUrl.pathname), }, { name: "Project Keys", diff --git a/packages/shared/src/interface/admin-interface.ts b/packages/shared/src/interface/admin-interface.ts index 5710f165c..57d9be829 100644 --- a/packages/shared/src/interface/admin-interface.ts +++ b/packages/shared/src/interface/admin-interface.ts @@ -6,6 +6,7 @@ import type { MoneyAmount } from "../utils/currency-constants"; import type { Json } from "../utils/json"; import { Result } from "../utils/results"; import { urlString } from "../utils/urls"; +import type { PlanUsageResponse } from "./plan-usage"; import type { AnalyticsClickmapDevice, AnalyticsClickmapKind, AnalyticsClickmapResponse, AnalyticsClickmapTokenResponse, MetricsResponse, MetricsUserCounts, UserActivityResponse } from "./admin-metrics"; import type { AnalyticsQueryOptions, AnalyticsQueryResponse } from "./crud/analytics"; import { EmailOutboxCrud } from "./crud/email-outbox"; @@ -27,6 +28,8 @@ import { TeamPermissionDefinitionsCrud } from "./crud/team-permissions"; import type { Transaction, TransactionType } from "./crud/transactions"; import { ServerAuthApplicationOptions, HexclaveServerInterface } from "./server-interface"; +export type { PlanUsageResponse } from "./plan-usage"; + type BranchConfigSourceApi = yup.InferType; export type ChatContent = Array< @@ -424,6 +427,17 @@ export class HexclaveAdminInterface extends HexclaveServerInterface { }; } + async getPlanUsage(): Promise { + const response = await this.sendAdminRequest( + "/internal/plan-usage", + { + method: "GET", + }, + null, + ); + return await response.json(); + } + async getUserActivity(userId: string): Promise { const response = await this.sendAdminRequest( urlString`/internal/user-activity?user_id=${userId}`, diff --git a/packages/shared/src/interface/plan-usage.ts b/packages/shared/src/interface/plan-usage.ts new file mode 100644 index 000000000..3046bf388 --- /dev/null +++ b/packages/shared/src/interface/plan-usage.ts @@ -0,0 +1,34 @@ +import * as yup from "yup"; +import { ITEM_IDS, PLAN_LIMITS } from "../plans"; +import { yupArray, yupBoolean, yupNumber, yupObject, yupString } from "../schema-fields"; + +const PLAN_IDS = Object.keys(PLAN_LIMITS) as (keyof typeof PLAN_LIMITS)[]; +const UPGRADE_PLAN_IDS = PLAN_IDS.filter((id): id is Exclude => id !== "free"); + +export const planUsageKindSchema = yupString().oneOf(["current", "metered", "capability"]).defined(); + +export const planUsageRowSchema = yupObject({ + item_id: yupString().oneOf(Object.values(ITEM_IDS)).defined(), + display_name: yupString().defined(), + kind: planUsageKindSchema, + used: yupNumber().integer().nullable().defined(), + limit: yupNumber().integer().nullable().defined(), + remaining: yupNumber().integer().nullable().defined(), + overage: yupNumber().integer().nullable().defined(), + is_unlimited: yupBoolean().defined(), +}).defined(); + +export const planUsageResponseSchema = yupObject({ + owner_team_id: yupString().uuid().defined(), + owner_team_display_name: yupString().defined(), + plan_id: yupString().oneOf(PLAN_IDS).defined(), + plan_display_name: yupString().defined(), + period_start_millis: yupNumber().integer().defined(), + period_end_millis: yupNumber().integer().defined(), + next_plan_id: yupString().oneOf(UPGRADE_PLAN_IDS).nullable().defined(), + rows: yupArray(planUsageRowSchema).defined(), +}).defined(); + +export type PlanUsageKind = yup.InferType; +export type PlanUsageRow = yup.InferType; +export type PlanUsageResponse = yup.InferType; diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/admin-app-impl.ts b/packages/template/src/lib/hexclave-app/apps/implementations/admin-app-impl.ts index c87c95676..96091e5b1 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/admin-app-impl.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/admin-app-impl.ts @@ -20,6 +20,7 @@ import { EmailConfig, hexclaveAppInternalsSymbol } from "../../common"; import { AdminEmailTemplate } from "../../email-templates"; import { InternalApiKey, InternalApiKeyBase, InternalApiKeyBaseCrudRead, InternalApiKeyCreateOptions, InternalApiKeyFirstView, internalApiKeyCreateOptionsToCrud } from "../../internal-api-keys"; import { AdminProjectPermission, AdminProjectPermissionDefinition, AdminProjectPermissionDefinitionCreateOptions, AdminProjectPermissionDefinitionUpdateOptions, AdminTeamPermission, AdminTeamPermissionDefinition, AdminTeamPermissionDefinitionCreateOptions, AdminTeamPermissionDefinitionUpdateOptions, adminProjectPermissionDefinitionCreateOptionsToCrud, adminProjectPermissionDefinitionUpdateOptionsToCrud, adminTeamPermissionDefinitionCreateOptionsToCrud, adminTeamPermissionDefinitionUpdateOptionsToCrud } from "../../permissions"; +import type { PlanUsage } from "../../plan-usage"; import { AdminOwnedProject, AdminProject, AdminProjectUpdateOptions, PushConfigOptions, adminProjectUpdateOptionsToCrud } from "../../projects"; import type { AdminSessionReplay, AdminSessionReplayChunk, ListSessionReplayChunksOptions, ListSessionReplayChunksResult, ListSessionReplaysOptions, ListSessionReplaysResult, SessionReplayAllEventsResult } from "../../session-replays"; import { ManagedEmailProviderListItem, ManagedEmailProviderSetupResult, ManagedEmailProviderStatus, EmailOutboxUpdateOptions, StackAdminApp, StackAdminAppConstructorOptions } from "../interfaces/admin-app"; @@ -33,6 +34,7 @@ import { PushedConfigSource } from "../../projects"; import { useAsyncCache } from "./common"; // THIS_LINE_PLATFORM react-like type BranchConfigSourceApi = yup.InferType; +type PlanUsageResponse = Awaited>; /** * Converts a PushedConfigSource (SDK camelCase) to BranchConfigSourceApi (API snake_case). */ @@ -75,6 +77,9 @@ export class _HexclaveAdminAppImplIncomplete { return await this._interface.getProject(); }); + private readonly _planUsageCache = createCache(async () => { + return await this._interface.getPlanUsage(); + }); private readonly _internalApiKeysCache = createCache(async () => { const res = await this._interface.listInternalApiKeys(); return res; @@ -337,6 +342,28 @@ export class _HexclaveAdminAppImplIncomplete ({ + itemId: row.item_id, + displayName: row.display_name, + kind: row.kind, + used: row.used, + limit: row.limit, + remaining: row.remaining, + overage: row.overage, + isUnlimited: row.is_unlimited, + })), + }; + } + override async getProject(): Promise { return this._adminProjectFromCrud( Result.orThrow(await this._adminProjectCache.getOrWait([], "write-only")), @@ -354,6 +381,17 @@ export class _HexclaveAdminAppImplIncomplete { + return this._planUsageFromCrud(Result.orThrow(await this._planUsageCache.getOrWait([], "write-only"))); + } + + // IF_PLATFORM react-like + usePlanUsage(): PlanUsage { + const crud = useAsyncCache(this._planUsageCache, [], "adminApp.usePlanUsage()"); + return useMemo(() => this._planUsageFromCrud(crud), [crud]); + } + // END_PLATFORM + protected _createInternalApiKeyBaseFromCrud(data: InternalApiKeyBaseCrudRead): InternalApiKeyBase { const app = this; return { diff --git a/packages/template/src/lib/hexclave-app/apps/interfaces/admin-app.ts b/packages/template/src/lib/hexclave-app/apps/interfaces/admin-app.ts index 1d24954bb..cf497ba74 100644 --- a/packages/template/src/lib/hexclave-app/apps/interfaces/admin-app.ts +++ b/packages/template/src/lib/hexclave-app/apps/interfaces/admin-app.ts @@ -9,6 +9,7 @@ import { AsyncStoreProperty, EmailConfig } from "../../common"; import { AdminEmailOutbox, AdminSentEmail } from "../../email"; import { InternalApiKey, InternalApiKeyCreateOptions, InternalApiKeyFirstView } from "../../internal-api-keys"; import { AdminProjectPermission, AdminProjectPermissionDefinition, AdminProjectPermissionDefinitionCreateOptions, AdminProjectPermissionDefinitionUpdateOptions, AdminTeamPermission, AdminTeamPermissionDefinition, AdminTeamPermissionDefinitionCreateOptions, AdminTeamPermissionDefinitionUpdateOptions } from "../../permissions"; +import type { PlanUsage } from "../../plan-usage"; import { AdminProject } from "../../projects"; import { _HexclaveAdminAppImpl } from "../implementations"; import { StackServerApp, StackServerAppConstructorOptions } from "./server-app"; @@ -71,6 +72,7 @@ export type StackAdminAppConstructorOptions = ( & AsyncStoreProperty<"project", [], AdminProject, false> + & AsyncStoreProperty<"planUsage", [], PlanUsage, false> & AsyncStoreProperty<"internalApiKeys", [], InternalApiKey[], true> & AsyncStoreProperty<"teamPermissionDefinitions", [], AdminTeamPermissionDefinition[], true> & AsyncStoreProperty<"projectPermissionDefinitions", [], AdminProjectPermissionDefinition[], true> diff --git a/packages/template/src/lib/hexclave-app/index.ts b/packages/template/src/lib/hexclave-app/index.ts index 6776a7ed0..0633ac060 100644 --- a/packages/template/src/lib/hexclave-app/index.ts +++ b/packages/template/src/lib/hexclave-app/index.ts @@ -114,6 +114,14 @@ export type { PushedConfigSource } from "./projects"; +export type { + PlanUsage, + PlanUsageKind, + PlanUsageNextPlanId, + PlanUsagePlanId, + PlanUsageRow, +} from "./plan-usage"; + export type { EditableTeamMemberProfile, ReceivedTeamInvitation, SentTeamInvitation, ServerListUsersOptions, diff --git a/packages/template/src/lib/hexclave-app/plan-usage/index.ts b/packages/template/src/lib/hexclave-app/plan-usage/index.ts new file mode 100644 index 000000000..9226c3873 --- /dev/null +++ b/packages/template/src/lib/hexclave-app/plan-usage/index.ts @@ -0,0 +1,25 @@ +export type PlanUsageKind = "current" | "metered" | "capability"; +export type PlanUsagePlanId = "free" | "team" | "growth"; +export type PlanUsageNextPlanId = "team" | "growth"; + +export type PlanUsageRow = { + itemId: string, + displayName: string, + kind: PlanUsageKind, + used: number | null, + limit: number | null, + remaining: number | null, + overage: number | null, + isUnlimited: boolean, +}; + +export type PlanUsage = { + ownerTeamId: string, + ownerTeamDisplayName: string, + planId: PlanUsagePlanId, + planDisplayName: string, + periodStart: Date, + periodEnd: Date, + nextPlanId: PlanUsageNextPlanId | null, + rows: PlanUsageRow[], +};