From 791f6c756d9c57015701f03b53778f8c2874ddfb Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 1 Nov 2026 11:15:07 -0800 Subject: [PATCH 1/2] feat(billing): require a paid plan or prepaid credits for Chat --- .../copilot/api-keys/validate/route.test.ts | 2 +- .../api/copilot/api-keys/validate/route.ts | 24 +++++++++- .../components/special-tags/special-tags.tsx | 33 ++++++++++---- .../home/hooks/use-chat-plan-gate.ts | 44 +++++++++++++++++++ .../[workspaceId]/home/hooks/use-chat.ts | 4 ++ .../comparison-table/comparison-data.ts | 4 ++ .../[workspaceId]/upgrade/plan-configs.ts | 2 + apps/sim/lib/api/contracts/workspaces.ts | 5 +++ apps/sim/lib/billing/chat-plan.ts | 5 +++ apps/sim/lib/billing/core/chat-access.ts | 40 +++++++++++++++++ apps/sim/lib/billing/usage-upgrade.ts | 17 ++++++- apps/sim/lib/mothership/jobs/dispatch.ts | 7 +++ .../mothership/request/lifecycle/run.test.ts | 18 ++++++-- .../lib/mothership/request/lifecycle/run.ts | 24 +++++++--- .../lib/mothership/request/lifecycle/start.ts | 3 ++ .../lib/mothership/request/tools/billing.ts | 25 ++++++++++- apps/sim/lib/workspaces/host-context.test.ts | 1 + apps/sim/lib/workspaces/host-context.ts | 10 ++++- 18 files changed, 244 insertions(+), 24 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat-plan-gate.ts create mode 100644 apps/sim/lib/billing/chat-plan.ts create mode 100644 apps/sim/lib/billing/core/chat-access.ts diff --git a/apps/sim/app/api/copilot/api-keys/validate/route.test.ts b/apps/sim/app/api/copilot/api-keys/validate/route.test.ts index 1a5f8d643be..e04b0aac04b 100644 --- a/apps/sim/app/api/copilot/api-keys/validate/route.test.ts +++ b/apps/sim/app/api/copilot/api-keys/validate/route.test.ts @@ -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() }) diff --git a/apps/sim/app/api/copilot/api-keys/validate/route.ts b/apps/sim/app/api/copilot/api-keys/validate/route.ts index e52ba5e5dde..1a159072395 100644 --- a/apps/sim/app/api/copilot/api-keys/validate/route.ts +++ b/apps/sim/app/api/copilot/api-keys/validate/route.ts @@ -26,6 +26,7 @@ import { serializeAccountBillingDecisionHeader, serializeBillingAttributionHeader, } from '@/lib/billing/core/attribution' +import { payerHasChatAccess } from '@/lib/billing/core/chat-access' import { isFreeRunPayer, readMidRunAccountUsageVerdict, @@ -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' @@ -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') { @@ -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, } } @@ -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, @@ -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, + }, } } @@ -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))) { + 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, 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 7d55d929cf4..1e78416c8f0 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 @@ -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' @@ -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() @@ -3500,6 +3508,7 @@ 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 @@ -3507,7 +3516,9 @@ function PlanUsageLimitDisplay({ data }: PlanUsageLimitDisplayProps) { 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 @@ -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 ( - Usage Limit Reached + {planRequired ? 'Paid Plan Required' : 'Usage Limit Reached'} } > diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat-plan-gate.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat-plan-gate.ts new file mode 100644 index 00000000000..1c5f1aa903f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat-plan-gate.ts @@ -0,0 +1,44 @@ +'use client' + +import { useCallback } from 'react' +import { useToast } 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() + const { toast } = useToast() + + 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, toast]) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 5577b79af38..d21ab236c1f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -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' @@ -779,6 +780,7 @@ export function useChat( const pathname = usePathname() const router = useRouter() const queryClient = useQueryClient() + const stopSendWithoutChatPlan = useChatPlanGate() const [pendingMessages, setPendingMessages] = useState([]) const [completedTurn, setCompletedTurn] = useState(null) const completedTurnRef = useRef(null) @@ -4437,6 +4439,7 @@ export function useChat( options?: SendMessageOptions ) => { if ((!message.trim() && !fileAttachments?.length) || !scopeKey) return + if (stopSendWithoutChatPlan()) return const queueStore = useMothershipQueueStore.getState() const activeChatKey = chatKeyRef.current @@ -4524,6 +4527,7 @@ export function useChat( }, [ workspaceId, + stopSendWithoutChatPlan, createQueuedMessage, startSendMessage, handOffWithdrawnSend, diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/components/comparison-table/comparison-data.ts b/apps/sim/app/workspace/[workspaceId]/upgrade/components/comparison-table/comparison-data.ts index 0eb4e368a5e..5f443636eb3 100644 --- a/apps/sim/app/workspace/[workspaceId]/upgrade/components/comparison-table/comparison-data.ts +++ b/apps/sim/app/workspace/[workspaceId]/upgrade/components/comparison-table/comparison-data.ts @@ -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], diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/plan-configs.ts b/apps/sim/app/workspace/[workspaceId]/upgrade/plan-configs.ts index 03b87e22618..9b48675f6eb 100644 --- a/apps/sim/app/workspace/[workspaceId]/upgrade/plan-configs.ts +++ b/apps/sim/app/workspace/[workspaceId]/upgrade/plan-configs.ts @@ -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', @@ -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', diff --git a/apps/sim/lib/api/contracts/workspaces.ts b/apps/sim/lib/api/contracts/workspaces.ts index bbfc2b2e924..71bb7ae8efb 100644 --- a/apps/sim/lib/api/contracts/workspaces.ts +++ b/apps/sim/lib/api/contracts/workspaces.ts @@ -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. */ diff --git a/apps/sim/lib/billing/chat-plan.ts b/apps/sim/lib/billing/chat-plan.ts new file mode 100644 index 00000000000..a1153b6c037 --- /dev/null +++ b/apps/sim/lib/billing/chat-plan.ts @@ -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.' diff --git a/apps/sim/lib/billing/core/chat-access.ts b/apps/sim/lib/billing/core/chat-access.ts new file mode 100644 index 00000000000..72645f0afd8 --- /dev/null +++ b/apps/sim/lib/billing/core/chat-access.ts @@ -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 { + 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 + } +} diff --git a/apps/sim/lib/billing/usage-upgrade.ts b/apps/sim/lib/billing/usage-upgrade.ts index 37fe1e6dbae..10f129f5927 100644 --- a/apps/sim/lib/billing/usage-upgrade.ts +++ b/apps/sim/lib/billing/usage-upgrade.ts @@ -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' @@ -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 `${JSON.stringify(payload)}` } diff --git a/apps/sim/lib/mothership/jobs/dispatch.ts b/apps/sim/lib/mothership/jobs/dispatch.ts index 90c37bf4448..91c02e34a6f 100644 --- a/apps/sim/lib/mothership/jobs/dispatch.ts +++ b/apps/sim/lib/mothership/jobs/dispatch.ts @@ -5,6 +5,8 @@ import { checkAttributedUsageLimits, resolveBillingAttribution, } from '@/lib/billing/core/attribution' +import { payerHasChatAccess } from '@/lib/billing/core/chat-access' +import { CHAT_PLAN_REQUIRED_UPGRADE } from '@/lib/billing/usage-upgrade' import { isHosted } from '@/lib/core/config/env-flags' import { acquireLock, releaseLock } from '@/lib/core/config/redis' import { asOrchestrationError } from '@/lib/core/orchestration/types' @@ -104,6 +106,11 @@ async function dispatchQueuedOccurrence(executionId: string): Promise ({ vi.mock('@/lib/mothership/request/tools/billing', () => ({ handleBillingLimitResponse: vi.fn(), + handleChatPlanRequiredResponse: vi.fn(), })) const mockRequestExplicitStreamAbort = vi.hoisted(() => vi.fn()) @@ -2310,6 +2311,17 @@ describe('runCopilotLifecycle', () => { expect(capturedExecContext?.userPermission).toBe('read') }) + /** A paid organization payer, which the free-plan Chat gate admits. */ + const paidOrganizationPayer = { + id: 'sub-org-1', + referenceId: 'org-1', + plan: 'team', + status: 'active', + seats: 2, + periodStart: null, + periodEnd: null, + } + it.each([ { workspaceId: 'ws-1', organizationId: undefined }, { workspaceId: undefined, organizationId: 'org-1' }, @@ -2324,7 +2336,7 @@ describe('runCopilotLifecycle', () => { start: '2026-07-01T00:00:00.000Z', end: '2026-08-01T00:00:00.000Z', }, - payerSubscription: null, + payerSubscription: paidOrganizationPayer, } setEnvFlags({ isHosted: true, isBillingEnabled: true }) mockEnv.COPILOT_API_KEY = 'sim-agent-key' @@ -2464,7 +2476,7 @@ describe('runCopilotLifecycle', () => { start: '2026-07-01T00:00:00.000Z', end: '2026-08-01T00:00:00.000Z', }, - payerSubscription: null, + payerSubscription: paidOrganizationPayer, } setEnvFlags({ isHosted: true, isBillingEnabled: true }) mockCheckAttributedUsageLimits.mockResolvedValue({ @@ -2517,7 +2529,7 @@ describe('runCopilotLifecycle', () => { billedAccountUserId: 'user-1', billingEntity: { type: 'organization' as const, id: 'org-1' }, billingPeriod: { start: '2026-07-01T00:00:00.000Z', end: '2099-01-01T00:00:00.000Z' }, - payerSubscription: null, + payerSubscription: paidOrganizationPayer, } setEnvFlags({ isHosted: true, isBillingEnabled: true }) resetUsageGateCache() diff --git a/apps/sim/lib/mothership/request/lifecycle/run.ts b/apps/sim/lib/mothership/request/lifecycle/run.ts index 52db59650a2..135b1bc3f0c 100644 --- a/apps/sim/lib/mothership/request/lifecycle/run.ts +++ b/apps/sim/lib/mothership/request/lifecycle/run.ts @@ -14,6 +14,7 @@ import { checkAttributedUsageLimits, createAttributedBillingRequestEnvelope, } from '@/lib/billing/core/attribution' +import { payerHasChatAccess } from '@/lib/billing/core/chat-access' import { env } from '@/lib/core/config/env' import { isCopilotToolPermissionsEnabled, isHosted } from '@/lib/core/config/env-flags' import { withFileOwnerContext } from '@/lib/mothership/application/file-owner-context' @@ -71,7 +72,10 @@ import { requireToolCallStateResult, setTerminalToolCallState, } from '@/lib/mothership/request/tool-call-state' -import { handleBillingLimitResponse } from '@/lib/mothership/request/tools/billing' +import { + handleBillingLimitResponse, + handleChatPlanRequiredResponse, +} from '@/lib/mothership/request/tools/billing' import { executeToolAndReport, failPendingToolCall, @@ -572,22 +576,30 @@ export async function runCopilotLifecycle( let onCompleteStarted = false try { - // Hosted admission (usage limits) belongs HERE, at dispatch, with the attribution + // Hosted admission (plan and usage limits) belongs HERE, at dispatch, with the attribution // already in hand. The old path outsourced it to the Go backend's api-keys/validate // callback — a call the TS worker rightly never makes, so without this gate the // limit check simply never runs on the new path. On exceeded, the same synthetic // 402 UX as the mid-stream path renders the upgrade prompt, the backend is never // dispatched, and the shared verdict assembly below runs exactly as after a // mid-stream billing break. - const admission = + const admittedAttribution = isHosted && !isContinuation && execContext.billingAttribution - ? await checkAttributedUsageLimits( - assertBillingAttributionSnapshot(execContext.billingAttribution) - ) + ? assertBillingAttributionSnapshot(execContext.billingAttribution) + : undefined + // The free plan does not include Chat, on any surface that starts a run: such a payer's + // turn ends here with the upgrade card, before usage is read or the backend dispatched. + const chatPlanRequired = + admittedAttribution !== undefined && !(await payerHasChatAccess(admittedAttribution)) + const admission = + admittedAttribution && !chatPlanRequired + ? await checkAttributedUsageLimits(admittedAttribution) : { isExceeded: false as const } if (options.recovery && context.completionStatus) { // The worker terminal was already delivered before the relay died. // Rebuild persistence from that receipt without charging its usage twice. + } else if (chatPlanRequired) { + await handleChatPlanRequiredResponse(context, execContext, lifecycleOptions) } else if (admission.isExceeded) { await handleBillingLimitResponse( execContext.userId, diff --git a/apps/sim/lib/mothership/request/lifecycle/start.ts b/apps/sim/lib/mothership/request/lifecycle/start.ts index 62f87601a6b..d499c7b136f 100644 --- a/apps/sim/lib/mothership/request/lifecycle/start.ts +++ b/apps/sim/lib/mothership/request/lifecycle/start.ts @@ -13,6 +13,7 @@ import { resolveBillingAttribution, resolveOrganizationBillingAttribution, } from '@/lib/billing/core/attribution' +import { payerHasChatAccess } from '@/lib/billing/core/chat-access' import { isHosted } from '@/lib/core/config/env-flags' import { createRunSegment, recordRunBillingAdmission } from '@/lib/mothership/async-runs/repository' import { buildChatTitleContext } from '@/lib/mothership/chat/title-context' @@ -771,6 +772,8 @@ export async function requestChatTitle(params: { throw new Error('Title billing attribution does not match its actor and workspace') } + // A title is a Chat model call; a payer whose plan excludes Chat gets none. + if (!(await payerHasChatAccess(attribution))) return null const admission = await checkAttributedUsageLimits(attribution) if (admission.isExceeded) return null diff --git a/apps/sim/lib/mothership/request/tools/billing.ts b/apps/sim/lib/mothership/request/tools/billing.ts index 295bf69ddcc..93fd55d4b9b 100644 --- a/apps/sim/lib/mothership/request/tools/billing.ts +++ b/apps/sim/lib/mothership/request/tools/billing.ts @@ -1,5 +1,10 @@ import { createLogger } from '@sim/logger' -import { formatUsageUpgradeTag, resolveUsageUpgradePayload } from '@/lib/billing/usage-upgrade' +import { + CHAT_PLAN_REQUIRED_UPGRADE, + formatUsageUpgradeTag, + resolveUsageUpgradePayload, + type UsageUpgradeTagPayload, +} from '@/lib/billing/usage-upgrade' import { MothershipStreamV1CompletionStatus, MothershipStreamV1EventType, @@ -31,6 +36,24 @@ export async function handleBillingLimitResponse( scope?: 'actor' | 'payer' | 'member' ): Promise { const payload = await resolveUsageUpgradePayload(userId, execContext.billingAttribution, scope) + await endTurnWithUpgradeCard(payload, context, execContext, options) +} + +/** Ends a free payer's turn at admission with the card offering a plan that includes Chat. */ +export async function handleChatPlanRequiredResponse( + context: StreamingContext, + execContext: ExecutionContext, + options: OrchestratorOptions +): Promise { + await endTurnWithUpgradeCard(CHAT_PLAN_REQUIRED_UPGRADE, context, execContext, options) +} + +async function endTurnWithUpgradeCard( + payload: UsageUpgradeTagPayload, + context: StreamingContext, + execContext: ExecutionContext, + options: OrchestratorOptions +): Promise { const syntheticContent = formatUsageUpgradeTag(payload) // The card is this turn's terminal even when the refused leg follows one that already ended. context.streamComplete = false diff --git a/apps/sim/lib/workspaces/host-context.test.ts b/apps/sim/lib/workspaces/host-context.test.ts index 48314ac75d2..3f73b965fcf 100644 --- a/apps/sim/lib/workspaces/host-context.test.ts +++ b/apps/sim/lib/workspaces/host-context.test.ts @@ -104,6 +104,7 @@ describe('getWorkspaceHostContextForViewer', () => { organizationSearch, knowledgeMemberAccess: workspaceKnowledge, knowledgeSourceMirroredAccess: workspaceKnowledge, + chat: true, }) expect(mockIsKnowledgeMemberAccessAvailable).toHaveBeenCalledExactlyOnceWith({ organizationId: 'org-host', diff --git a/apps/sim/lib/workspaces/host-context.ts b/apps/sim/lib/workspaces/host-context.ts index df82e3d4829..a91ba7c8edf 100644 --- a/apps/sim/lib/workspaces/host-context.ts +++ b/apps/sim/lib/workspaces/host-context.ts @@ -1,5 +1,6 @@ import { cache } from 'react' import type { WorkspaceHostContext } from '@/lib/api/contracts/workspaces' +import { payerHasChatAccess } from '@/lib/billing/core/chat-access' import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access' import { resolveDeploymentShape } from '@/lib/core/config/deployment-shape' import { isScopedCredentialGroupsAvailable } from '@/lib/credential-groups/scoped-availability' @@ -33,7 +34,7 @@ async function resolveWorkspaceHostContextForViewer( ? getOrganizationSettingsAccess(hostOrganizationId, userId) : Promise.resolve({ role: null, isMember: false, isAdmin: false }), ]) - const [credentialGroupsAvailable, knowledgeAccess, organizationSearch] = await Promise.all([ + const [credentialGroupsAvailable, knowledgeAccess, organizationSearch, chat] = await Promise.all([ hostOrganizationId ? isScopedCredentialGroupsAvailable({ kind: 'organization', @@ -44,6 +45,12 @@ async function resolveWorkspaceHostContextForViewer( hostOrganizationId && hostOrganizationAccess.isMember ? isKnowledgeMemberAccessAvailable({ organizationId: hostOrganizationId }) : Promise.resolve(false), + payerHasChatAccess({ + billingEntity: ownerBilling.organizationId + ? { type: 'organization', id: ownerBilling.organizationId } + : { type: 'user', id: access.workspace.billedAccountUserId }, + payerSubscription: { plan: ownerBilling.plan }, + }), ]) return { @@ -67,6 +74,7 @@ async function resolveWorkspaceHostContextForViewer( organizationSearch, knowledgeMemberAccess: knowledgeAccess.memberScoped, knowledgeSourceMirroredAccess: knowledgeAccess.sourceMirrored, + chat, }, deployment: resolveDeploymentShape(), } From d521a9506362965712062d5853e3f8d027fc3a5d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 1 Nov 2026 11:22:29 -0800 Subject: [PATCH 2/2] fix(chat): keep the draft when the plan gate refuses a send --- .../home/components/user-input/user-input.tsx | 7 ++++++- .../[workspaceId]/home/hooks/use-chat-plan-gate.ts | 5 ++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx index 34d9d043b6a..3595beb2823 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx @@ -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, @@ -507,6 +508,9 @@ const UserInputImpl = forwardRef(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() @@ -527,6 +531,7 @@ const UserInputImpl = forwardRef(function UserI }, [resetTranscript, history.reset]) const handleSubmit = useCallback(() => { + if (stopSendWithoutChatPlan()) return const currentFiles = filesRef.current const currentEditor = editorRef.current @@ -552,7 +557,7 @@ const UserInputImpl = forwardRef(function UserI ) if (accepted === false) return clearComposer() - }, [onSubmit, clearComposer]) + }, [stopSendWithoutChatPlan, onSubmit, clearComposer]) /** * Enter policy for the editor: mirror canSubmit's uploading guard (Enter diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat-plan-gate.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat-plan-gate.ts index 1c5f1aa903f..93c444584e3 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat-plan-gate.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat-plan-gate.ts @@ -1,7 +1,7 @@ 'use client' import { useCallback } from 'react' -import { useToast } from '@sim/emcn' +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' @@ -25,7 +25,6 @@ export function useChatPlanGate(): () => boolean { const { data: session } = useSession() const viewerId = session?.user?.id const router = useRouter() - const { toast } = useToast() return useCallback(() => { if (!hostContext || hostContext.features?.chat !== false) return false @@ -40,5 +39,5 @@ export function useChatPlanGate(): () => boolean { ) } return true - }, [hostContext, viewerId, router, toast]) + }, [hostContext, viewerId, router]) }