From 933c22826543b571c52ce5c16903e3475da83ffd Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 15 Jun 2026 10:01:17 -0700 Subject: [PATCH 1/4] 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. --- .../api-execution-entitlement/route.test.ts | 77 +++++++++++++++++++ .../[id]/api-execution-entitlement/route.ts | 39 ++++++++++ .../components/deploy-modal/deploy-modal.tsx | 21 ++--- apps/sim/hooks/queries/workspace.ts | 28 +++++++ apps/sim/lib/api/contracts/workspaces.ts | 19 +++++ scripts/check-api-validation-contracts.ts | 4 +- 6 files changed, 177 insertions(+), 11 deletions(-) create mode 100644 apps/sim/app/api/workspaces/[id]/api-execution-entitlement/route.test.ts create mode 100644 apps/sim/app/api/workspaces/[id]/api-execution-entitlement/route.ts diff --git a/apps/sim/app/api/workspaces/[id]/api-execution-entitlement/route.test.ts b/apps/sim/app/api/workspaces/[id]/api-execution-entitlement/route.test.ts new file mode 100644 index 00000000000..87661472e1a --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/api-execution-entitlement/route.test.ts @@ -0,0 +1,77 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSession, mockGetUserEntityPermissions, mockIsWorkspaceApiExecutionEntitled } = + vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), + mockIsWorkspaceApiExecutionEntitled: 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/api-access', () => ({ + isWorkspaceApiExecutionEntitled: mockIsWorkspaceApiExecutionEntitled, +})) + +import { GET } from '@/app/api/workspaces/[id]/api-execution-entitlement/route' + +const WORKSPACE_ID = 'ws-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]/api-execution-entitlement', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ user: { id: 'u-1' } }) + mockGetUserEntityPermissions.mockResolvedValue('read') + mockIsWorkspaceApiExecutionEntitled.mockResolvedValue(true) + }) + + it('returns 401 when unauthenticated', async () => { + mockGetSession.mockResolvedValue(null) + const { status } = await callGet() + expect(status).toBe(401) + expect(mockIsWorkspaceApiExecutionEntitled).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(mockIsWorkspaceApiExecutionEntitled).not.toHaveBeenCalled() + }) + + it('returns entitled: true for an entitled workspace', async () => { + mockIsWorkspaceApiExecutionEntitled.mockResolvedValue(true) + const { status, body } = await callGet() + expect(status).toBe(200) + expect(body).toEqual({ entitled: true }) + expect(mockIsWorkspaceApiExecutionEntitled).toHaveBeenCalledWith(WORKSPACE_ID) + }) + + it('returns entitled: false for a free workspace with the gate active', async () => { + mockIsWorkspaceApiExecutionEntitled.mockResolvedValue(false) + const { status, body } = await callGet() + expect(status).toBe(200) + expect(body).toEqual({ entitled: false }) + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/api-execution-entitlement/route.ts b/apps/sim/app/api/workspaces/[id]/api-execution-entitlement/route.ts new file mode 100644 index 00000000000..72b89ff79c5 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/api-execution-entitlement/route.ts @@ -0,0 +1,39 @@ +import { createLogger } from '@sim/logger' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { getWorkspaceApiExecutionEntitlementContract } from '@/lib/api/contracts/workspaces' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { isWorkspaceApiExecutionEntitled } from '@/lib/billing/core/api-access' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('WorkspaceApiExecutionEntitlementAPI') + +/** + * Whether this workspace may run workflows programmatically — the UI mirror of + * the server gate (`isWorkspaceApiExecutionEntitled`). Lets the deploy modal + * reflect the workspace's billed-account plan instead of the viewer's individual + * plan, so a free member of a paid workspace isn't shown the 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(getWorkspaceApiExecutionEntitlementContract, 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 entitled = await isWorkspaceApiExecutionEntitled(workspaceId) + logger.info('Resolved workspace API-execution entitlement', { workspaceId, entitled }) + return NextResponse.json({ entitled }) + } +) 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 5f19ce56e4e..564b170d058 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,12 +21,10 @@ 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' import { CreateApiKeyModal } from '@/app/workspace/[workspaceId]/settings/components/api-keys/components' -import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation' import { releaseDeployAction, tryAcquireDeployAction, @@ -46,10 +44,12 @@ 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 { + useWorkspaceApiExecutionEntitlement, + 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 +158,14 @@ 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) + // Mirror the server gate: entitlement reflects the workspace's billed-account + // plan (rolled up), not the viewer's individual plan, so a free member of a + // paid workspace isn't shown the upgrade wall. Undefined while loading keeps + // the gate closed (no flash); only an explicit `entitled === false` gates. + const { data: apiExecutionEntitlement } = useWorkspaceApiExecutionEntitlement( + workflowWorkspaceId ?? undefined + ) + const gateProgrammaticDeploy = apiExecutionEntitlement?.entitled === false 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 cdb700fdb06..295f7b0c8e2 100644 --- a/apps/sim/hooks/queries/workspace.ts +++ b/apps/sim/hooks/queries/workspace.ts @@ -6,12 +6,14 @@ import type { ContractBodyInput } from '@/lib/api/contracts' import { createWorkspaceContract, deleteWorkspaceContract, + getWorkspaceApiExecutionEntitlementContract, getWorkspaceContract, getWorkspaceMembersContract, getWorkspacePermissionsContract, listWorkspacesContract, updateWorkspaceContract, type Workspace, + type WorkspaceApiExecutionEntitlement, type WorkspaceCreationPolicy, type WorkspaceMember, type WorkspacePermissions, @@ -33,6 +35,8 @@ 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, + apiExecutionEntitlement: (id: string) => + [...workspaceKeys.detail(id), 'apiExecutionEntitlement'] as const, adminLists: () => [...workspaceKeys.all, 'adminList'] as const, adminList: (userId: string | undefined) => [...workspaceKeys.adminLists(), userId ?? ''] as const, } @@ -108,6 +112,30 @@ export function useWorkspaceCreationPolicy(enabled = true) { }) } +async function fetchWorkspaceApiExecutionEntitlement( + workspaceId: string, + signal?: AbortSignal +): Promise { + return requestJson(getWorkspaceApiExecutionEntitlementContract, { + params: { id: workspaceId }, + signal, + }) +} + +/** + * Whether the workspace may run workflows programmatically — the UI mirror of the + * server gate. Reflects the workspace's billed-account plan, not the viewer's + * individual plan, so a free member of a paid workspace isn't gated. + */ +export function useWorkspaceApiExecutionEntitlement(workspaceId?: string) { + return useQuery({ + queryKey: workspaceKeys.apiExecutionEntitlement(workspaceId ?? ''), + queryFn: ({ signal }) => fetchWorkspaceApiExecutionEntitlement(workspaceId as string, signal), + enabled: Boolean(workspaceId), + staleTime: 60 * 1000, + }) +} + type CreateWorkspaceParams = Pick, 'name'> /** diff --git a/apps/sim/lib/api/contracts/workspaces.ts b/apps/sim/lib/api/contracts/workspaces.ts index 6a8a0de3948..dcb910a5cf4 100644 --- a/apps/sim/lib/api/contracts/workspaces.ts +++ b/apps/sim/lib/api/contracts/workspaces.ts @@ -181,6 +181,25 @@ export const getWorkspaceContract = defineRouteContract({ }, }) +export const workspaceApiExecutionEntitlementSchema = z.object({ + /** Whether this workspace may run workflows programmatically (mirrors the server gate). */ + entitled: z.boolean(), +}) + +export type WorkspaceApiExecutionEntitlement = z.output< + typeof workspaceApiExecutionEntitlementSchema +> + +export const getWorkspaceApiExecutionEntitlementContract = defineRouteContract({ + method: 'GET', + path: '/api/workspaces/[id]/api-execution-entitlement', + params: workspaceParamsSchema, + response: { + mode: 'json', + schema: workspaceApiExecutionEntitlementSchema, + }, +}) + export const updateWorkspaceContract = defineRouteContract({ method: 'PATCH', path: '/api/workspaces/[id]', diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 2ca18013904..d82996804ca 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 From fff69b730be2fa56ea19619309da6fbb065ce63a Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 15 Jun 2026 10:31:57 -0700 Subject: [PATCH 2/4] 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 --- .../deploy/components/deploy-modal/deploy-modal.tsx | 9 ++++++--- apps/sim/hooks/queries/workspace.ts | 6 +++++- 2 files changed, 11 insertions(+), 4 deletions(-) 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 564b170d058..621792ceb04 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 @@ -160,10 +160,13 @@ export function DeployModal({ const { config: permissionConfig, isPublicApiDisabled } = usePermissionConfig() // Mirror the server gate: entitlement reflects the workspace's billed-account // plan (rolled up), not the viewer's individual plan, so a free member of a - // paid workspace isn't shown the upgrade wall. Undefined while loading keeps - // the gate closed (no flash); only an explicit `entitled === false` gates. + // paid workspace isn't shown the upgrade wall. Keyed on the URL `workspaceId` + // (the workflow's workspace, available on mount) rather than the workflow-map + // metadata, so the check fires immediately instead of leaving the tabs ungated + // until the map resolves. Undefined while loading keeps the gate closed (no + // flash); only an explicit `entitled === false` gates. const { data: apiExecutionEntitlement } = useWorkspaceApiExecutionEntitlement( - workflowWorkspaceId ?? undefined + workspaceId ?? undefined ) const gateProgrammaticDeploy = apiExecutionEntitlement?.entitled === false const { data: apiKeysData, isLoading: isLoadingKeys } = useApiKeys(workflowWorkspaceId || '') diff --git a/apps/sim/hooks/queries/workspace.ts b/apps/sim/hooks/queries/workspace.ts index 295f7b0c8e2..84d22ce5261 100644 --- a/apps/sim/hooks/queries/workspace.ts +++ b/apps/sim/hooks/queries/workspace.ts @@ -126,13 +126,17 @@ async function fetchWorkspaceApiExecutionEntitlement( * Whether the workspace may run workflows programmatically — the UI mirror of the * server gate. Reflects the workspace's billed-account plan, not the viewer's * individual plan, so a free member of a paid workspace isn't gated. + * + * `staleTime: 0` so reopening the deploy modal refetches: a plan upgrade happens + * outside this query's invalidation graph, and the cached value is shown while the + * background refetch resolves (no flash), so the gate self-heals on next open. */ export function useWorkspaceApiExecutionEntitlement(workspaceId?: string) { return useQuery({ queryKey: workspaceKeys.apiExecutionEntitlement(workspaceId ?? ''), queryFn: ({ signal }) => fetchWorkspaceApiExecutionEntitlement(workspaceId as string, signal), enabled: Boolean(workspaceId), - staleTime: 60 * 1000, + staleTime: 0, }) } From 950ee2d3313af41dfd1f7de9bb872389800be5cd Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 15 Jun 2026 11:10:57 -0700 Subject: [PATCH 3/4] refactor(billing): workspace owner access state instead of bespoke entitlement endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../route.test.ts | 43 +++++++------- .../route.ts | 22 +++---- .../components/deploy-modal/deploy-modal.tsx | 27 ++++----- apps/sim/hooks/queries/workspace.ts | 33 ++++++----- apps/sim/lib/api/contracts/workspaces.ts | 28 ++++++--- .../lib/billing/core/workspace-access.test.ts | 59 +++++++++++++++++++ apps/sim/lib/billing/core/workspace-access.ts | 56 ++++++++++++++++++ apps/sim/lib/billing/index.ts | 1 + 8 files changed, 196 insertions(+), 73 deletions(-) rename apps/sim/app/api/workspaces/[id]/{api-execution-entitlement => owner-billing}/route.test.ts (54%) rename apps/sim/app/api/workspaces/[id]/{api-execution-entitlement => owner-billing}/route.ts (50%) 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]/api-execution-entitlement/route.test.ts b/apps/sim/app/api/workspaces/[id]/owner-billing/route.test.ts similarity index 54% rename from apps/sim/app/api/workspaces/[id]/api-execution-entitlement/route.test.ts rename to apps/sim/app/api/workspaces/[id]/owner-billing/route.test.ts index 87661472e1a..2ae727ae92b 100644 --- a/apps/sim/app/api/workspaces/[id]/api-execution-entitlement/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/owner-billing/route.test.ts @@ -4,11 +4,11 @@ import { createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetSession, mockGetUserEntityPermissions, mockIsWorkspaceApiExecutionEntitled } = +const { mockGetSession, mockGetUserEntityPermissions, mockGetWorkspaceOwnerSubscriptionAccess } = vi.hoisted(() => ({ mockGetSession: vi.fn(), mockGetUserEntityPermissions: vi.fn(), - mockIsWorkspaceApiExecutionEntitled: vi.fn(), + mockGetWorkspaceOwnerSubscriptionAccess: vi.fn(), })) vi.mock('@/lib/auth', () => ({ @@ -20,14 +20,25 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ getUserEntityPermissions: mockGetUserEntityPermissions, })) -vi.mock('@/lib/billing/core/api-access', () => ({ - isWorkspaceApiExecutionEntitled: mockIsWorkspaceApiExecutionEntitled, +vi.mock('@/lib/billing/core/workspace-access', () => ({ + getWorkspaceOwnerSubscriptionAccess: mockGetWorkspaceOwnerSubscriptionAccess, })) -import { GET } from '@/app/api/workspaces/[id]/api-execution-entitlement/route' +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 }) } } @@ -38,40 +49,32 @@ async function callGet() { return { status: response.status, body: await response.json() } } -describe('GET /api/workspaces/[id]/api-execution-entitlement', () => { +describe('GET /api/workspaces/[id]/owner-billing', () => { beforeEach(() => { vi.clearAllMocks() mockGetSession.mockResolvedValue({ user: { id: 'u-1' } }) mockGetUserEntityPermissions.mockResolvedValue('read') - mockIsWorkspaceApiExecutionEntitled.mockResolvedValue(true) + mockGetWorkspaceOwnerSubscriptionAccess.mockResolvedValue(PAID_ACCESS) }) it('returns 401 when unauthenticated', async () => { mockGetSession.mockResolvedValue(null) const { status } = await callGet() expect(status).toBe(401) - expect(mockIsWorkspaceApiExecutionEntitled).not.toHaveBeenCalled() + 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(mockIsWorkspaceApiExecutionEntitled).not.toHaveBeenCalled() - }) - - it('returns entitled: true for an entitled workspace', async () => { - mockIsWorkspaceApiExecutionEntitled.mockResolvedValue(true) - const { status, body } = await callGet() - expect(status).toBe(200) - expect(body).toEqual({ entitled: true }) - expect(mockIsWorkspaceApiExecutionEntitled).toHaveBeenCalledWith(WORKSPACE_ID) + expect(mockGetWorkspaceOwnerSubscriptionAccess).not.toHaveBeenCalled() }) - it('returns entitled: false for a free workspace with the gate active', async () => { - mockIsWorkspaceApiExecutionEntitled.mockResolvedValue(false) + it('returns the workspace owner subscription access for a member', async () => { const { status, body } = await callGet() expect(status).toBe(200) - expect(body).toEqual({ entitled: false }) + expect(body).toEqual(PAID_ACCESS) + expect(mockGetWorkspaceOwnerSubscriptionAccess).toHaveBeenCalledWith(WORKSPACE_ID) }) }) diff --git a/apps/sim/app/api/workspaces/[id]/api-execution-entitlement/route.ts b/apps/sim/app/api/workspaces/[id]/owner-billing/route.ts similarity index 50% rename from apps/sim/app/api/workspaces/[id]/api-execution-entitlement/route.ts rename to apps/sim/app/api/workspaces/[id]/owner-billing/route.ts index 72b89ff79c5..6766c3351a4 100644 --- a/apps/sim/app/api/workspaces/[id]/api-execution-entitlement/route.ts +++ b/apps/sim/app/api/workspaces/[id]/owner-billing/route.ts @@ -1,20 +1,17 @@ -import { createLogger } from '@sim/logger' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' -import { getWorkspaceApiExecutionEntitlementContract } from '@/lib/api/contracts/workspaces' +import { getWorkspaceOwnerBillingContract } from '@/lib/api/contracts/workspaces' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' -import { isWorkspaceApiExecutionEntitled } from '@/lib/billing/core/api-access' +import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' -const logger = createLogger('WorkspaceApiExecutionEntitlementAPI') - /** - * Whether this workspace may run workflows programmatically — the UI mirror of - * the server gate (`isWorkspaceApiExecutionEntitled`). Lets the deploy modal - * reflect the workspace's billed-account plan instead of the viewer's individual - * plan, so a free member of a paid workspace isn't shown the upgrade wall. + * 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 }> }) => { @@ -23,7 +20,7 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const parsed = await parseRequest(getWorkspaceApiExecutionEntitlementContract, req, context) + const parsed = await parseRequest(getWorkspaceOwnerBillingContract, req, context) if (!parsed.success) return parsed.response const { id: workspaceId } = parsed.data.params @@ -32,8 +29,7 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Not found' }, { status: 404 }) } - const entitled = await isWorkspaceApiExecutionEntitled(workspaceId) - logger.info('Resolved workspace API-execution entitlement', { workspaceId, entitled }) - return NextResponse.json({ entitled }) + 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 621792ceb04..a5aecf29f12 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,10 +21,12 @@ import { ModalTabsList, ModalTabsTrigger, } from '@/components/emcn' +import { getSubscriptionAccessState } from '@/lib/billing/client' 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' import { CreateApiKeyModal } from '@/app/workspace/[workspaceId]/settings/components/api-keys/components' +import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation' import { releaseDeployAction, tryAcquireDeployAction, @@ -46,10 +48,7 @@ import { } from '@/hooks/queries/deployments' import { useWorkflowMcpServers } from '@/hooks/queries/workflow-mcp-servers' import { useWorkflowMap } from '@/hooks/queries/workflows' -import { - useWorkspaceApiExecutionEntitlement, - 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,17 +157,15 @@ export function DeployModal({ const userPermissions = useUserPermissionsContext() const canManageWorkspaceKeys = userPermissions.canAdmin const { config: permissionConfig, isPublicApiDisabled } = usePermissionConfig() - // Mirror the server gate: entitlement reflects the workspace's billed-account - // plan (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` - // (the workflow's workspace, available on mount) rather than the workflow-map - // metadata, so the check fires immediately instead of leaving the tabs ungated - // until the map resolves. Undefined while loading keeps the gate closed (no - // flash); only an explicit `entitled === false` gates. - const { data: apiExecutionEntitlement } = useWorkspaceApiExecutionEntitlement( - workspaceId ?? undefined - ) - const gateProgrammaticDeploy = apiExecutionEntitlement?.entitled === false + // 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). While + // the owner billing is loading the data is undefined → gate stays closed (no + // flash); only a resolved, non-paid owner gates. + const { data: ownerBilling } = useWorkspaceOwnerBilling(workspaceId ?? undefined) + const ownerAccess = getSubscriptionAccessState(ownerBilling) + const gateProgrammaticDeploy = + isBillingEnabled && !!ownerBilling && !ownerAccess.hasUsablePaidAccess 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 84d22ce5261..f91d769d1f2 100644 --- a/apps/sim/hooks/queries/workspace.ts +++ b/apps/sim/hooks/queries/workspace.ts @@ -6,16 +6,16 @@ import type { ContractBodyInput } from '@/lib/api/contracts' import { createWorkspaceContract, deleteWorkspaceContract, - getWorkspaceApiExecutionEntitlementContract, getWorkspaceContract, getWorkspaceMembersContract, + getWorkspaceOwnerBillingContract, getWorkspacePermissionsContract, listWorkspacesContract, updateWorkspaceContract, type Workspace, - type WorkspaceApiExecutionEntitlement, type WorkspaceCreationPolicy, type WorkspaceMember, + type WorkspaceOwnerBilling, type WorkspacePermissions, type WorkspaceQueryScope, type WorkspacesResponse, @@ -35,8 +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, - apiExecutionEntitlement: (id: string) => - [...workspaceKeys.detail(id), 'apiExecutionEntitlement'] 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, } @@ -112,29 +111,31 @@ export function useWorkspaceCreationPolicy(enabled = true) { }) } -async function fetchWorkspaceApiExecutionEntitlement( +async function fetchWorkspaceOwnerBilling( workspaceId: string, signal?: AbortSignal -): Promise { - return requestJson(getWorkspaceApiExecutionEntitlementContract, { +): Promise { + return requestJson(getWorkspaceOwnerBillingContract, { params: { id: workspaceId }, signal, }) } /** - * Whether the workspace may run workflows programmatically — the UI mirror of the - * server gate. Reflects the workspace's billed-account plan, not the viewer's - * individual plan, so a free member of a paid workspace isn't gated. + * 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 reopening the deploy modal refetches: a plan upgrade happens - * outside this query's invalidation graph, and the cached value is shown while the - * background refetch resolves (no flash), so the gate self-heals on next open. + * `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 useWorkspaceApiExecutionEntitlement(workspaceId?: string) { +export function useWorkspaceOwnerBilling(workspaceId?: string) { return useQuery({ - queryKey: workspaceKeys.apiExecutionEntitlement(workspaceId ?? ''), - queryFn: ({ signal }) => fetchWorkspaceApiExecutionEntitlement(workspaceId as string, signal), + queryKey: workspaceKeys.ownerBilling(workspaceId ?? ''), + queryFn: ({ signal }) => fetchWorkspaceOwnerBilling(workspaceId as string, signal), enabled: Boolean(workspaceId), staleTime: 0, }) diff --git a/apps/sim/lib/api/contracts/workspaces.ts b/apps/sim/lib/api/contracts/workspaces.ts index dcb910a5cf4..361004bdf25 100644 --- a/apps/sim/lib/api/contracts/workspaces.ts +++ b/apps/sim/lib/api/contracts/workspaces.ts @@ -181,22 +181,32 @@ export const getWorkspaceContract = defineRouteContract({ }, }) -export const workspaceApiExecutionEntitlementSchema = z.object({ - /** Whether this workspace may run workflows programmatically (mirrors the server gate). */ - entitled: z.boolean(), +/** + * 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 WorkspaceApiExecutionEntitlement = z.output< - typeof workspaceApiExecutionEntitlementSchema -> +export type WorkspaceOwnerBilling = z.output -export const getWorkspaceApiExecutionEntitlementContract = defineRouteContract({ +export const getWorkspaceOwnerBillingContract = defineRouteContract({ method: 'GET', - path: '/api/workspaces/[id]/api-execution-entitlement', + path: '/api/workspaces/[id]/owner-billing', params: workspaceParamsSchema, response: { mode: 'json', - schema: workspaceApiExecutionEntitlementSchema, + schema: workspaceOwnerBillingSchema, }, }) 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 00000000000..5ff234c9b73 --- /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 00000000000..db83bf8c8d4 --- /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 83a938f633c..eff8e32c75f 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 { From 8b85b762d4b365740b89c028637f9d046a2e0eaf Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 15 Jun 2026 11:21:55 -0700 Subject: [PATCH 4/4] 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. --- .../components/deploy-modal/deploy-modal.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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 a5aecf29f12..4f7cddaa134 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 { getSubscriptionAccessState } from '@/lib/billing/client' 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' @@ -159,13 +158,14 @@ export function DeployModal({ const { config: permissionConfig, isPublicApiDisabled } = usePermissionConfig() // 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). While - // the owner billing is loading the data is undefined → gate stays closed (no - // flash); only a resolved, non-paid owner gates. + // 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 ownerAccess = getSubscriptionAccessState(ownerBilling) - const gateProgrammaticDeploy = - isBillingEnabled && !!ownerBilling && !ownerAccess.hasUsablePaidAccess + const gateProgrammaticDeploy = isBillingEnabled && !!ownerBilling && !ownerBilling.isPaid const { data: apiKeysData, isLoading: isLoadingKeys } = useApiKeys(workflowWorkspaceId || '') const { data: workspaceSettingsData, isLoading: isLoadingSettings } = useWorkspaceSettings( workflowWorkspaceId || ''