Skip to content

Commit e1ac4a8

Browse files
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.
1 parent 8329dac commit e1ac4a8

7 files changed

Lines changed: 129 additions & 20 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,14 +10,17 @@ import {
1010
ExpandableContent,
1111
SecretInput,
1212
SecretReveal,
13+
SquareArrowUpRight,
1314
Tooltip,
1415
toast,
1516
} from '@sim/emcn'
1617
import { useParams } from 'next/navigation'
1718
import { ThinkingLoader } from '@/components/ui'
1819
import { useSession } from '@/lib/auth/auth-client'
20+
import { buildHostedUpgradeUrl, HOSTED_BILLING_SETTINGS_URL } from '@/lib/billing/upgrade-reasons'
1921
import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions'
2022
import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils'
23+
import { isHosted } from '@/lib/core/config/env-flags'
2124
import { isSafeHttpUrl } from '@/lib/core/utils/urls'
2225
import {
2326
resolveOAuthServiceForSlug,
@@ -1052,9 +1055,16 @@ function UsageUpgradeDisplay({ data }: { data: UsageUpgradeTagData }) {
10521055
const { data: session } = useSession()
10531056
const hostContext = useWorkspaceHostContext()
10541057
const { getSettingsHref } = useSettingsNavigation()
1055-
const settingsPath = getSettingsHref({ section: 'billing' })
10561058
const buttonLabel = data.action === 'upgrade_plan' ? 'Upgrade Plan' : 'Increase Limit'
1057-
const canManageBilling = canManageWorkspaceBilling(hostContext, session?.user?.id)
1059+
1060+
// Self-hosted plan and limit both live on the hosted account, so local
1061+
// workspace billing roles say nothing about who may change them.
1062+
const href = isHosted
1063+
? getSettingsHref({ section: 'billing' })
1064+
: data.action === 'upgrade_plan'
1065+
? buildHostedUpgradeUrl()
1066+
: HOSTED_BILLING_SETTINGS_URL
1067+
const canManageBilling = !isHosted || canManageWorkspaceBilling(hostContext, session?.user?.id)
10581068
const unavailableMessage = hostContext.hostOrganizationId
10591069
? 'Contact an organization admin to manage this workspace’s usage limits.'
10601070
: 'Only the workspace owner can manage this workspace’s usage limits.'
@@ -1086,11 +1096,14 @@ function UsageUpgradeDisplay({ data }: { data: UsageUpgradeTagData }) {
10861096
</p>
10871097
{canManageBilling ? (
10881098
<a
1089-
href={settingsPath}
1099+
href={href}
1100+
target={isHosted ? undefined : '_blank'}
1101+
rel={isHosted ? undefined : 'noopener noreferrer'}
1102+
aria-label={isHosted ? undefined : `${buttonLabel} (opens in a new tab)`}
10901103
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'
10911104
>
10921105
{buttonLabel}
1093-
<ArrowRight className='size-3' />
1106+
{isHosted ? <ArrowRight className='size-3' /> : <SquareArrowUpRight className='size-3' />}
10941107
</a>
10951108
) : (
10961109
<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
@@ -15,7 +15,6 @@ import {
1515
import { ANNUAL_DISCOUNT_RATE } from '@/lib/billing/constants'
1616
import { DEFAULT_UPGRADE_HEADER, UPGRADE_REASON_COPY } from '@/lib/billing/upgrade-reasons'
1717
import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions'
18-
import { isBillingEnabled } from '@/lib/core/config/env-flags'
1918
import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
2019
import {
2120
BillingPeriodToggle,
@@ -72,23 +71,16 @@ export function Upgrade({ workspaceId }: UpgradeProps) {
7271
router.replace(origin ?? `/workspace/${workspaceId}/home`)
7372
}, [origin, router, workspaceId])
7473

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

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

apps/sim/app/workspace/page.tsx

Lines changed: 19 additions & 0 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 { useWorkspacesWithMetadata, type WorkspaceCreationPolicy } from '@/hooks/queries/workspace'
1621

@@ -79,6 +84,7 @@ export default function WorkspacePage() {
7984

8085
const urlParams = new URLSearchParams(window.location.search)
8186
const redirectWorkflowId = urlParams.get('redirect_workflow')
87+
const redirectTarget = urlParams.get('redirect')
8288

8389
const { workspaces, lastActiveWorkspaceId, creationPolicy } = data
8490

@@ -99,6 +105,19 @@ export default function WorkspacePage() {
99105
return
100106
}
101107

108+
// `?redirect=upgrade` is how a caller that cannot know a workspace id — a
109+
// self-hosted deployment, an email — reaches the plan picker.
110+
if (redirectTarget === 'upgrade') {
111+
const rawReason = urlParams.get(UPGRADE_REASON_PARAM)
112+
const href = buildUpgradeHref(
113+
targetWorkspace.id,
114+
isUpgradeReason(rawReason) ? rawReason : undefined
115+
)
116+
logger.info(`Redirecting to upgrade: ${targetWorkspace.id}`)
117+
router.replace(href)
118+
return
119+
}
120+
102121
logger.info(`Redirecting to workspace: ${targetWorkspace.id}`)
103122
router.replace(`/workspace/${targetWorkspace.id}/home`)
104123
}, [session, isSessionPending, sessionError, isWorkspacesLoading, workspacesError, data, router])

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)