From 58d682be0d73b5976d2f9a44629d74f782c80167 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 28 Jul 2026 09:00:15 -0700 Subject: [PATCH 1/2] 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. --- apps/sim/app/upgrade/page.tsx | 37 +++++++++++++++++++ .../components/special-tags/special-tags.tsx | 21 +++++++++-- .../workspace/[workspaceId]/upgrade/page.tsx | 24 +++++++++++- .../[workspaceId]/upgrade/upgrade.tsx | 16 ++------ apps/sim/app/workspace/page.tsx | 19 ++++++++++ apps/sim/lib/billing/upgrade-reasons.test.ts | 8 ++++ apps/sim/lib/billing/upgrade-reasons.ts | 24 ++++++++++-- 7 files changed, 129 insertions(+), 20 deletions(-) create mode 100644 apps/sim/app/upgrade/page.tsx 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..c149539c432 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' @@ -143,6 +148,7 @@ export default function WorkspacePage() { const urlParams = new URLSearchParams(window.location.search) const redirectWorkflowId = urlParams.get('redirect_workflow') + const redirectTarget = urlParams.get('redirect') const localRecentId = WorkspaceRecencyStorage.getMostRecent() const findWorkspace = (id: string | null) => @@ -156,6 +162,19 @@ export default function WorkspacePage() { return } + // `?redirect=upgrade` is how a caller that cannot know a workspace id — a + // self-hosted deployment, an email — reaches the plan picker. + if (redirectTarget === 'upgrade') { + const rawReason = urlParams.get(UPGRADE_REASON_PARAM) + const href = buildUpgradeHref( + targetWorkspace.id, + isUpgradeReason(rawReason) ? rawReason : undefined + ) + logger.info(`Redirecting to upgrade: ${targetWorkspace.id}`) + router.replace(href) + return + } + logger.info(`Redirecting to workspace: ${targetWorkspace.id}`) router.replace(`/workspace/${targetWorkspace.id}/home`) }, [session, isSessionPending, sessionError, isWorkspacesLoading, workspacesError, data, router]) 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 From 1b703482c683c8402562934c39549e5322e8cd75 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 12:28:12 -0700 Subject: [PATCH 2/2] fix(billing): keep the upgrade intent through workspace creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/sim/app/workspace/page.tsx | 40 ++++++++++++++++----------------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/apps/sim/app/workspace/page.tsx b/apps/sim/app/workspace/page.tsx index c149539c432..5db8905baae 100644 --- a/apps/sim/app/workspace/page.tsx +++ b/apps/sim/app/workspace/page.tsx @@ -120,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) { @@ -140,16 +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 redirectTarget = urlParams.get('redirect') - const localRecentId = WorkspaceRecencyStorage.getMostRecent() const findWorkspace = (id: string | null) => id ? workspaces.find((w) => w.id === id) : undefined @@ -162,21 +172,8 @@ export default function WorkspacePage() { return } - // `?redirect=upgrade` is how a caller that cannot know a workspace id — a - // self-hosted deployment, an email — reaches the plan picker. - if (redirectTarget === 'upgrade') { - const rawReason = urlParams.get(UPGRADE_REASON_PARAM) - const href = buildUpgradeHref( - targetWorkspace.id, - isUpgradeReason(rawReason) ? rawReason : undefined - ) - logger.info(`Redirecting to upgrade: ${targetWorkspace.id}`) - router.replace(href) - return - } - 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 = @@ -261,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 { @@ -271,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')