From e1ac4a84da3d42b13378824a3268367b26442b86 Mon Sep 17 00:00:00 2001
From: Theodore Li
Date: Tue, 28 Jul 2026 09:00:15 -0700
Subject: [PATCH] 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 60227897d94..2d1c01b5441 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,14 +10,17 @@ import {
ExpandableContent,
SecretInput,
SecretReveal,
+ SquareArrowUpRight,
Tooltip,
toast,
} from '@sim/emcn'
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 { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils'
+import { isHosted } from '@/lib/core/config/env-flags'
import { isSafeHttpUrl } from '@/lib/core/utils/urls'
import {
resolveOAuthServiceForSlug,
@@ -1052,9 +1055,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.'
@@ -1086,11 +1096,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 ce9e0d95ce7..b0d4d436e0a 100644
--- a/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx
@@ -15,7 +15,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,
@@ -72,23 +71,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 6137b205d19..c8c9c2e3367 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 { useWorkspacesWithMetadata, type WorkspaceCreationPolicy } from '@/hooks/queries/workspace'
@@ -79,6 +84,7 @@ export default function WorkspacePage() {
const urlParams = new URLSearchParams(window.location.search)
const redirectWorkflowId = urlParams.get('redirect_workflow')
+ const redirectTarget = urlParams.get('redirect')
const { workspaces, lastActiveWorkspaceId, creationPolicy } = data
@@ -99,6 +105,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