Skip to content

Commit 18edc94

Browse files
fix(billing): deploy modal gates on workspace entitlement, not viewer plan (#5055)
* 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.
1 parent 0673e3c commit 18edc94

9 files changed

Lines changed: 306 additions & 10 deletions

File tree

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { createMockRequest } from '@sim/testing'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const { mockGetSession, mockGetUserEntityPermissions, mockGetWorkspaceOwnerSubscriptionAccess } =
8+
vi.hoisted(() => ({
9+
mockGetSession: vi.fn(),
10+
mockGetUserEntityPermissions: vi.fn(),
11+
mockGetWorkspaceOwnerSubscriptionAccess: vi.fn(),
12+
}))
13+
14+
vi.mock('@/lib/auth', () => ({
15+
auth: { api: { getSession: vi.fn() } },
16+
getSession: mockGetSession,
17+
}))
18+
19+
vi.mock('@/lib/workspaces/permissions/utils', () => ({
20+
getUserEntityPermissions: mockGetUserEntityPermissions,
21+
}))
22+
23+
vi.mock('@/lib/billing/core/workspace-access', () => ({
24+
getWorkspaceOwnerSubscriptionAccess: mockGetWorkspaceOwnerSubscriptionAccess,
25+
}))
26+
27+
import { GET } from '@/app/api/workspaces/[id]/owner-billing/route'
28+
29+
const WORKSPACE_ID = 'ws-1'
30+
31+
const PAID_ACCESS = {
32+
plan: 'team_25000',
33+
status: 'active',
34+
isPaid: true,
35+
isPro: false,
36+
isTeam: true,
37+
isEnterprise: false,
38+
isOrgScoped: true,
39+
organizationId: 'org-1',
40+
}
41+
42+
function buildParams() {
43+
return { params: Promise.resolve({ id: WORKSPACE_ID }) }
44+
}
45+
46+
async function callGet() {
47+
const request = createMockRequest('GET')
48+
const response = await GET(request, buildParams())
49+
return { status: response.status, body: await response.json() }
50+
}
51+
52+
describe('GET /api/workspaces/[id]/owner-billing', () => {
53+
beforeEach(() => {
54+
vi.clearAllMocks()
55+
mockGetSession.mockResolvedValue({ user: { id: 'u-1' } })
56+
mockGetUserEntityPermissions.mockResolvedValue('read')
57+
mockGetWorkspaceOwnerSubscriptionAccess.mockResolvedValue(PAID_ACCESS)
58+
})
59+
60+
it('returns 401 when unauthenticated', async () => {
61+
mockGetSession.mockResolvedValue(null)
62+
const { status } = await callGet()
63+
expect(status).toBe(401)
64+
expect(mockGetWorkspaceOwnerSubscriptionAccess).not.toHaveBeenCalled()
65+
})
66+
67+
it('returns 404 when the caller has no workspace access', async () => {
68+
mockGetUserEntityPermissions.mockResolvedValue(null)
69+
const { status } = await callGet()
70+
expect(status).toBe(404)
71+
expect(mockGetWorkspaceOwnerSubscriptionAccess).not.toHaveBeenCalled()
72+
})
73+
74+
it('returns the workspace owner subscription access for a member', async () => {
75+
const { status, body } = await callGet()
76+
expect(status).toBe(200)
77+
expect(body).toEqual(PAID_ACCESS)
78+
expect(mockGetWorkspaceOwnerSubscriptionAccess).toHaveBeenCalledWith(WORKSPACE_ID)
79+
})
80+
})
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import type { NextRequest } from 'next/server'
2+
import { NextResponse } from 'next/server'
3+
import { getWorkspaceOwnerBillingContract } from '@/lib/api/contracts/workspaces'
4+
import { parseRequest } from '@/lib/api/server'
5+
import { getSession } from '@/lib/auth'
6+
import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access'
7+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8+
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
9+
10+
/**
11+
* Subscription access state of the workspace's billed account — the workspace-
12+
* scoped counterpart to the viewer `/api/billing`. Lets the UI gate workspace
13+
* features (e.g. the deploy modal) on the owner's plan rather than the viewer's,
14+
* so a free member of a paid workspace isn't shown an upgrade wall.
15+
*/
16+
export const GET = withRouteHandler(
17+
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
18+
const session = await getSession()
19+
if (!session?.user?.id) {
20+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
21+
}
22+
23+
const parsed = await parseRequest(getWorkspaceOwnerBillingContract, req, context)
24+
if (!parsed.success) return parsed.response
25+
const { id: workspaceId } = parsed.data.params
26+
27+
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
28+
if (!permission) {
29+
return NextResponse.json({ error: 'Not found' }, { status: 404 })
30+
}
31+
32+
const ownerAccess = await getWorkspaceOwnerSubscriptionAccess(workspaceId)
33+
return NextResponse.json(ownerAccess)
34+
}
35+
)

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ import {
2121
ModalTabsList,
2222
ModalTabsTrigger,
2323
} from '@/components/emcn'
24-
import { isFree } from '@/lib/billing/plan-helpers'
2524
import { getBaseUrl } from '@/lib/core/utils/urls'
2625
import { getInputFormatExample as getInputFormatExampleUtil } from '@/lib/workflows/operations/deployment-utils'
2726
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
@@ -46,10 +45,9 @@ import {
4645
useDeployWorkflow,
4746
useUndeployWorkflow,
4847
} from '@/hooks/queries/deployments'
49-
import { useSubscriptionData } from '@/hooks/queries/subscription'
5048
import { useWorkflowMcpServers } from '@/hooks/queries/workflow-mcp-servers'
5149
import { useWorkflowMap } from '@/hooks/queries/workflows'
52-
import { useWorkspaceSettings } from '@/hooks/queries/workspace'
50+
import { useWorkspaceOwnerBilling, useWorkspaceSettings } from '@/hooks/queries/workspace'
5351
import { usePermissionConfig } from '@/hooks/use-permission-config'
5452
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
5553
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -158,11 +156,16 @@ export function DeployModal({
158156
const userPermissions = useUserPermissionsContext()
159157
const canManageWorkspaceKeys = userPermissions.canAdmin
160158
const { config: permissionConfig, isPublicApiDisabled } = usePermissionConfig()
161-
const { data: subscriptionData, isLoading: isLoadingSubscription } = useSubscriptionData()
162-
// Hold the gate closed until the plan is known — isFree(undefined) is true, so
163-
// gating during load would flash the upgrade wall at paid users.
164-
const gateProgrammaticDeploy =
165-
isBillingEnabled && !isLoadingSubscription && isFree(subscriptionData?.data?.plan)
159+
// Gate on the WORKSPACE owner's plan (billed account, rolled up), not the
160+
// viewer's individual plan, so a free member of a paid workspace isn't shown
161+
// the upgrade wall. Keyed on the URL `workspaceId` (available on mount). Uses
162+
// `isPaid` — the same check the server gate runs (any paid plan in an entitled
163+
// status, incl. `past_due`) — rather than `hasUsablePaidAccess`, which would
164+
// reject `past_due`/billing-blocked owners the API still allows. While loading
165+
// the data is undefined → gate stays closed (no flash); only a resolved,
166+
// non-paid owner gates.
167+
const { data: ownerBilling } = useWorkspaceOwnerBilling(workspaceId ?? undefined)
168+
const gateProgrammaticDeploy = isBillingEnabled && !!ownerBilling && !ownerBilling.isPaid
166169
const { data: apiKeysData, isLoading: isLoadingKeys } = useApiKeys(workflowWorkspaceId || '')
167170
const { data: workspaceSettingsData, isLoading: isLoadingSettings } = useWorkspaceSettings(
168171
workflowWorkspaceId || ''

apps/sim/hooks/queries/workspace.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@ import {
88
deleteWorkspaceContract,
99
getWorkspaceContract,
1010
getWorkspaceMembersContract,
11+
getWorkspaceOwnerBillingContract,
1112
getWorkspacePermissionsContract,
1213
listWorkspacesContract,
1314
updateWorkspaceContract,
1415
type Workspace,
1516
type WorkspaceCreationPolicy,
1617
type WorkspaceMember,
18+
type WorkspaceOwnerBilling,
1719
type WorkspacePermissions,
1820
type WorkspaceQueryScope,
1921
type WorkspacesResponse,
@@ -33,6 +35,7 @@ export const workspaceKeys = {
3335
settings: (id: string) => [...workspaceKeys.detail(id), 'settings'] as const,
3436
permissions: (id: string) => [...workspaceKeys.detail(id), 'permissions'] as const,
3537
members: (id: string) => [...workspaceKeys.detail(id), 'members'] as const,
38+
ownerBilling: (id: string) => [...workspaceKeys.detail(id), 'ownerBilling'] as const,
3639
adminLists: () => [...workspaceKeys.all, 'adminList'] as const,
3740
adminList: (userId: string | undefined) => [...workspaceKeys.adminLists(), userId ?? ''] as const,
3841
}
@@ -108,6 +111,36 @@ export function useWorkspaceCreationPolicy(enabled = true) {
108111
})
109112
}
110113

114+
async function fetchWorkspaceOwnerBilling(
115+
workspaceId: string,
116+
signal?: AbortSignal
117+
): Promise<WorkspaceOwnerBilling> {
118+
return requestJson(getWorkspaceOwnerBillingContract, {
119+
params: { id: workspaceId },
120+
signal,
121+
})
122+
}
123+
124+
/**
125+
* Subscription access state of the workspace's billed account (its owner's
126+
* rolled-up plan) — the workspace-scoped counterpart to `useSubscriptionData`.
127+
* Feed the result to `getSubscriptionAccessState` to gate workspace features on
128+
* the owner's plan rather than the viewer's, so a free member of a paid workspace
129+
* isn't gated.
130+
*
131+
* `staleTime: 0` so consumers (e.g. the deploy modal) refetch on mount: a plan
132+
* change happens outside this query's invalidation graph, and the cached value is
133+
* shown during the background refetch (no flash), so gates self-heal on reopen.
134+
*/
135+
export function useWorkspaceOwnerBilling(workspaceId?: string) {
136+
return useQuery({
137+
queryKey: workspaceKeys.ownerBilling(workspaceId ?? ''),
138+
queryFn: ({ signal }) => fetchWorkspaceOwnerBilling(workspaceId as string, signal),
139+
enabled: Boolean(workspaceId),
140+
staleTime: 0,
141+
})
142+
}
143+
111144
type CreateWorkspaceParams = Pick<ContractBodyInput<typeof createWorkspaceContract>, 'name'>
112145

113146
/**

apps/sim/lib/api/contracts/workspaces.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,35 @@ export const getWorkspaceContract = defineRouteContract({
181181
},
182182
})
183183

184+
/**
185+
* Subscription access fields of the workspace's billed account (its OWNER's
186+
* rolled-up plan) — the workspace-scoped counterpart to the viewer `/api/billing`
187+
* data. Feed to `getSubscriptionAccessState` to gate workspace features on the
188+
* owner's plan instead of the signed-in viewer's. No usage/credit data.
189+
*/
190+
export const workspaceOwnerBillingSchema = z.object({
191+
plan: z.string(),
192+
status: z.string().nullable(),
193+
isPaid: z.boolean(),
194+
isPro: z.boolean(),
195+
isTeam: z.boolean(),
196+
isEnterprise: z.boolean(),
197+
isOrgScoped: z.boolean(),
198+
organizationId: z.string().nullable(),
199+
})
200+
201+
export type WorkspaceOwnerBilling = z.output<typeof workspaceOwnerBillingSchema>
202+
203+
export const getWorkspaceOwnerBillingContract = defineRouteContract({
204+
method: 'GET',
205+
path: '/api/workspaces/[id]/owner-billing',
206+
params: workspaceParamsSchema,
207+
response: {
208+
mode: 'json',
209+
schema: workspaceOwnerBillingSchema,
210+
},
211+
})
212+
184213
export const updateWorkspaceContract = defineRouteContract({
185214
method: 'PATCH',
186215
path: '/api/workspaces/[id]',
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const { mockGetWorkspaceBilledAccountUserId, mockGetHighestPrioritySubscription } = vi.hoisted(
7+
() => ({
8+
mockGetWorkspaceBilledAccountUserId: vi.fn(),
9+
mockGetHighestPrioritySubscription: vi.fn(),
10+
})
11+
)
12+
13+
vi.mock('@/lib/workspaces/utils', () => ({
14+
getWorkspaceBilledAccountUserId: mockGetWorkspaceBilledAccountUserId,
15+
}))
16+
17+
vi.mock('@/lib/billing/core/subscription', () => ({
18+
getHighestPrioritySubscription: mockGetHighestPrioritySubscription,
19+
}))
20+
21+
import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access'
22+
23+
describe('getWorkspaceOwnerSubscriptionAccess', () => {
24+
beforeEach(() => {
25+
vi.clearAllMocks()
26+
mockGetWorkspaceBilledAccountUserId.mockResolvedValue('owner-1')
27+
})
28+
29+
it('reports paid + org-scoped for an org team plan billed to the owner', async () => {
30+
mockGetHighestPrioritySubscription.mockResolvedValue({
31+
plan: 'team_25000',
32+
status: 'active',
33+
referenceId: 'org-1',
34+
})
35+
const access = await getWorkspaceOwnerSubscriptionAccess('ws-1')
36+
expect(access).toMatchObject({
37+
plan: 'team_25000',
38+
isPaid: true,
39+
isTeam: true,
40+
isPro: false,
41+
isEnterprise: false,
42+
isOrgScoped: true,
43+
organizationId: 'org-1',
44+
})
45+
})
46+
47+
it('reports free when the billed account has no subscription', async () => {
48+
mockGetHighestPrioritySubscription.mockResolvedValue(null)
49+
const access = await getWorkspaceOwnerSubscriptionAccess('ws-1')
50+
expect(access).toMatchObject({ plan: 'free', isPaid: false, isOrgScoped: false })
51+
})
52+
53+
it('reports free when the workspace has no billed account', async () => {
54+
mockGetWorkspaceBilledAccountUserId.mockResolvedValue(null)
55+
const access = await getWorkspaceOwnerSubscriptionAccess('ws-1')
56+
expect(access.isPaid).toBe(false)
57+
expect(mockGetHighestPrioritySubscription).not.toHaveBeenCalled()
58+
})
59+
})
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
2+
import { isEnterprise, isPaid, isPro, isTeam } from '@/lib/billing/plan-helpers'
3+
import {
4+
hasPaidSubscriptionStatus,
5+
isOrgScopedSubscription,
6+
} from '@/lib/billing/subscriptions/utils'
7+
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
8+
9+
/**
10+
* The subscription access fields of a workspace's billed account, as a workspace-
11+
* scoped counterpart to the viewer's `/api/billing` data. Feed this to the
12+
* client `getSubscriptionAccessState` to derive `hasUsablePaidAccess` etc. for
13+
* the WORKSPACE (its owner's rolled-up plan), instead of the signed-in viewer's
14+
* individual plan — so a free member of a paid workspace isn't gated.
15+
*
16+
* Carries no usage/credit/Stripe data: safe to expose to any workspace member.
17+
*/
18+
export interface WorkspaceOwnerSubscriptionAccess {
19+
plan: string
20+
status: string | null
21+
isPaid: boolean
22+
isPro: boolean
23+
isTeam: boolean
24+
isEnterprise: boolean
25+
isOrgScoped: boolean
26+
organizationId: string | null
27+
}
28+
29+
/**
30+
* Resolves the workspace's billed account and returns its subscription access
31+
* fields (rolled up over org memberships). Mirrors the flag derivation in
32+
* `getSimplifiedBillingSummary` so the result matches the viewer `/api/billing`
33+
* shape for the owner.
34+
*/
35+
export async function getWorkspaceOwnerSubscriptionAccess(
36+
workspaceId: string
37+
): Promise<WorkspaceOwnerSubscriptionAccess> {
38+
const billedUserId = await getWorkspaceBilledAccountUserId(workspaceId)
39+
const subscription = billedUserId ? await getHighestPrioritySubscription(billedUserId) : null
40+
41+
const plan = subscription?.plan ?? 'free'
42+
const hasPaidEntitlement = hasPaidSubscriptionStatus(subscription?.status)
43+
const orgScoped =
44+
subscription && billedUserId ? isOrgScopedSubscription(subscription, billedUserId) : false
45+
46+
return {
47+
plan,
48+
status: subscription?.status ?? null,
49+
isPaid: hasPaidEntitlement && isPaid(plan),
50+
isPro: hasPaidEntitlement && isPro(plan),
51+
isTeam: hasPaidEntitlement && isTeam(plan),
52+
isEnterprise: hasPaidEntitlement && isEnterprise(plan),
53+
isOrgScoped: orgScoped,
54+
organizationId: orgScoped && subscription ? subscription.referenceId : null,
55+
}
56+
}

apps/sim/lib/billing/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export {
3030
getUserUsageLimit as getUsageLimit,
3131
updateUserUsageLimit as updateUsageLimit,
3232
} from '@/lib/billing/core/usage'
33+
export * from '@/lib/billing/core/workspace-access'
3334
export * from '@/lib/billing/credits/balance'
3435
export * from '@/lib/billing/credits/purchase'
3536
export {

scripts/check-api-validation-contracts.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries')
99
const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors')
1010

1111
const BASELINE = {
12-
totalRoutes: 827,
13-
zodRoutes: 827,
12+
totalRoutes: 828,
13+
zodRoutes: 828,
1414
nonZodRoutes: 0,
1515
} as const
1616

0 commit comments

Comments
 (0)