Skip to content

Commit 132447a

Browse files
fix(billing): gate on isBillingEnabled, not isHosted
The paywall should follow billing enforcement, not the hostname. Keying off isHosted would still 402 free users on a hosted deployment with BILLING_ENABLED unset. Switch the server gate (api-access, chat embed) to isBillingEnabled and the deploy-modal UI to the client NEXT_PUBLIC_BILLING_ENABLED flag (matching the Inbox paywall), so a billing-disabled deployment skips the gate entirely.
1 parent 57a0f47 commit 132447a

5 files changed

Lines changed: 26 additions & 26 deletions

File tree

apps/sim/app/api/chat/utils.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ const {
3131
mockGetSession: vi.fn(),
3232
mockCheckRateLimitDirect: vi.fn().mockResolvedValue({ allowed: true }),
3333
mockIsWorkspaceApiExecutionEntitled: vi.fn().mockResolvedValue(true),
34-
flagState: { isHosted: false },
34+
flagState: { isBillingEnabled: false },
3535
}))
3636

3737
vi.mock('@/lib/billing/core/api-access', () => ({
@@ -77,8 +77,8 @@ vi.mock('@/lib/core/security/deployment', () => ({
7777
vi.mock('@/lib/core/config/feature-flags', () => ({
7878
isDev: true,
7979
isProd: false,
80-
get isHosted() {
81-
return flagState.isHosted
80+
get isBillingEnabled() {
81+
return flagState.isBillingEnabled
8282
},
8383
}))
8484

@@ -474,7 +474,7 @@ describe('Chat API Utils', () => {
474474
describe('assertChatEmbedAllowed', () => {
475475
beforeEach(() => {
476476
vi.clearAllMocks()
477-
flagState.isHosted = true
477+
flagState.isBillingEnabled = true
478478
mockIsWorkspaceApiExecutionEntitled.mockResolvedValue(true)
479479
})
480480

@@ -509,8 +509,8 @@ describe('assertChatEmbedAllowed', () => {
509509
expect(mockIsWorkspaceApiExecutionEntitled).not.toHaveBeenCalled()
510510
})
511511

512-
it('is a no-op on self-hosted', async () => {
513-
flagState.isHosted = false
512+
it('is a no-op when billing is disabled', async () => {
513+
flagState.isBillingEnabled = false
514514
const res = await assertChatEmbedAllowed(
515515
chatRequest('https://evil.example.com'),
516516
'wf-1',

apps/sim/app/api/chat/utils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { and, eq, isNull } from 'drizzle-orm'
77
import type { NextRequest, NextResponse } from 'next/server'
88
import { isWorkspaceApiExecutionEntitled } from '@/lib/billing/core/api-access'
99
import { getEnv } from '@/lib/core/config/env'
10-
import { isHosted } from '@/lib/core/config/feature-flags'
10+
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
1111
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
1212
import { RateLimiter } from '@/lib/core/rate-limiter'
1313
import {
@@ -70,7 +70,7 @@ export async function assertChatEmbedAllowed(
7070
workflowId: string,
7171
requestId: string
7272
): Promise<NextResponse | null> {
73-
if (!isHosted) return null
73+
if (!isBillingEnabled) return null
7474

7575
const origin = request.headers.get('origin')
7676
if (!origin || isFirstPartyOrigin(origin)) return null

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,11 @@ import {
2222
ModalTabsTrigger,
2323
} from '@/components/emcn'
2424
import { isFree } from '@/lib/billing/plan-helpers'
25-
import { isHosted } from '@/lib/core/config/feature-flags'
2625
import { getBaseUrl } from '@/lib/core/utils/urls'
2726
import { getInputFormatExample as getInputFormatExampleUtil } from '@/lib/workflows/operations/deployment-utils'
2827
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
2928
import { CreateApiKeyModal } from '@/app/workspace/[workspaceId]/settings/components/api-keys/components'
29+
import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
3030
import {
3131
releaseDeployAction,
3232
tryAcquireDeployAction,
@@ -162,7 +162,7 @@ export function DeployModal({
162162
// Hold the gate closed until the plan is known — isFree(undefined) is true, so
163163
// gating during load would flash the upgrade wall at paid users.
164164
const gateProgrammaticDeploy =
165-
isHosted && !isLoadingSubscription && isFree(subscriptionData?.data?.plan)
165+
isBillingEnabled && !isLoadingSubscription && isFree(subscriptionData?.data?.plan)
166166
const { data: apiKeysData, isLoading: isLoadingKeys } = useApiKeys(workflowWorkspaceId || '')
167167
const { data: workspaceSettingsData, isLoading: isLoadingSettings } = useWorkspaceSettings(
168168
workflowWorkspaceId || ''

apps/sim/lib/billing/core/api-access.test.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,16 @@
33
*/
44
import { beforeEach, describe, expect, it, vi } from 'vitest'
55

6-
const { mockGetHighestPrioritySubscription, mockGetWorkspaceBilledAccountUserId, hostedState } =
6+
const { mockGetHighestPrioritySubscription, mockGetWorkspaceBilledAccountUserId, billingState } =
77
vi.hoisted(() => ({
88
mockGetHighestPrioritySubscription: vi.fn(),
99
mockGetWorkspaceBilledAccountUserId: vi.fn(),
10-
hostedState: { isHosted: true },
10+
billingState: { isBillingEnabled: true },
1111
}))
1212

1313
vi.mock('@/lib/core/config/feature-flags', () => ({
14-
get isHosted() {
15-
return hostedState.isHosted
14+
get isBillingEnabled() {
15+
return billingState.isBillingEnabled
1616
},
1717
}))
1818

@@ -32,7 +32,7 @@ import {
3232
describe('isApiExecutionEntitled', () => {
3333
beforeEach(() => {
3434
vi.clearAllMocks()
35-
hostedState.isHosted = true
35+
billingState.isBillingEnabled = true
3636
})
3737

3838
it('is false for a free plan', async () => {
@@ -54,7 +54,7 @@ describe('isApiExecutionEntitled', () => {
5454
)
5555

5656
it('is true on self-hosted regardless of plan, without a subscription lookup', async () => {
57-
hostedState.isHosted = false
57+
billingState.isBillingEnabled = false
5858
expect(await isApiExecutionEntitled('user-1')).toBe(true)
5959
expect(mockGetHighestPrioritySubscription).not.toHaveBeenCalled()
6060
})
@@ -68,7 +68,7 @@ describe('isApiExecutionEntitled', () => {
6868
describe('isWorkspaceApiExecutionEntitled', () => {
6969
beforeEach(() => {
7070
vi.clearAllMocks()
71-
hostedState.isHosted = true
71+
billingState.isBillingEnabled = true
7272
})
7373

7474
it('is false when the workspace billed account is free', async () => {
@@ -84,7 +84,7 @@ describe('isWorkspaceApiExecutionEntitled', () => {
8484
})
8585

8686
it('skips the billed-account lookup on self-hosted', async () => {
87-
hostedState.isHosted = false
87+
billingState.isBillingEnabled = false
8888
expect(await isWorkspaceApiExecutionEntitled('ws-1')).toBe(true)
8989
expect(mockGetWorkspaceBilledAccountUserId).not.toHaveBeenCalled()
9090
})

apps/sim/lib/billing/core/api-access.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
22
import { isPaid } from '@/lib/billing/plan-helpers'
3-
import { isHosted } from '@/lib/core/config/feature-flags'
3+
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
44
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
55

66
/**
@@ -11,29 +11,29 @@ export const API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE =
1111
'Programmatic workflow execution requires a paid plan. Upgrade to Pro or higher to use the API.'
1212

1313
/**
14-
* Whether `userId` may run workflows programmatically. Always allowed on
15-
* self-hosted (no billing) and when no user is resolved; on hosted, requires a
16-
* paid plan.
14+
* Whether `userId` may run workflows programmatically. Always allowed when
15+
* billing enforcement is off (self-hosted / `BILLING_ENABLED` unset) and when no
16+
* user is resolved; otherwise requires a paid plan.
1717
*
1818
* `getHighestPrioritySubscription` rolls up organization memberships, so a free
1919
* individual belonging to a paid org/workspace is entitled.
2020
*/
2121
export async function isApiExecutionEntitled(userId: string | undefined): Promise<boolean> {
22-
if (!isHosted || !userId) return true
22+
if (!isBillingEnabled || !userId) return true
2323

2424
const subscription = await getHighestPrioritySubscription(userId)
2525
return isPaid(subscription?.plan)
2626
}
2727

2828
/**
2929
* Workspace-scoped variant of {@link isApiExecutionEntitled} that gates on the
30-
* workspace's billed account. Short-circuits on self-hosted before any DB
31-
* lookup, so the billed-account query only runs on hosted.
30+
* workspace's billed account. Short-circuits when billing is off before any DB
31+
* lookup, so the billed-account query only runs when billing is enforced.
3232
*/
3333
export async function isWorkspaceApiExecutionEntitled(
3434
workspaceId: string | undefined
3535
): Promise<boolean> {
36-
if (!isHosted || !workspaceId) return true
36+
if (!isBillingEnabled || !workspaceId) return true
3737

3838
const billedUserId = await getWorkspaceBilledAccountUserId(workspaceId)
3939
return isApiExecutionEntitled(billedUserId ?? undefined)

0 commit comments

Comments
 (0)