Skip to content

Commit b4f027e

Browse files
fix(billing): point self-hosted upgrade CTAs at the hosted app (#6003)
* fix(billing): point self-hosted upgrade CTAs at the hosted app Self-hosted Chat bills against the sim.ai account behind COPILOT_API_KEY, but its 402 upgrade card linked to local billing settings that a self-hosted deployment does not have. Point those CTAs at the hosted app instead, and drop the local workspace-role gate that could hide the CTA from the only person able to act on it. Adds /upgrade, an account-scoped entry for callers that cannot know a workspace id. It delegates to /workspace?redirect=upgrade rather than re-deriving workspace resolution, inheriting local recency, stale-session recovery, and the no-workspace creation policy. * fix(billing): keep the upgrade intent through workspace creation A first-time visitor to /upgrade has no workspace to resolve, so /workspace creates one — and then hardcoded a redirect to home, silently dropping the upgrade intent. Route both exits through one destination helper so the created workspace lands on the plan picker with its reason intact.
1 parent e98715d commit b4f027e

7 files changed

Lines changed: 134 additions & 27 deletions

File tree

apps/sim/app/upgrade/page.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { redirect } from 'next/navigation'
2+
import { getSession } from '@/lib/auth'
3+
import { isUpgradeReason, UPGRADE_REASON_PARAM } from '@/lib/billing/upgrade-reasons'
4+
5+
/**
6+
* Public upgrade entry, for callers that cannot know a workspace id — a
7+
* self-hosted deployment linking its users to the hosted plans, or an email
8+
* that predates a workspace switch.
9+
*
10+
* Workspace resolution is not repeated here: `/workspace` already owns it,
11+
* including local recency, last-active fallback, stale-session recovery, and
12+
* the no-workspace creation policy.
13+
*/
14+
export default async function UpgradePage({
15+
searchParams,
16+
}: {
17+
searchParams: Promise<Record<string, string | string[] | undefined>>
18+
}) {
19+
const [session, params] = await Promise.all([getSession(), searchParams])
20+
21+
const rawReason = params[UPGRADE_REASON_PARAM]
22+
const reasonValue = Array.isArray(rawReason) ? rawReason[0] : rawReason
23+
const reason = isUpgradeReason(reasonValue) ? reasonValue : undefined
24+
25+
const target = reason
26+
? `/workspace?redirect=upgrade&${UPGRADE_REASON_PARAM}=${reason}`
27+
: '/workspace?redirect=upgrade'
28+
29+
// `/workspace` recovers a signed-out visitor by hard-navigating to `/login`
30+
// with no callback, which would drop the upgrade intent — carry it here
31+
// instead, where the destination is still known.
32+
if (!session?.user) {
33+
redirect(`/login?callbackUrl=${encodeURIComponent(target)}`)
34+
}
35+
36+
redirect(target)
37+
}

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,19 @@ import {
1010
ExpandableContent,
1111
SecretInput,
1212
SecretReveal,
13+
SquareArrowUpRight,
1314
Tooltip,
1415
toast,
1516
} from '@sim/emcn'
1617
import { Cursor, TerminalWindow } from '@sim/emcn/icons'
1718
import { useParams } from 'next/navigation'
1819
import { ThinkingLoader } from '@/components/ui'
1920
import { useSession } from '@/lib/auth/auth-client'
21+
import { buildHostedUpgradeUrl, HOSTED_BILLING_SETTINGS_URL } from '@/lib/billing/upgrade-reasons'
2022
import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions'
2123
import { isBrowserAgentAvailable, sendBrowserPanelAction } from '@/lib/browser-agent/transport'
2224
import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils'
25+
import { isHosted } from '@/lib/core/config/env-flags'
2326
import { isSafeHttpUrl } from '@/lib/core/utils/urls'
2427
import { getDesktopBridge } from '@/lib/desktop'
2528
import {
@@ -1846,9 +1849,16 @@ function UsageUpgradeDisplay({ data }: { data: UsageUpgradeTagData }) {
18461849
const { data: session } = useSession()
18471850
const hostContext = useWorkspaceHostContext()
18481851
const { getSettingsHref } = useSettingsNavigation()
1849-
const settingsPath = getSettingsHref({ section: 'billing' })
18501852
const buttonLabel = data.action === 'upgrade_plan' ? 'Upgrade Plan' : 'Increase Limit'
1851-
const canManageBilling = canManageWorkspaceBilling(hostContext, session?.user?.id)
1853+
1854+
// Self-hosted plan and limit both live on the hosted account, so local
1855+
// workspace billing roles say nothing about who may change them.
1856+
const href = isHosted
1857+
? getSettingsHref({ section: 'billing' })
1858+
: data.action === 'upgrade_plan'
1859+
? buildHostedUpgradeUrl()
1860+
: HOSTED_BILLING_SETTINGS_URL
1861+
const canManageBilling = !isHosted || canManageWorkspaceBilling(hostContext, session?.user?.id)
18521862
const unavailableMessage = hostContext.hostOrganizationId
18531863
? 'Contact an organization admin to manage this workspace’s usage limits.'
18541864
: 'Only the workspace owner can manage this workspace’s usage limits.'
@@ -1880,11 +1890,14 @@ function UsageUpgradeDisplay({ data }: { data: UsageUpgradeTagData }) {
18801890
</p>
18811891
{canManageBilling ? (
18821892
<a
1883-
href={settingsPath}
1893+
href={href}
1894+
target={isHosted ? undefined : '_blank'}
1895+
rel={isHosted ? undefined : 'noopener noreferrer'}
1896+
aria-label={isHosted ? undefined : `${buttonLabel} (opens in a new tab)`}
18841897
className='mt-2 inline-flex items-center gap-1 font-[500] text-amber-700 text-small underline decoration-dashed underline-offset-2 transition-colors hover-hover:text-amber-900 dark:text-amber-300 dark:hover-hover:text-amber-200'
18851898
>
18861899
{buttonLabel}
1887-
<ArrowRight className='size-3' />
1900+
{isHosted ? <ArrowRight className='size-3' /> : <SquareArrowUpRight className='size-3' />}
18881901
</a>
18891902
) : (
18901903
<p className='mt-2 font-[500] text-amber-700 text-small dark:text-amber-300'>

apps/sim/app/workspace/[workspaceId]/upgrade/page.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,37 @@
11
import { Suspense } from 'react'
22
import type { Metadata } from 'next'
3+
import { redirect } from 'next/navigation'
4+
import {
5+
buildHostedUpgradeUrl,
6+
isUpgradeReason,
7+
UPGRADE_REASON_PARAM,
8+
} from '@/lib/billing/upgrade-reasons'
9+
import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags'
310
import { Upgrade } from '@/app/workspace/[workspaceId]/upgrade/upgrade'
411

512
export const metadata: Metadata = { title: 'Upgrade' }
613

714
export default async function UpgradePage({
815
params,
16+
searchParams,
917
}: {
1018
params: Promise<{ workspaceId: string }>
19+
searchParams: Promise<Record<string, string | string[] | undefined>>
1120
}) {
12-
const { workspaceId } = await params
21+
const [{ workspaceId }, query] = await Promise.all([params, searchParams])
22+
23+
// Both are build constants, so resolve them here rather than mounting a page
24+
// whose only job would be to navigate away.
25+
if (!isHosted) {
26+
const rawReason = query[UPGRADE_REASON_PARAM]
27+
const reasonValue = Array.isArray(rawReason) ? rawReason[0] : rawReason
28+
redirect(buildHostedUpgradeUrl(isUpgradeReason(reasonValue) ? reasonValue : undefined))
29+
}
30+
31+
if (!isBillingEnabled) {
32+
redirect(`/workspace/${workspaceId}/home`)
33+
}
34+
1335
return (
1436
<Suspense fallback={<div className='h-full bg-[var(--bg)]' />}>
1537
<Upgrade workspaceId={workspaceId} />

apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import {
1616
import { ANNUAL_DISCOUNT_RATE } from '@/lib/billing/constants'
1717
import { DEFAULT_UPGRADE_HEADER, UPGRADE_REASON_COPY } from '@/lib/billing/upgrade-reasons'
1818
import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions'
19-
import { isBillingEnabled } from '@/lib/core/config/env-flags'
2019
import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
2120
import {
2221
BillingPeriodToggle,
@@ -73,23 +72,16 @@ export function Upgrade({ workspaceId }: UpgradeProps) {
7372
router.replace(origin ?? `/workspace/${workspaceId}/home`)
7473
}, [origin, router, workspaceId])
7574

76-
// Enterprise manages billing out-of-band, and self-hosted deployments with
77-
// billing disabled have no plans to surface — redirect to home in both cases.
75+
// Enterprise manages billing out-of-band, so there is no plan to pick here.
76+
// The self-hosted and billing-disabled cases are build constants, not reactive
77+
// state — page.tsx resolves those before this ever mounts.
7878
useEffect(() => {
79-
if (!isBillingEnabled) {
80-
router.replace(`/workspace/${workspaceId}/home`)
81-
return
82-
}
8379
if (canManageBilling && !state.isLoading && state.subscription.isEnterprise) {
8480
router.replace(`/workspace/${workspaceId}/home`)
8581
}
8682
}, [canManageBilling, state.isLoading, state.subscription.isEnterprise, router, workspaceId])
8783

88-
if (
89-
!isBillingEnabled ||
90-
state.isLoading ||
91-
(canManageBilling && state.subscription.isEnterprise)
92-
) {
84+
if (state.isLoading || (canManageBilling && state.subscription.isEnterprise)) {
9385
return null
9486
}
9587

apps/sim/app/workspace/page.tsx

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ import { getWorkflowStateContract } from '@/lib/api/contracts/workflows'
1111
import { createWorkspaceContract } from '@/lib/api/contracts/workspaces'
1212
import { useSession } from '@/lib/auth/auth-client'
1313
import { recoverFromStaleSession } from '@/lib/auth/stale-session-recovery'
14+
import {
15+
buildUpgradeHref,
16+
isUpgradeReason,
17+
UPGRADE_REASON_PARAM,
18+
} from '@/lib/billing/upgrade-reasons'
1419
import { WorkspaceRecencyStorage } from '@/lib/core/utils/browser-storage'
1520
import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar'
1621
import { useWorkspacesWithMetadata } from '@/hooks/queries/workspace'
@@ -115,6 +120,20 @@ export default function WorkspacePage() {
115120

116121
if (isWorkspacesLoading || workspacesError || !data) return
117122

123+
const urlParams = new URLSearchParams(window.location.search)
124+
const redirectWorkflowId = urlParams.get('redirect_workflow')
125+
const redirectTarget = urlParams.get('redirect')
126+
const rawReason = urlParams.get(UPGRADE_REASON_PARAM)
127+
128+
// `?redirect=upgrade` is how a caller that cannot know a workspace id — a
129+
// self-hosted deployment, an email — reaches the plan picker. It has to
130+
// survive workspace creation too: a first-time visitor has no workspace to
131+
// resolve, and dropping the intent lands them on home with no explanation.
132+
const destinationFor = (id: string) =>
133+
redirectTarget === 'upgrade'
134+
? buildUpgradeHref(id, isUpgradeReason(rawReason) ? rawReason : undefined)
135+
: `/workspace/${id}/home`
136+
118137
const { workspaces, lastActiveWorkspaceId, creationPolicy } = data
119138

120139
if (workspaces.length === 0) {
@@ -135,15 +154,12 @@ export default function WorkspacePage() {
135154
return
136155
}
137156
hasRedirectedRef.current = true
138-
handleNoWorkspaces(router, () => setRecoveryFailed(true))
157+
handleNoWorkspaces(router, () => setRecoveryFailed(true), destinationFor)
139158
return
140159
}
141160

142161
hasRedirectedRef.current = true
143162

144-
const urlParams = new URLSearchParams(window.location.search)
145-
const redirectWorkflowId = urlParams.get('redirect_workflow')
146-
147163
const localRecentId = WorkspaceRecencyStorage.getMostRecent()
148164
const findWorkspace = (id: string | null) =>
149165
id ? workspaces.find((w) => w.id === id) : undefined
@@ -157,7 +173,7 @@ export default function WorkspacePage() {
157173
}
158174

159175
logger.info(`Redirecting to workspace: ${targetWorkspace.id}`)
160-
router.replace(`/workspace/${targetWorkspace.id}/home`)
176+
router.replace(destinationFor(targetWorkspace.id))
161177
}, [session, isSessionPending, sessionError, isWorkspacesLoading, workspacesError, data, router])
162178

163179
const blockedPolicy =
@@ -242,7 +258,8 @@ async function handleWorkflowRedirect(
242258

243259
async function handleNoWorkspaces(
244260
router: ReturnType<typeof useRouter>,
245-
onUnrecoverable: () => void
261+
onUnrecoverable: () => void,
262+
destinationFor: (workspaceId: string) => string
246263
): Promise<void> {
247264
logger.warn('No workspaces found, creating default workspace')
248265
try {
@@ -252,7 +269,7 @@ async function handleNoWorkspaces(
252269
if (data.workspace?.id) {
253270
logger.info(`Created default workspace: ${data.workspace.id}`)
254271
sessionStorage.removeItem(WORKSPACE_RACE_RETRY_KEY)
255-
router.replace(`/workspace/${data.workspace.id}/home`)
272+
router.replace(destinationFor(data.workspace.id))
256273
return
257274
}
258275
logger.error('Failed to create default workspace')

apps/sim/lib/billing/upgrade-reasons.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
*/
44
import { describe, expect, it } from 'vitest'
55
import {
6+
buildHostedUpgradeUrl,
67
buildUpgradeHref,
8+
HOSTED_BILLING_SETTINGS_URL,
79
isUpgradeReason,
810
UPGRADE_REASON_COPY,
911
UPGRADE_REASONS,
@@ -31,6 +33,12 @@ describe('upgrade-reasons', () => {
3133
expect(buildUpgradeHref('ws-1', 'tables')).toBe('/workspace/ws-1/upgrade?reason=tables')
3234
})
3335

36+
it('builds absolute hosted URLs for self-hosted deployments', () => {
37+
expect(buildHostedUpgradeUrl()).toBe('https://www.sim.ai/upgrade')
38+
expect(buildHostedUpgradeUrl('credits')).toBe('https://www.sim.ai/upgrade?reason=credits')
39+
expect(HOSTED_BILLING_SETTINGS_URL).toBe('https://www.sim.ai/account/settings/billing')
40+
})
41+
3442
it('guards known reasons', () => {
3543
expect(isUpgradeReason('storage')).toBe(true)
3644
expect(isUpgradeReason('seats')).toBe(true)

apps/sim/lib/billing/upgrade-reasons.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22
* Upgrade-reason registry.
33
*
44
* Single source of truth for the language shown when a user is routed to the
5-
* upgrade page after hitting a usage limit. The same copy drives both the
6-
* upgrade-page header and the threshold/limit emails, so the in-app and email
7-
* journeys never drift apart.
5+
* upgrade page after hitting a usage limit, and for where that route points.
6+
* The same copy drives both the upgrade-page header and the threshold/limit
7+
* emails, so the in-app and email journeys never drift apart.
88
*/
9+
import { SITE_URL } from '@/lib/core/utils/urls'
910

1011
/** The limit categories that can route a user to the upgrade page. */
1112
export const UPGRADE_REASONS = ['credits', 'storage', 'tables', 'seats'] as const
@@ -86,3 +87,20 @@ export function buildUpgradeHref(workspaceId: string, reason?: UpgradeReason): s
8687
const base = `/workspace/${workspaceId}/upgrade`
8788
return reason ? `${base}?${UPGRADE_REASON_PARAM}=${reason}` : base
8889
}
90+
91+
/**
92+
* Absolute upgrade URL on the hosted app.
93+
*
94+
* Self-hosted deployments talk to Chat through a Chat key issued by the user's
95+
* sim.ai account, so their plan and credits live there rather than on the local
96+
* instance. A local workspace id is meaningless on the hosted app, so this
97+
* points at the account-scoped `/upgrade` entry, which resolves the signed-in
98+
* user's own workspace.
99+
*/
100+
export function buildHostedUpgradeUrl(reason?: UpgradeReason): string {
101+
const base = `${SITE_URL}/upgrade`
102+
return reason ? `${base}?${UPGRADE_REASON_PARAM}=${reason}` : base
103+
}
104+
105+
/** Account billing settings on the hosted app, for raising a usage limit. */
106+
export const HOSTED_BILLING_SETTINGS_URL = `${SITE_URL}/account/settings/billing` as const

0 commit comments

Comments
 (0)