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