diff --git a/apps/backend/src/lib/plan-usage.ts b/apps/backend/src/lib/plan-usage.ts index 6c28c398e..14d8195ae 100644 --- a/apps/backend/src/lib/plan-usage.ts +++ b/apps/backend/src/lib/plan-usage.ts @@ -3,6 +3,7 @@ import { getClickhouseAdminClientForMetrics } from "@/lib/clickhouse"; import { getSubscriptionMapForCustomer } from "@/lib/payments/customer-data"; import { isActiveSubscription } from "@/lib/payments"; import { + arePlanLimitsEnforced, getBillingTeamId, getNonAnonymousUserCountForTenancies, getOwnedProjectAndTenancyIdsForBillingTeam, @@ -423,6 +424,7 @@ export async function getPlanUsageForProject(project: UsageSourceProject, now: D period_start_millis: period.start.getTime(), period_end_millis: period.end.getTime(), next_plan_id: getNextPlanId(planId), + are_plan_limits_enforced: arePlanLimitsEnforced(), rows: buildRows({ planId, dashboardAdmins, diff --git a/apps/dashboard/.env b/apps/dashboard/.env index b99c169b0..a3358387b 100644 --- a/apps/dashboard/.env +++ b/apps/dashboard/.env @@ -16,5 +16,6 @@ NEXT_PUBLIC_HEXCLAVE_HEAD_TAGS=# a JSON array of head tags to inject, e.g. '[{ " HEXCLAVE_DEVELOPMENT_TRANSLATION_LOCALE=# enter the locale to use for the translation provider here, for example: de-DE. Only works during development, not in production. Optional, by default don't translate NEXT_PUBLIC_HEXCLAVE_ENABLE_DEVELOPMENT_FEATURES_PROJECT_IDS=# JSON array of project IDs that get development features (set to '["internal"]' in .env.development). Leave empty here so a platform-set legacy NEXT_PUBLIC_STACK_* value isn't treated as a conflict in prod builds. NEXT_PUBLIC_HEXCLAVE_DEBUGGER_ON_ASSERTION_ERROR=# set to true to open the debugger on assertion errors (set to true in .env.development) +HEXCLAVE_DISABLE_PLAN_LIMITS=# set to "true" to bypass enforcement of plan limits in the dashboard (seat-capacity gate on the projects page). Default unset/false preserves enforcement. HEXCLAVE_FEATUREBASE_JWT_SECRET=# used for Featurebase SSO, you probably won't have to set this HEXCLAVE_CHANGELOG_URL=# Used for raw github link to root changelog.md file. diff --git a/apps/dashboard/.env.development b/apps/dashboard/.env.development index 0b8dd416e..bf14e7d85 100644 --- a/apps/dashboard/.env.development +++ b/apps/dashboard/.env.development @@ -13,4 +13,5 @@ HEXCLAVE_ARTIFICIAL_DEVELOPMENT_DELAY_MS=50 NEXT_PUBLIC_HEXCLAVE_DEBUGGER_ON_ASSERTION_ERROR=false NEXT_PUBLIC_HEXCLAVE_ENABLE_DEVELOPMENT_FEATURES_PROJECT_IDS='["internal"]' +HEXCLAVE_DISABLE_PLAN_LIMITS=false HEXCLAVE_FEATUREBASE_JWT_SECRET=secret-value diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/actions.ts b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/actions.ts index 807ce9271..816b6a40c 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/actions.ts +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/actions.ts @@ -1,5 +1,6 @@ "use server"; import { isRemoteDevelopmentEnvironmentEnabled } from "@/lib/remote-development-environment/env"; +import { getEnvVariable } from "@hexclave/shared/dist/utils/env"; import { hexclaveAppInternalsSymbol } from "@hexclave/next"; async function getServerApp() { @@ -49,3 +50,7 @@ export async function inviteUser(teamId: string, email: string, origin: string) } await team.inviteUser({ email, callbackUrl }); } + +export async function getArePlanLimitsEnforced(): Promise { + return getEnvVariable("STACK_DISABLE_PLAN_LIMITS", "false") !== "true"; +} diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx index 11fe624ec..bfcf8bba0 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx @@ -20,7 +20,7 @@ import { stringCompare } from "@hexclave/shared/dist/utils/strings"; import { urlString } from "@hexclave/shared/dist/utils/urls"; import { useCallback, useEffect, useMemo, useState } from "react"; import * as yup from "yup"; -import { inviteUser, listInvitations, revokeInvitation } from "./actions"; +import { getArePlanLimitsEnforced, inviteUser, listInvitations, revokeInvitation } from "./actions"; import Footer from "./footer"; import PreviewProjectRedirect from "./preview-project-redirect"; @@ -513,14 +513,16 @@ type TeamAddUserDialogData = { userCount: number, seatLimit: number, hasPaidPlan: boolean, + arePlanLimitsEnforced: boolean, }; async function loadTeamAddUserDialogData(team: Team): Promise { - const [invitations, users, admins, products] = await Promise.all([ + const [invitations, users, admins, products, arePlanLimitsEnforced] = await Promise.all([ listInvitations(team.id), team.listUsers(), team.getItem("dashboard_admins"), team.listProducts(), + getArePlanLimitsEnforced(), ]); return { @@ -528,6 +530,7 @@ async function loadTeamAddUserDialogData(team: Team): Promise= dialogData.seatLimit; + const atCapacity = dialogData != null && dialogData.arePlanLimitsEnforced && activeSeats != null && activeSeats >= dialogData.seatLimit; const handleInvite = async () => { if (dialogData == null || atCapacity) { diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx index 9e80840fb..a848bedb2 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx @@ -329,6 +329,7 @@ export function ErrorDisplay({ error, onRetry }: { error: unknown, onRetry: () = export function AnalyticsEventLimitBanner() { const adminApp = useAdminApp(); const project = adminApp.useProject(); + const planUsage = adminApp.usePlanUsage(); const user = useDashboardInternalUser(); const teams = user.useTeams(); @@ -337,7 +338,7 @@ export function AnalyticsEventLimitBanner() { [teams, project.ownerTeamId], ); - if (ownerTeam == null) { + if (!planUsage.arePlanLimitsEnforced || ownerTeam == null) { return null; } @@ -351,6 +352,7 @@ export function AnalyticsEventLimitBanner() { export function SessionReplayLimitBanner() { const adminApp = useAdminApp(); const project = adminApp.useProject(); + const planUsage = adminApp.usePlanUsage(); const user = useDashboardInternalUser(); const teams = user.useTeams(); @@ -359,7 +361,7 @@ export function SessionReplayLimitBanner() { [teams, project.ownerTeamId], ); - if (ownerTeam == null) { + if (!planUsage.arePlanLimitsEnforced || ownerTeam == null) { return null; } 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 index 4ec7a3934..848047c40 100644 --- 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 @@ -15,6 +15,7 @@ function createPlanUsageState() { periodStart: new Date(Date.UTC(2026, 5, 1)), periodEnd: new Date(Date.UTC(2026, 6, 1)), nextPlanId: "team", + arePlanLimitsEnforced: true, rows: [ { itemId: "dashboard_admins", @@ -148,6 +149,17 @@ describe("Usage settings page", () => { expect(authUsageFill.style.width).toBe("0%"); }); + it("hides overage banner when plan limits are not enforced", () => { + planUsageState = { + ...createPlanUsageState(), + arePlanLimitsEnforced: false, + }; + + render(); + + expect(screen.queryByRole("alert")).toBeNull(); + }); + it("starts checkout for the next plan from the upgrade CTA", async () => { render(); 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 index cfdac5213..71cb01900 100644 --- 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 @@ -288,16 +288,18 @@ function UsageBody({ planUsage, analyticsTimeoutRow, onUpgrade, + arePlanLimitsEnforced, }: { planUsage: PlanUsageData, analyticsTimeoutRow: UsageRow | undefined, onUpgrade: (() => void) | undefined, + arePlanLimitsEnforced: boolean, }) { const overageRows = getOverageRows(planUsage.rows); return (
- {overageRows.length > 0 && ( + {arePlanLimitsEnforced && overageRows.length > 0 && (
); diff --git a/packages/shared/src/interface/plan-usage.ts b/packages/shared/src/interface/plan-usage.ts index 3046bf388..43c828a75 100644 --- a/packages/shared/src/interface/plan-usage.ts +++ b/packages/shared/src/interface/plan-usage.ts @@ -26,6 +26,7 @@ export const planUsageResponseSchema = yupObject({ period_start_millis: yupNumber().integer().defined(), period_end_millis: yupNumber().integer().defined(), next_plan_id: yupString().oneOf(UPGRADE_PLAN_IDS).nullable().defined(), + are_plan_limits_enforced: yupBoolean().optional().default(true), rows: yupArray(planUsageRowSchema).defined(), }).defined(); 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 78ecdddd1..be2426139 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 @@ -351,6 +351,8 @@ export class _HexclaveAdminAppImplIncomplete ({ itemId: row.item_id, displayName: row.display_name, diff --git a/packages/template/src/lib/hexclave-app/plan-usage/index.ts b/packages/template/src/lib/hexclave-app/plan-usage/index.ts index 9226c3873..b0e66596d 100644 --- a/packages/template/src/lib/hexclave-app/plan-usage/index.ts +++ b/packages/template/src/lib/hexclave-app/plan-usage/index.ts @@ -21,5 +21,6 @@ export type PlanUsage = { periodStart: Date, periodEnd: Date, nextPlanId: PlanUsageNextPlanId | null, + arePlanLimitsEnforced: boolean, rows: PlanUsageRow[], };