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[], +};