From 18edc94b2caa56e6b0b7a56d21c62c40bca0b965 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 15 Jun 2026 12:36:56 -0700 Subject: [PATCH] fix(billing): deploy modal gates on workspace entitlement, not viewer plan (#5055) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(billing): deploy modal gates on workspace entitlement, not viewer plan The deploy modal showed the upgrade wall to a free user in a PAID workspace, because it gated on the viewer's individual plan (useSubscriptionData) while the server gates on the workspace billed account (rolled-up plan). Add a workspace api-execution-entitlement endpoint that mirrors isWorkspaceApiExecutionEntitled, and gate the API/MCP/A2A tabs on it so the UI matches the server exactly. * fix(billing): key deploy gate on URL workspaceId + refetch entitlement on open Address review findings: - key useWorkspaceApiExecutionEntitlement on the URL workspaceId (available on mount) instead of workflowWorkspaceId (null until the workflow map resolves), so the gate fires immediately instead of leaving the tabs ungated until then - staleTime 0 so reopening the deploy modal refetches entitlement; a plan upgrade happens outside this query's invalidation graph, so the gate self-heals on open * refactor(billing): workspace owner access state instead of bespoke entitlement endpoint Replace the single-purpose api-execution-entitlement endpoint with a reusable workspace-owner billing/access concept — the workspace-scoped counterpart to the viewer-scoped useSubscriptionData: - getWorkspaceOwnerSubscriptionAccess(workspaceId): the billed account's rolled-up subscription access fields (mirrors getSimplifiedBillingSummary's flag derivation) - GET /api/workspaces/[id]/owner-billing + useWorkspaceOwnerBilling hook - deploy modal derives its gate via the existing getSubscriptionAccessState (hasUsablePaidAccess) on the owner data, exactly like every other paid feature Audited the rest of the app: no other UI gates on the viewer's plan where the server gates on the workspace owner — programmatic execution is the only workspace-owner-scoped feature; inbox/KB-live-sync/credential-sets all gate consistently on both sides. * fix(billing): deploy gate on owner isPaid, not hasUsablePaidAccess hasUsablePaidAccess rejects past_due and billing-blocked, but the server gate (isWorkspaceApiExecutionEntitled) allows any paid plan in an entitled status (active or past_due). Gate on the owner's isPaid so a past_due paid workspace isn't shown the upgrade wall while the API still works. --- .../[id]/owner-billing/route.test.ts | 80 +++++++++++++++++++ .../workspaces/[id]/owner-billing/route.ts | 35 ++++++++ .../components/deploy-modal/deploy-modal.tsx | 19 +++-- apps/sim/hooks/queries/workspace.ts | 33 ++++++++ apps/sim/lib/api/contracts/workspaces.ts | 29 +++++++ .../lib/billing/core/workspace-access.test.ts | 59 ++++++++++++++ apps/sim/lib/billing/core/workspace-access.ts | 56 +++++++++++++ apps/sim/lib/billing/index.ts | 1 + scripts/check-api-validation-contracts.ts | 4 +- 9 files changed, 306 insertions(+), 10 deletions(-) create mode 100644 apps/sim/app/api/workspaces/[id]/owner-billing/route.test.ts create mode 100644 apps/sim/app/api/workspaces/[id]/owner-billing/route.ts create mode 100644 apps/sim/lib/billing/core/workspace-access.test.ts create mode 100644 apps/sim/lib/billing/core/workspace-access.ts diff --git a/apps/sim/app/api/workspaces/[id]/owner-billing/route.test.ts b/apps/sim/app/api/workspaces/[id]/owner-billing/route.test.ts new file mode 100644 index 0000000000..2ae727ae92 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/owner-billing/route.test.ts @@ -0,0 +1,80 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSession, mockGetUserEntityPermissions, mockGetWorkspaceOwnerSubscriptionAccess } = + vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), + mockGetWorkspaceOwnerSubscriptionAccess: vi.fn(), + })) + +vi.mock('@/lib/auth', () => ({ + auth: { api: { getSession: vi.fn() } }, + getSession: mockGetSession, +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mockGetUserEntityPermissions, +})) + +vi.mock('@/lib/billing/core/workspace-access', () => ({ + getWorkspaceOwnerSubscriptionAccess: mockGetWorkspaceOwnerSubscriptionAccess, +})) + +import { GET } from '@/app/api/workspaces/[id]/owner-billing/route' + +const WORKSPACE_ID = 'ws-1' + +const PAID_ACCESS = { + plan: 'team_25000', + status: 'active', + isPaid: true, + isPro: false, + isTeam: true, + isEnterprise: false, + isOrgScoped: true, + organizationId: 'org-1', +} + +function buildParams() { + return { params: Promise.resolve({ id: WORKSPACE_ID }) } +} + +async function callGet() { + const request = createMockRequest('GET') + const response = await GET(request, buildParams()) + return { status: response.status, body: await response.json() } +} + +describe('GET /api/workspaces/[id]/owner-billing', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ user: { id: 'u-1' } }) + mockGetUserEntityPermissions.mockResolvedValue('read') + mockGetWorkspaceOwnerSubscriptionAccess.mockResolvedValue(PAID_ACCESS) + }) + + it('returns 401 when unauthenticated', async () => { + mockGetSession.mockResolvedValue(null) + const { status } = await callGet() + expect(status).toBe(401) + expect(mockGetWorkspaceOwnerSubscriptionAccess).not.toHaveBeenCalled() + }) + + it('returns 404 when the caller has no workspace access', async () => { + mockGetUserEntityPermissions.mockResolvedValue(null) + const { status } = await callGet() + expect(status).toBe(404) + expect(mockGetWorkspaceOwnerSubscriptionAccess).not.toHaveBeenCalled() + }) + + it('returns the workspace owner subscription access for a member', async () => { + const { status, body } = await callGet() + expect(status).toBe(200) + expect(body).toEqual(PAID_ACCESS) + expect(mockGetWorkspaceOwnerSubscriptionAccess).toHaveBeenCalledWith(WORKSPACE_ID) + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/owner-billing/route.ts b/apps/sim/app/api/workspaces/[id]/owner-billing/route.ts new file mode 100644 index 0000000000..6766c3351a --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/owner-billing/route.ts @@ -0,0 +1,35 @@ +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { getWorkspaceOwnerBillingContract } from '@/lib/api/contracts/workspaces' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +/** + * Subscription access state of the workspace's billed account — the workspace- + * scoped counterpart to the viewer `/api/billing`. Lets the UI gate workspace + * features (e.g. the deploy modal) on the owner's plan rather than the viewer's, + * so a free member of a paid workspace isn't shown an upgrade wall. + */ +export const GET = withRouteHandler( + async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(getWorkspaceOwnerBillingContract, req, context) + if (!parsed.success) return parsed.response + const { id: workspaceId } = parsed.data.params + + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (!permission) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + + const ownerAccess = await getWorkspaceOwnerSubscriptionAccess(workspaceId) + return NextResponse.json(ownerAccess) + } +) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx index 5f19ce56e4..4f7cddaa13 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx @@ -21,7 +21,6 @@ import { ModalTabsList, ModalTabsTrigger, } from '@/components/emcn' -import { isFree } from '@/lib/billing/plan-helpers' import { getBaseUrl } from '@/lib/core/utils/urls' import { getInputFormatExample as getInputFormatExampleUtil } from '@/lib/workflows/operations/deployment-utils' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' @@ -46,10 +45,9 @@ import { useDeployWorkflow, useUndeployWorkflow, } from '@/hooks/queries/deployments' -import { useSubscriptionData } from '@/hooks/queries/subscription' import { useWorkflowMcpServers } from '@/hooks/queries/workflow-mcp-servers' import { useWorkflowMap } from '@/hooks/queries/workflows' -import { useWorkspaceSettings } from '@/hooks/queries/workspace' +import { useWorkspaceOwnerBilling, useWorkspaceSettings } from '@/hooks/queries/workspace' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -158,11 +156,16 @@ export function DeployModal({ const userPermissions = useUserPermissionsContext() const canManageWorkspaceKeys = userPermissions.canAdmin const { config: permissionConfig, isPublicApiDisabled } = usePermissionConfig() - const { data: subscriptionData, isLoading: isLoadingSubscription } = useSubscriptionData() - // Hold the gate closed until the plan is known — isFree(undefined) is true, so - // gating during load would flash the upgrade wall at paid users. - const gateProgrammaticDeploy = - isBillingEnabled && !isLoadingSubscription && isFree(subscriptionData?.data?.plan) + // Gate on the WORKSPACE owner's plan (billed account, rolled up), not the + // viewer's individual plan, so a free member of a paid workspace isn't shown + // the upgrade wall. Keyed on the URL `workspaceId` (available on mount). Uses + // `isPaid` — the same check the server gate runs (any paid plan in an entitled + // status, incl. `past_due`) — rather than `hasUsablePaidAccess`, which would + // reject `past_due`/billing-blocked owners the API still allows. While loading + // the data is undefined → gate stays closed (no flash); only a resolved, + // non-paid owner gates. + const { data: ownerBilling } = useWorkspaceOwnerBilling(workspaceId ?? undefined) + const gateProgrammaticDeploy = isBillingEnabled && !!ownerBilling && !ownerBilling.isPaid const { data: apiKeysData, isLoading: isLoadingKeys } = useApiKeys(workflowWorkspaceId || '') const { data: workspaceSettingsData, isLoading: isLoadingSettings } = useWorkspaceSettings( workflowWorkspaceId || '' diff --git a/apps/sim/hooks/queries/workspace.ts b/apps/sim/hooks/queries/workspace.ts index cdb700fdb0..f91d769d1f 100644 --- a/apps/sim/hooks/queries/workspace.ts +++ b/apps/sim/hooks/queries/workspace.ts @@ -8,12 +8,14 @@ import { deleteWorkspaceContract, getWorkspaceContract, getWorkspaceMembersContract, + getWorkspaceOwnerBillingContract, getWorkspacePermissionsContract, listWorkspacesContract, updateWorkspaceContract, type Workspace, type WorkspaceCreationPolicy, type WorkspaceMember, + type WorkspaceOwnerBilling, type WorkspacePermissions, type WorkspaceQueryScope, type WorkspacesResponse, @@ -33,6 +35,7 @@ export const workspaceKeys = { settings: (id: string) => [...workspaceKeys.detail(id), 'settings'] as const, permissions: (id: string) => [...workspaceKeys.detail(id), 'permissions'] as const, members: (id: string) => [...workspaceKeys.detail(id), 'members'] as const, + ownerBilling: (id: string) => [...workspaceKeys.detail(id), 'ownerBilling'] as const, adminLists: () => [...workspaceKeys.all, 'adminList'] as const, adminList: (userId: string | undefined) => [...workspaceKeys.adminLists(), userId ?? ''] as const, } @@ -108,6 +111,36 @@ export function useWorkspaceCreationPolicy(enabled = true) { }) } +async function fetchWorkspaceOwnerBilling( + workspaceId: string, + signal?: AbortSignal +): Promise { + return requestJson(getWorkspaceOwnerBillingContract, { + params: { id: workspaceId }, + signal, + }) +} + +/** + * Subscription access state of the workspace's billed account (its owner's + * rolled-up plan) — the workspace-scoped counterpart to `useSubscriptionData`. + * Feed the result to `getSubscriptionAccessState` to gate workspace features on + * the owner's plan rather than the viewer's, so a free member of a paid workspace + * isn't gated. + * + * `staleTime: 0` so consumers (e.g. the deploy modal) refetch on mount: a plan + * change happens outside this query's invalidation graph, and the cached value is + * shown during the background refetch (no flash), so gates self-heal on reopen. + */ +export function useWorkspaceOwnerBilling(workspaceId?: string) { + return useQuery({ + queryKey: workspaceKeys.ownerBilling(workspaceId ?? ''), + queryFn: ({ signal }) => fetchWorkspaceOwnerBilling(workspaceId as string, signal), + enabled: Boolean(workspaceId), + staleTime: 0, + }) +} + type CreateWorkspaceParams = Pick, 'name'> /** diff --git a/apps/sim/lib/api/contracts/workspaces.ts b/apps/sim/lib/api/contracts/workspaces.ts index 6a8a0de394..361004bdf2 100644 --- a/apps/sim/lib/api/contracts/workspaces.ts +++ b/apps/sim/lib/api/contracts/workspaces.ts @@ -181,6 +181,35 @@ export const getWorkspaceContract = defineRouteContract({ }, }) +/** + * Subscription access fields of the workspace's billed account (its OWNER's + * rolled-up plan) — the workspace-scoped counterpart to the viewer `/api/billing` + * data. Feed to `getSubscriptionAccessState` to gate workspace features on the + * owner's plan instead of the signed-in viewer's. No usage/credit data. + */ +export const workspaceOwnerBillingSchema = z.object({ + plan: z.string(), + status: z.string().nullable(), + isPaid: z.boolean(), + isPro: z.boolean(), + isTeam: z.boolean(), + isEnterprise: z.boolean(), + isOrgScoped: z.boolean(), + organizationId: z.string().nullable(), +}) + +export type WorkspaceOwnerBilling = z.output + +export const getWorkspaceOwnerBillingContract = defineRouteContract({ + method: 'GET', + path: '/api/workspaces/[id]/owner-billing', + params: workspaceParamsSchema, + response: { + mode: 'json', + schema: workspaceOwnerBillingSchema, + }, +}) + export const updateWorkspaceContract = defineRouteContract({ method: 'PATCH', path: '/api/workspaces/[id]', diff --git a/apps/sim/lib/billing/core/workspace-access.test.ts b/apps/sim/lib/billing/core/workspace-access.test.ts new file mode 100644 index 0000000000..5ff234c9b7 --- /dev/null +++ b/apps/sim/lib/billing/core/workspace-access.test.ts @@ -0,0 +1,59 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetWorkspaceBilledAccountUserId, mockGetHighestPrioritySubscription } = vi.hoisted( + () => ({ + mockGetWorkspaceBilledAccountUserId: vi.fn(), + mockGetHighestPrioritySubscription: vi.fn(), + }) +) + +vi.mock('@/lib/workspaces/utils', () => ({ + getWorkspaceBilledAccountUserId: mockGetWorkspaceBilledAccountUserId, +})) + +vi.mock('@/lib/billing/core/subscription', () => ({ + getHighestPrioritySubscription: mockGetHighestPrioritySubscription, +})) + +import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access' + +describe('getWorkspaceOwnerSubscriptionAccess', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetWorkspaceBilledAccountUserId.mockResolvedValue('owner-1') + }) + + it('reports paid + org-scoped for an org team plan billed to the owner', async () => { + mockGetHighestPrioritySubscription.mockResolvedValue({ + plan: 'team_25000', + status: 'active', + referenceId: 'org-1', + }) + const access = await getWorkspaceOwnerSubscriptionAccess('ws-1') + expect(access).toMatchObject({ + plan: 'team_25000', + isPaid: true, + isTeam: true, + isPro: false, + isEnterprise: false, + isOrgScoped: true, + organizationId: 'org-1', + }) + }) + + it('reports free when the billed account has no subscription', async () => { + mockGetHighestPrioritySubscription.mockResolvedValue(null) + const access = await getWorkspaceOwnerSubscriptionAccess('ws-1') + expect(access).toMatchObject({ plan: 'free', isPaid: false, isOrgScoped: false }) + }) + + it('reports free when the workspace has no billed account', async () => { + mockGetWorkspaceBilledAccountUserId.mockResolvedValue(null) + const access = await getWorkspaceOwnerSubscriptionAccess('ws-1') + expect(access.isPaid).toBe(false) + expect(mockGetHighestPrioritySubscription).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/billing/core/workspace-access.ts b/apps/sim/lib/billing/core/workspace-access.ts new file mode 100644 index 0000000000..db83bf8c8d --- /dev/null +++ b/apps/sim/lib/billing/core/workspace-access.ts @@ -0,0 +1,56 @@ +import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' +import { isEnterprise, isPaid, isPro, isTeam } from '@/lib/billing/plan-helpers' +import { + hasPaidSubscriptionStatus, + isOrgScopedSubscription, +} from '@/lib/billing/subscriptions/utils' +import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils' + +/** + * The subscription access fields of a workspace's billed account, as a workspace- + * scoped counterpart to the viewer's `/api/billing` data. Feed this to the + * client `getSubscriptionAccessState` to derive `hasUsablePaidAccess` etc. for + * the WORKSPACE (its owner's rolled-up plan), instead of the signed-in viewer's + * individual plan — so a free member of a paid workspace isn't gated. + * + * Carries no usage/credit/Stripe data: safe to expose to any workspace member. + */ +export interface WorkspaceOwnerSubscriptionAccess { + plan: string + status: string | null + isPaid: boolean + isPro: boolean + isTeam: boolean + isEnterprise: boolean + isOrgScoped: boolean + organizationId: string | null +} + +/** + * Resolves the workspace's billed account and returns its subscription access + * fields (rolled up over org memberships). Mirrors the flag derivation in + * `getSimplifiedBillingSummary` so the result matches the viewer `/api/billing` + * shape for the owner. + */ +export async function getWorkspaceOwnerSubscriptionAccess( + workspaceId: string +): Promise { + const billedUserId = await getWorkspaceBilledAccountUserId(workspaceId) + const subscription = billedUserId ? await getHighestPrioritySubscription(billedUserId) : null + + const plan = subscription?.plan ?? 'free' + const hasPaidEntitlement = hasPaidSubscriptionStatus(subscription?.status) + const orgScoped = + subscription && billedUserId ? isOrgScopedSubscription(subscription, billedUserId) : false + + return { + plan, + status: subscription?.status ?? null, + isPaid: hasPaidEntitlement && isPaid(plan), + isPro: hasPaidEntitlement && isPro(plan), + isTeam: hasPaidEntitlement && isTeam(plan), + isEnterprise: hasPaidEntitlement && isEnterprise(plan), + isOrgScoped: orgScoped, + organizationId: orgScoped && subscription ? subscription.referenceId : null, + } +} diff --git a/apps/sim/lib/billing/index.ts b/apps/sim/lib/billing/index.ts index 83a938f633..eff8e32c75 100644 --- a/apps/sim/lib/billing/index.ts +++ b/apps/sim/lib/billing/index.ts @@ -30,6 +30,7 @@ export { getUserUsageLimit as getUsageLimit, updateUserUsageLimit as updateUsageLimit, } from '@/lib/billing/core/usage' +export * from '@/lib/billing/core/workspace-access' export * from '@/lib/billing/credits/balance' export * from '@/lib/billing/credits/purchase' export { diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 2ca1801390..d82996804c 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 827, - zodRoutes: 827, + totalRoutes: 828, + zodRoutes: 828, nonZodRoutes: 0, } as const