Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions apps/sim/app/api/workspaces/[id]/owner-billing/route.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
35 changes: 35 additions & 0 deletions apps/sim/app/api/workspaces/[id]/owner-billing/route.ts
Original file line number Diff line number Diff line change
@@ -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)
}
)
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -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
Comment thread
TheodoreSpeaks marked this conversation as resolved.
const { data: apiKeysData, isLoading: isLoadingKeys } = useApiKeys(workflowWorkspaceId || '')
const { data: workspaceSettingsData, isLoading: isLoadingSettings } = useWorkspaceSettings(
workflowWorkspaceId || ''
Expand Down
33 changes: 33 additions & 0 deletions apps/sim/hooks/queries/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
}
Expand Down Expand Up @@ -108,6 +111,36 @@ export function useWorkspaceCreationPolicy(enabled = true) {
})
}

async function fetchWorkspaceOwnerBilling(
workspaceId: string,
signal?: AbortSignal
): Promise<WorkspaceOwnerBilling> {
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,
})
Comment thread
cursor[bot] marked this conversation as resolved.
}

type CreateWorkspaceParams = Pick<ContractBodyInput<typeof createWorkspaceContract>, 'name'>

/**
Expand Down
29 changes: 29 additions & 0 deletions apps/sim/lib/api/contracts/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof workspaceOwnerBillingSchema>

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]',
Expand Down
59 changes: 59 additions & 0 deletions apps/sim/lib/billing/core/workspace-access.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
56 changes: 56 additions & 0 deletions apps/sim/lib/billing/core/workspace-access.ts
Original file line number Diff line number Diff line change
@@ -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<WorkspaceOwnerSubscriptionAccess> {
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

Comment thread
TheodoreSpeaks marked this conversation as resolved.
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,
}
}
1 change: 1 addition & 0 deletions apps/sim/lib/billing/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions scripts/check-api-validation-contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading