diff --git a/apps/sim/app/upgrade/page.tsx b/apps/sim/app/upgrade/page.tsx
new file mode 100644
index 00000000000..aa9a9263ee0
--- /dev/null
+++ b/apps/sim/app/upgrade/page.tsx
@@ -0,0 +1,37 @@
+import { redirect } from 'next/navigation'
+import { getSession } from '@/lib/auth'
+import { isUpgradeReason, UPGRADE_REASON_PARAM } from '@/lib/billing/upgrade-reasons'
+
+/**
+ * Public upgrade entry, for callers that cannot know a workspace id — a
+ * self-hosted deployment linking its users to the hosted plans, or an email
+ * that predates a workspace switch.
+ *
+ * Workspace resolution is not repeated here: `/workspace` already owns it,
+ * including local recency, last-active fallback, stale-session recovery, and
+ * the no-workspace creation policy.
+ */
+export default async function UpgradePage({
+ searchParams,
+}: {
+ searchParams: Promise>
+}) {
+ const [session, params] = await Promise.all([getSession(), searchParams])
+
+ const rawReason = params[UPGRADE_REASON_PARAM]
+ const reasonValue = Array.isArray(rawReason) ? rawReason[0] : rawReason
+ const reason = isUpgradeReason(reasonValue) ? reasonValue : undefined
+
+ const target = reason
+ ? `/workspace?redirect=upgrade&${UPGRADE_REASON_PARAM}=${reason}`
+ : '/workspace?redirect=upgrade'
+
+ // `/workspace` recovers a signed-out visitor by hard-navigating to `/login`
+ // with no callback, which would drop the upgrade intent — carry it here
+ // instead, where the destination is still known.
+ if (!session?.user) {
+ redirect(`/login?callbackUrl=${encodeURIComponent(target)}`)
+ }
+
+ redirect(target)
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx
index 2dd4e87562d..36353eafa38 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx
@@ -10,6 +10,7 @@ import {
ExpandableContent,
SecretInput,
SecretReveal,
+ SquareArrowUpRight,
Tooltip,
toast,
} from '@sim/emcn'
@@ -17,9 +18,11 @@ import { Cursor, TerminalWindow } from '@sim/emcn/icons'
import { useParams } from 'next/navigation'
import { ThinkingLoader } from '@/components/ui'
import { useSession } from '@/lib/auth/auth-client'
+import { buildHostedUpgradeUrl, HOSTED_BILLING_SETTINGS_URL } from '@/lib/billing/upgrade-reasons'
import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions'
import { isBrowserAgentAvailable, sendBrowserPanelAction } from '@/lib/browser-agent/transport'
import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils'
+import { isHosted } from '@/lib/core/config/env-flags'
import { isSafeHttpUrl } from '@/lib/core/utils/urls'
import { getDesktopBridge } from '@/lib/desktop'
import {
@@ -1846,9 +1849,16 @@ function UsageUpgradeDisplay({ data }: { data: UsageUpgradeTagData }) {
const { data: session } = useSession()
const hostContext = useWorkspaceHostContext()
const { getSettingsHref } = useSettingsNavigation()
- const settingsPath = getSettingsHref({ section: 'billing' })
const buttonLabel = data.action === 'upgrade_plan' ? 'Upgrade Plan' : 'Increase Limit'
- const canManageBilling = canManageWorkspaceBilling(hostContext, session?.user?.id)
+
+ // Self-hosted plan and limit both live on the hosted account, so local
+ // workspace billing roles say nothing about who may change them.
+ const href = isHosted
+ ? getSettingsHref({ section: 'billing' })
+ : data.action === 'upgrade_plan'
+ ? buildHostedUpgradeUrl()
+ : HOSTED_BILLING_SETTINGS_URL
+ const canManageBilling = !isHosted || canManageWorkspaceBilling(hostContext, session?.user?.id)
const unavailableMessage = hostContext.hostOrganizationId
? 'Contact an organization admin to manage this workspace’s usage limits.'
: 'Only the workspace owner can manage this workspace’s usage limits.'
@@ -1880,11 +1890,14 @@ function UsageUpgradeDisplay({ data }: { data: UsageUpgradeTagData }) {
{canManageBilling ? (
{buttonLabel}
-
+ {isHosted ? : }
) : (
diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/page.tsx b/apps/sim/app/workspace/[workspaceId]/upgrade/page.tsx
index af1e0f80f1e..d86ed09d2a2 100644
--- a/apps/sim/app/workspace/[workspaceId]/upgrade/page.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/upgrade/page.tsx
@@ -1,15 +1,37 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
+import { redirect } from 'next/navigation'
+import {
+ buildHostedUpgradeUrl,
+ isUpgradeReason,
+ UPGRADE_REASON_PARAM,
+} from '@/lib/billing/upgrade-reasons'
+import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags'
import { Upgrade } from '@/app/workspace/[workspaceId]/upgrade/upgrade'
export const metadata: Metadata = { title: 'Upgrade' }
export default async function UpgradePage({
params,
+ searchParams,
}: {
params: Promise<{ workspaceId: string }>
+ searchParams: Promise>
}) {
- const { workspaceId } = await params
+ const [{ workspaceId }, query] = await Promise.all([params, searchParams])
+
+ // Both are build constants, so resolve them here rather than mounting a page
+ // whose only job would be to navigate away.
+ if (!isHosted) {
+ const rawReason = query[UPGRADE_REASON_PARAM]
+ const reasonValue = Array.isArray(rawReason) ? rawReason[0] : rawReason
+ redirect(buildHostedUpgradeUrl(isUpgradeReason(reasonValue) ? reasonValue : undefined))
+ }
+
+ if (!isBillingEnabled) {
+ redirect(`/workspace/${workspaceId}/home`)
+ }
+
return (
}>
diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx b/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx
index 20e698d4a2c..547008947c2 100644
--- a/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx
@@ -16,7 +16,6 @@ import {
import { ANNUAL_DISCOUNT_RATE } from '@/lib/billing/constants'
import { DEFAULT_UPGRADE_HEADER, UPGRADE_REASON_COPY } from '@/lib/billing/upgrade-reasons'
import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions'
-import { isBillingEnabled } from '@/lib/core/config/env-flags'
import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
import {
BillingPeriodToggle,
@@ -73,23 +72,16 @@ export function Upgrade({ workspaceId }: UpgradeProps) {
router.replace(origin ?? `/workspace/${workspaceId}/home`)
}, [origin, router, workspaceId])
- // Enterprise manages billing out-of-band, and self-hosted deployments with
- // billing disabled have no plans to surface — redirect to home in both cases.
+ // Enterprise manages billing out-of-band, so there is no plan to pick here.
+ // The self-hosted and billing-disabled cases are build constants, not reactive
+ // state — page.tsx resolves those before this ever mounts.
useEffect(() => {
- if (!isBillingEnabled) {
- router.replace(`/workspace/${workspaceId}/home`)
- return
- }
if (canManageBilling && !state.isLoading && state.subscription.isEnterprise) {
router.replace(`/workspace/${workspaceId}/home`)
}
}, [canManageBilling, state.isLoading, state.subscription.isEnterprise, router, workspaceId])
- if (
- !isBillingEnabled ||
- state.isLoading ||
- (canManageBilling && state.subscription.isEnterprise)
- ) {
+ if (state.isLoading || (canManageBilling && state.subscription.isEnterprise)) {
return null
}
diff --git a/apps/sim/app/workspace/page.tsx b/apps/sim/app/workspace/page.tsx
index cb6e4d95953..5db8905baae 100644
--- a/apps/sim/app/workspace/page.tsx
+++ b/apps/sim/app/workspace/page.tsx
@@ -11,6 +11,11 @@ import { getWorkflowStateContract } from '@/lib/api/contracts/workflows'
import { createWorkspaceContract } from '@/lib/api/contracts/workspaces'
import { useSession } from '@/lib/auth/auth-client'
import { recoverFromStaleSession } from '@/lib/auth/stale-session-recovery'
+import {
+ buildUpgradeHref,
+ isUpgradeReason,
+ UPGRADE_REASON_PARAM,
+} from '@/lib/billing/upgrade-reasons'
import { WorkspaceRecencyStorage } from '@/lib/core/utils/browser-storage'
import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar'
import { useWorkspacesWithMetadata } from '@/hooks/queries/workspace'
@@ -115,6 +120,20 @@ export default function WorkspacePage() {
if (isWorkspacesLoading || workspacesError || !data) return
+ const urlParams = new URLSearchParams(window.location.search)
+ const redirectWorkflowId = urlParams.get('redirect_workflow')
+ const redirectTarget = urlParams.get('redirect')
+ const rawReason = urlParams.get(UPGRADE_REASON_PARAM)
+
+ // `?redirect=upgrade` is how a caller that cannot know a workspace id — a
+ // self-hosted deployment, an email — reaches the plan picker. It has to
+ // survive workspace creation too: a first-time visitor has no workspace to
+ // resolve, and dropping the intent lands them on home with no explanation.
+ const destinationFor = (id: string) =>
+ redirectTarget === 'upgrade'
+ ? buildUpgradeHref(id, isUpgradeReason(rawReason) ? rawReason : undefined)
+ : `/workspace/${id}/home`
+
const { workspaces, lastActiveWorkspaceId, creationPolicy } = data
if (workspaces.length === 0) {
@@ -135,15 +154,12 @@ export default function WorkspacePage() {
return
}
hasRedirectedRef.current = true
- handleNoWorkspaces(router, () => setRecoveryFailed(true))
+ handleNoWorkspaces(router, () => setRecoveryFailed(true), destinationFor)
return
}
hasRedirectedRef.current = true
- const urlParams = new URLSearchParams(window.location.search)
- const redirectWorkflowId = urlParams.get('redirect_workflow')
-
const localRecentId = WorkspaceRecencyStorage.getMostRecent()
const findWorkspace = (id: string | null) =>
id ? workspaces.find((w) => w.id === id) : undefined
@@ -157,7 +173,7 @@ export default function WorkspacePage() {
}
logger.info(`Redirecting to workspace: ${targetWorkspace.id}`)
- router.replace(`/workspace/${targetWorkspace.id}/home`)
+ router.replace(destinationFor(targetWorkspace.id))
}, [session, isSessionPending, sessionError, isWorkspacesLoading, workspacesError, data, router])
const blockedPolicy =
@@ -242,7 +258,8 @@ async function handleWorkflowRedirect(
async function handleNoWorkspaces(
router: ReturnType,
- onUnrecoverable: () => void
+ onUnrecoverable: () => void,
+ destinationFor: (workspaceId: string) => string
): Promise {
logger.warn('No workspaces found, creating default workspace')
try {
@@ -252,7 +269,7 @@ async function handleNoWorkspaces(
if (data.workspace?.id) {
logger.info(`Created default workspace: ${data.workspace.id}`)
sessionStorage.removeItem(WORKSPACE_RACE_RETRY_KEY)
- router.replace(`/workspace/${data.workspace.id}/home`)
+ router.replace(destinationFor(data.workspace.id))
return
}
logger.error('Failed to create default workspace')
diff --git a/apps/sim/lib/billing/upgrade-reasons.test.ts b/apps/sim/lib/billing/upgrade-reasons.test.ts
index 5505a576de6..2d5a5b785f4 100644
--- a/apps/sim/lib/billing/upgrade-reasons.test.ts
+++ b/apps/sim/lib/billing/upgrade-reasons.test.ts
@@ -3,7 +3,9 @@
*/
import { describe, expect, it } from 'vitest'
import {
+ buildHostedUpgradeUrl,
buildUpgradeHref,
+ HOSTED_BILLING_SETTINGS_URL,
isUpgradeReason,
UPGRADE_REASON_COPY,
UPGRADE_REASONS,
@@ -31,6 +33,12 @@ describe('upgrade-reasons', () => {
expect(buildUpgradeHref('ws-1', 'tables')).toBe('/workspace/ws-1/upgrade?reason=tables')
})
+ it('builds absolute hosted URLs for self-hosted deployments', () => {
+ expect(buildHostedUpgradeUrl()).toBe('https://www.sim.ai/upgrade')
+ expect(buildHostedUpgradeUrl('credits')).toBe('https://www.sim.ai/upgrade?reason=credits')
+ expect(HOSTED_BILLING_SETTINGS_URL).toBe('https://www.sim.ai/account/settings/billing')
+ })
+
it('guards known reasons', () => {
expect(isUpgradeReason('storage')).toBe(true)
expect(isUpgradeReason('seats')).toBe(true)
diff --git a/apps/sim/lib/billing/upgrade-reasons.ts b/apps/sim/lib/billing/upgrade-reasons.ts
index 9e8986f1938..7d25d93fe25 100644
--- a/apps/sim/lib/billing/upgrade-reasons.ts
+++ b/apps/sim/lib/billing/upgrade-reasons.ts
@@ -2,10 +2,11 @@
* Upgrade-reason registry.
*
* Single source of truth for the language shown when a user is routed to the
- * upgrade page after hitting a usage limit. The same copy drives both the
- * upgrade-page header and the threshold/limit emails, so the in-app and email
- * journeys never drift apart.
+ * upgrade page after hitting a usage limit, and for where that route points.
+ * The same copy drives both the upgrade-page header and the threshold/limit
+ * emails, so the in-app and email journeys never drift apart.
*/
+import { SITE_URL } from '@/lib/core/utils/urls'
/** The limit categories that can route a user to the upgrade page. */
export const UPGRADE_REASONS = ['credits', 'storage', 'tables', 'seats'] as const
@@ -86,3 +87,20 @@ export function buildUpgradeHref(workspaceId: string, reason?: UpgradeReason): s
const base = `/workspace/${workspaceId}/upgrade`
return reason ? `${base}?${UPGRADE_REASON_PARAM}=${reason}` : base
}
+
+/**
+ * Absolute upgrade URL on the hosted app.
+ *
+ * Self-hosted deployments talk to Chat through a Chat key issued by the user's
+ * sim.ai account, so their plan and credits live there rather than on the local
+ * instance. A local workspace id is meaningless on the hosted app, so this
+ * points at the account-scoped `/upgrade` entry, which resolves the signed-in
+ * user's own workspace.
+ */
+export function buildHostedUpgradeUrl(reason?: UpgradeReason): string {
+ const base = `${SITE_URL}/upgrade`
+ return reason ? `${base}?${UPGRADE_REASON_PARAM}=${reason}` : base
+}
+
+/** Account billing settings on the hosted app, for raising a usage limit. */
+export const HOSTED_BILLING_SETTINGS_URL = `${SITE_URL}/account/settings/billing` as const