Skip to content
Merged
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
2 changes: 1 addition & 1 deletion apps/sim/app/api/copilot/api-keys/validate/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ describe('POST /api/copilot/api-keys/validate billing protocols', () => {
const res = await POST(request(SELF_HOSTED_WORKSPACELESS_VALIDATE_BODY))

expect(res.status).toBe(200)
expect(mockCheckServerSideUsageLimits).toHaveBeenCalledWith('user-1')
expect(mockCheckServerSideUsageLimits).toHaveBeenCalledWith('user-1', ACCOUNT_SUBSCRIPTION)
expect(mockResolveLegacyV0BillingAttribution).not.toHaveBeenCalled()
expect(mockCheckAttributedUsageLimits).not.toHaveBeenCalled()
})
Expand Down
24 changes: 22 additions & 2 deletions apps/sim/app/api/copilot/api-keys/validate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
serializeAccountBillingDecisionHeader,
serializeBillingAttributionHeader,
} from '@/lib/billing/core/attribution'
import { payerHasChatAccess } from '@/lib/billing/core/chat-access'
import {
isFreeRunPayer,
readMidRunAccountUsageVerdict,
Expand All @@ -34,7 +35,7 @@ import {
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
import { isEnterprisePlan } from '@/lib/billing/core/subscription'
import { deriveBillingContext } from '@/lib/billing/core/usage-log'
import { resolveUsageUpgradePayload } from '@/lib/billing/usage-upgrade'
import { resolveUsageUpgradePayload, type UsageUpgradePayer } from '@/lib/billing/usage-upgrade'
import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags'
import { asOrchestrationError } from '@/lib/core/orchestration/types'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
Expand Down Expand Up @@ -246,11 +247,13 @@ function resolveContinuationBilling(
}
}

/** The admitted payer's usage standing, and the payer admission reads to decide Chat access. */
async function checkAdmissionUsage(admission: AdmissionBillingDecision): Promise<{
isExceeded: boolean
currentUsage: number
limit: number
scope: string
payer: UsageUpgradePayer
accountBillingDecision?: AccountBillingDecision
}> {
if (admission.kind === 'attributed' || admission.kind === 'legacy-scoped') {
Expand All @@ -262,6 +265,7 @@ async function checkAdmissionUsage(admission: AdmissionBillingDecision): Promise
currentUsage: enforcedUsage?.currentUsage ?? 0,
limit: enforcedUsage?.limit ?? 0,
scope: usage.scope ?? 'payer',
payer: admission.attribution,
}
}

Expand All @@ -276,6 +280,7 @@ async function checkAdmissionUsage(admission: AdmissionBillingDecision): Promise
currentUsage: usage.currentUsage,
limit: usage.limit,
scope: 'account',
payer: { billingEntity: billingContext.billingEntity, payerSubscription: subscription },
accountBillingDecision: {
userId: admission.userId,
billingEntity: billingContext.billingEntity,
Expand All @@ -291,12 +296,17 @@ async function checkAdmissionUsage(admission: AdmissionBillingDecision): Promise
}
}

const usage = await checkServerSideUsageLimits(admission.userId)
const subscription = await getHighestPrioritySubscription(admission.userId)
const usage = await checkServerSideUsageLimits(admission.userId, subscription)
return {
isExceeded: usage.isExceeded,
currentUsage: usage.currentUsage,
limit: usage.limit,
scope: 'account',
payer: {
billingEntity: deriveBillingContext(admission.userId, subscription).billingEntity,
payerSubscription: subscription,
},
}
}

Expand Down Expand Up @@ -528,6 +538,16 @@ export const POST = withRouteHandler((req: NextRequest) =>
: (usage.accountBillingDecision?.billingEntity ?? { type: 'account', id: userId }),
})

// A self-hosted or key-based turn is billed to this hosted payer, whose free plan does
// not include Chat. The refusal is the admission 402 every caller already renders as
// the upgrade card.
if (!(await payerHasChatAccess(usage.payer))) {
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
logger.info('[API VALIDATION] Free plan does not include Chat', { userId })
span.setAttribute(TraceAttr.CopilotValidateOutcome, CopilotValidateOutcome.UsageExceeded)
span.setAttribute(TraceAttr.HttpStatusCode, 402)
return new NextResponse(null, { status: 402 })
}

if (usage.isExceeded) {
logger.info('[API VALIDATION] Usage exceeded', {
userId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,12 @@ import { useParams } from 'next/navigation'
import { getOrganizationSettingsHref } from '@/components/settings/navigation'
import { useSettingsNavigationTarget } from '@/components/settings/settings-navigation-provider'
import { useSession } from '@/lib/auth/client'
import { buildHostedUpgradeUrl, HOSTED_BILLING_SETTINGS_URL } from '@/lib/billing/upgrade-reasons'
import { CHAT_PLAN_REQUIRED_REASON } from '@/lib/billing/chat-plan'
import {
buildHostedUpgradeUrl,
buildUpgradeHref,
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 { useDeploymentShape } from '@/lib/core/config/deployment-shape'
Expand Down Expand Up @@ -3491,7 +3496,10 @@ interface PlanUsageLimitDisplayProps {
data: UsageUpgradeTagData
}

/** The payer's plan limit: the card links to where the plan or the limit can change. */
/**
* The payer's plan limit, or a free plan that does not include Chat: the card links to where
* the plan or the limit can change, and a workspace's plan to its upgrade page.
*/
function PlanUsageLimitDisplay({ data }: PlanUsageLimitDisplayProps) {
const { data: session } = useSession()
const hostContext = useOptionalWorkspaceHostContext()
Expand All @@ -3500,14 +3508,17 @@ function PlanUsageLimitDisplay({ data }: PlanUsageLimitDisplayProps) {
const { hosted } = useDeploymentShape()
const navigationTarget = useSettingsNavigationTarget()
const opensInNewTab = !hosted || navigationTarget === '_blank'
const planRequired = data.reason === CHAT_PLAN_REQUIRED_REASON
const buttonLabel = data.action === 'upgrade_plan' ? 'Upgrade Plan' : 'Increase Limit'

// 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 = hosted
? organizationContext
? getOrganizationSettingsHref(organizationContext.organization.id, 'billing')
: getSettingsHref({ section: 'billing' })
: planRequired && hostContext
? buildUpgradeHref(hostContext.workspace.id)
: getSettingsHref({ section: 'billing' })
: data.action === 'upgrade_plan'
? buildHostedUpgradeUrl()
: HOSTED_BILLING_SETTINGS_URL
Expand All @@ -3519,11 +3530,15 @@ function PlanUsageLimitDisplay({ data }: PlanUsageLimitDisplayProps) {
const usageGate = useWorkspaceUsageGate(
data.action === 'increase_limit' && !canManageBilling ? hostContext?.workspace.id : undefined
)
const unavailableMessage = organizationContext
? 'Contact an organization admin to manage usage limits.'
: hostContext?.hostOrganizationId
? 'Contact an organization admin to manage this workspace’s usage limits.'
: 'Only the workspace owner can manage this workspace’s usage limits.'
const action = planRequired
? 'upgrade this workspace’s plan'
: 'manage this workspace’s usage limits'
const unavailableMessage =
organizationContext && !planRequired
? 'Contact an organization admin to manage usage limits.'
: organizationContext || hostContext?.hostOrganizationId
? `Contact an organization admin to ${action}.`
: `Only the workspace owner can ${action}.`

return (
<InteractionCard
Expand All @@ -3533,7 +3548,7 @@ function PlanUsageLimitDisplay({ data }: PlanUsageLimitDisplayProps) {
aria-hidden
className='size-[14px] shrink-0 text-[var(--badge-amber-text)]'
/>
Usage Limit Reached
{planRequired ? 'Paid Plan Required' : 'Usage Limit Reached'}
</span>
}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { InputToolbar } from '@/app/workspace/[workspaceId]/home/components/user
import type { ModelSelectorOptions } from '@/app/workspace/[workspaceId]/home/components/user-input/components/model-selector'
import { usePromptHistory } from '@/app/workspace/[workspaceId]/home/components/user-input/hooks/use-prompt-history'
import { handleMothershipAddContextEvent } from '@/app/workspace/[workspaceId]/home/components/user-input/mothership-context-event'
import { useChatPlanGate } from '@/app/workspace/[workspaceId]/home/hooks/use-chat-plan-gate'
import type {
ChatMessage,
FileAttachmentForApi,
Expand Down Expand Up @@ -507,6 +508,9 @@ const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(function UserI
textareaRef.current?.focus()
}

/** Refuses a send the workspace plan excludes here, before `onSubmit`, so the draft survives. */
const stopSendWithoutChatPlan = useChatPlanGate()

/** Empties the text, chips, attachments, transcript, and the saved draft in one step. */
const clearComposer = useCallback(() => {
history.reset()
Expand All @@ -527,6 +531,7 @@ const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(function UserI
}, [resetTranscript, history.reset])

const handleSubmit = useCallback(() => {
if (stopSendWithoutChatPlan()) return
const currentFiles = filesRef.current
const currentEditor = editorRef.current

Expand All @@ -552,7 +557,7 @@ const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(function UserI
)
if (accepted === false) return
clearComposer()
}, [onSubmit, clearComposer])
}, [stopSendWithoutChatPlan, onSubmit, clearComposer])

/**
* Enter policy for the editor: mirror canSubmit's uploading guard (Enter
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
'use client'

import { useCallback } from 'react'
import { toast } from '@sim/emcn'
import { useRouter } from 'next/navigation'
import { useSession } from '@/lib/auth/client'
import { CHAT_PLAN_REQUIRED_MESSAGE } from '@/lib/billing/chat-plan'
import { buildUpgradeHref } from '@/lib/billing/upgrade-reasons'
import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions'
import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'

/**
* Stops a send from a workspace whose payer may not use Chat before it starts a turn that run
* admission would refuse. A member who can manage the workspace's billing goes to its upgrade
* page; anyone else is told who can upgrade. The returned callback reports whether the send was
* stopped.
*
* The verdict is the host context's `features.chat`, which the server resolves with the same
* check admission runs. A surface without a workspace host (an organization conversation), or a
* context that predates the flag, is not stopped here; admission still answers it with the
* upgrade card.
*/
export function useChatPlanGate(): () => boolean {
const hostContext = useOptionalWorkspaceHostContext()
const { data: session } = useSession()
const viewerId = session?.user?.id
const router = useRouter()

return useCallback(() => {
if (!hostContext || hostContext.features?.chat !== false) return false

if (canManageWorkspaceBilling(hostContext, viewerId)) {
router.push(buildUpgradeHref(hostContext.workspace.id))
} else {
toast.error(
hostContext.hostOrganizationId
? `${CHAT_PLAN_REQUIRED_MESSAGE} Contact an organization admin to upgrade this workspace’s plan.`
: `${CHAT_PLAN_REQUIRED_MESSAGE} Ask the workspace owner to upgrade this workspace’s plan.`
)
}
return true
}, [hostContext, viewerId, router])
}
4 changes: 4 additions & 0 deletions apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ import {
dispatchStreamEvent,
finalizeResidualToolCalls,
} from '@/app/workspace/[workspaceId]/home/hooks/stream'
import { useChatPlanGate } from '@/app/workspace/[workspaceId]/home/hooks/use-chat-plan-gate'
import { useNativeActiveTabIds } from '@/app/workspace/[workspaceId]/home/hooks/use-desktop-tab-resources'
import { useMothershipChatOlderMessages } from '@/app/workspace/[workspaceId]/home/hooks/use-mothership-chat-older-messages'
import { resolveEffectiveResourceId } from '@/app/workspace/[workspaceId]/home/resource-view-policy'
Expand Down Expand Up @@ -779,6 +780,7 @@ export function useChat(
const pathname = usePathname()
const router = useRouter()
const queryClient = useQueryClient()
const stopSendWithoutChatPlan = useChatPlanGate()
const [pendingMessages, setPendingMessages] = useState<ChatMessage[]>([])
const [completedTurn, setCompletedTurn] = useState<CompletedTurn | null>(null)
const completedTurnRef = useRef<CompletedTurn | null>(null)
Expand Down Expand Up @@ -4437,6 +4439,7 @@ export function useChat(
options?: SendMessageOptions
) => {
if ((!message.trim() && !fileAttachments?.length) || !scopeKey) return
if (stopSendWithoutChatPlan()) return
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved

const queueStore = useMothershipQueueStore.getState()
const activeChatKey = chatKeyRef.current
Expand Down Expand Up @@ -4524,6 +4527,7 @@ export function useChat(
},
[
workspaceId,
stopSendWithoutChatPlan,
createQueuedMessage,
startSendMessage,
handOffWithdrawnSend,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,10 @@ export const COMPARISON_SECTIONS: ComparisonSection[] = [
{
title: 'Features',
rows: [
{
label: 'Chat with Sim',
values: [false, true, true, true],
},
{
label: 'Sim Mailer (Inbox)',
values: [false, false, true, true],
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/app/workspace/[workspaceId]/upgrade/plan-configs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const ENTERPRISE_PLAN_CREDITS: PlanCredits = {
}

export const PRO_PLAN_FEATURES: readonly string[] = [
'Chat with Sim',
`${DEFAULT_BILLING_CONCURRENCY_LIMITS.pro.toLocaleString('en-US')} concurrent executions`,
'Invite teammates',
'Higher rate limits',
Expand All @@ -35,6 +36,7 @@ export const PRO_PLAN_FEATURES: readonly string[] = [
]

export const MAX_PLAN_FEATURES: readonly string[] = [
'Chat with Sim',
`${DEFAULT_BILLING_CONCURRENCY_LIMITS.team.toLocaleString('en-US')} concurrent executions`,
'Invite teammates',
'Sim Mailer, KB Live Sync & Sandboxes',
Expand Down
5 changes: 5 additions & 0 deletions apps/sim/lib/api/contracts/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,11 @@ export const workspaceHostContextSchema = z.object({
knowledgeMemberAccess: z.boolean().optional(),
/** Optional for rolling compatibility with app versions that predate administrator mode. */
knowledgeSourceMirroredAccess: z.boolean().optional(),
/**
* Whether the workspace payer may use Chat. Optional for rolling compatibility; absent
* leaves the composer open, and run admission still decides.
*/
chat: z.boolean().optional(),
})
.optional(),
/** Optional for rolling compatibility with app versions that predate deployment projection. */
Expand Down
5 changes: 5 additions & 0 deletions apps/sim/lib/billing/chat-plan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/** The upgrade card's `reason` for a payer whose plan does not include Chat. */
export const CHAT_PLAN_REQUIRED_REASON = 'plan_required' as const

/** Lead sentence of every refusal that withholds Chat from a plan. */
export const CHAT_PLAN_REQUIRED_MESSAGE = 'Chat is included on paid plans.'
40 changes: 40 additions & 0 deletions apps/sim/lib/billing/core/chat-access.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { getCreditBalanceForEntity } from '@/lib/billing/credits/balance'
import { isFree } from '@/lib/billing/plan-helpers'
import type { UsageUpgradePayer } from '@/lib/billing/usage-upgrade'
import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags'

const logger = createLogger('ChatAccess')

/**
* Whether a payer may use Chat (talking to Sim). Every turn spends hosted model credits, so a
* hosted deployment that bills withholds Chat from a payer on the free plan — unless that payer
* holds prepaid credits, which an admin grants to customers billed outside a subscription.
*
* The plan is the payer's entitled subscription (active or past due), so a canceled
* plan reads as free; a blocked payer is refused by the usage check every caller runs next.
*
* This is the single verdict behind every Chat plan gate: run admission, chat titles, scheduled
* job dispatch, Copilot key admission, and the workspace host context the composer reads, so the
* composer never offers a send that admission will refuse. A balance that cannot be read admits
* the payer: a database blip must not lock a paying customer out, and a free payer stays bounded
* by its usage limit.
*/
export async function payerHasChatAccess(payer: UsageUpgradePayer): Promise<boolean> {
if (!isHosted || !isBillingEnabled) return true
if (!isFree(payer.payerSubscription?.plan)) return true
try {
const balance = await getCreditBalanceForEntity(
payer.billingEntity.type,
payer.billingEntity.id
)
return balance > 0
} catch (error) {
logger.warn('Credit balance unavailable; admitting the payer to Chat', {
billingEntity: payer.billingEntity,
error: getErrorMessage(error),
})
return true
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
}
}
17 changes: 16 additions & 1 deletion apps/sim/lib/billing/usage-upgrade.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import type { UsageUpgradePayload } from '@/lib/api/contracts/subscription'
import { CHAT_PLAN_REQUIRED_MESSAGE, CHAT_PLAN_REQUIRED_REASON } from '@/lib/billing/chat-plan'
import type { AttributedUsageLimitsResult } from '@/lib/billing/core/attribution'
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
import type { BillingEntity } from '@/lib/billing/core/usage-log'
Expand Down Expand Up @@ -68,7 +69,21 @@ export async function resolveUsageUpgradePayload(
return { reason: 'usage_limit', action: 'increase_limit', message }
}

/**
* The card for a payer whose plan does not include Chat. Sim writes it at run admission, before
* any worker run exists, so it never crosses the worker billing protocol: its reason widens only
* the tag Sim renders, not the worker-shared card.
*/
export const CHAT_PLAN_REQUIRED_UPGRADE = {
reason: CHAT_PLAN_REQUIRED_REASON,
action: 'upgrade_plan',
message: `${CHAT_PLAN_REQUIRED_MESSAGE} Upgrade your plan to talk to Sim.`,
} as const

/** Every card body the usage tag carries: the worker-shared card or Sim's Chat plan card. */
export type UsageUpgradeTagPayload = UsageUpgradePayload | typeof CHAT_PLAN_REQUIRED_UPGRADE

/** The assistant text that renders {@link payload} as the usage card. */
export function formatUsageUpgradeTag(payload: UsageUpgradePayload): string {
export function formatUsageUpgradeTag(payload: UsageUpgradeTagPayload): string {
return `<usage_upgrade>${JSON.stringify(payload)}</usage_upgrade>`
}
Loading
Loading