Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions apps/sim/app/upgrade/page.tsx
Original file line number Diff line number Diff line change
@@ -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<Record<string, string | string[] | undefined>>
}) {
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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Comment on lines +1064 to +1066

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Upgrade CTA drops reason param

On self-hosted, the upgrade_plan CTA calls buildHostedUpgradeUrl() without data.reason, so the hosted /upgrade page never receives the reason query used for limit-specific header copy; other upgrade paths forward reason through buildUpgradeHref / buildHostedUpgradeUrl.

Suggested change
: data.action === 'upgrade_plan'
? buildHostedUpgradeUrl()
: HOSTED_BILLING_SETTINGS_URL
: data.action === 'upgrade_plan'
? buildHostedUpgradeUrl(isUpgradeReason(data.reason) ? data.reason : undefined)
: HOSTED_BILLING_SETTINGS_URL

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Billing CTA drops login destination

Medium Severity

Self-hosted increase_limit CTAs open hosted /account/settings/billing directly. That route sends signed-out visitors to /login with no callbackUrl, so after auth they land on the default workspace instead of billing — unlike the /upgrade path, which was written to preserve intent.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e1ac4a8. Configure here.

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.'
Expand Down Expand Up @@ -1086,11 +1096,14 @@ function UsageUpgradeDisplay({ data }: { data: UsageUpgradeTagData }) {
</p>
{canManageBilling ? (
<a
href={settingsPath}
href={href}
target={isHosted ? undefined : '_blank'}
rel={isHosted ? undefined : 'noopener noreferrer'}
aria-label={isHosted ? undefined : `${buttonLabel} (opens in a new tab)`}
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'
>
{buttonLabel}
<ArrowRight className='size-3' />
{isHosted ? <ArrowRight className='size-3' /> : <SquareArrowUpRight className='size-3' />}
</a>
) : (
<p className='mt-2 font-[500] text-amber-700 text-small dark:text-amber-300'>
Expand Down
24 changes: 23 additions & 1 deletion apps/sim/app/workspace/[workspaceId]/upgrade/page.tsx
Original file line number Diff line number Diff line change
@@ -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<Record<string, string | string[] | undefined>>
}) {
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 (
<Suspense fallback={<div className='h-full bg-[var(--bg)]' />}>
<Upgrade workspaceId={workspaceId} />
Expand Down
16 changes: 4 additions & 12 deletions apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
}

Expand Down
19 changes: 19 additions & 0 deletions apps/sim/app/workspace/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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

Expand All @@ -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])
Expand Down
8 changes: 8 additions & 0 deletions apps/sim/lib/billing/upgrade-reasons.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
*/
import { describe, expect, it } from 'vitest'
import {
buildHostedUpgradeUrl,
buildUpgradeHref,
HOSTED_BILLING_SETTINGS_URL,
isUpgradeReason,
UPGRADE_REASON_COPY,
UPGRADE_REASONS,
Expand Down Expand Up @@ -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)
Expand Down
24 changes: 21 additions & 3 deletions apps/sim/lib/billing/upgrade-reasons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Loading