Skip to content

Commit 791f6c7

Browse files
committed
feat(billing): require a paid plan or prepaid credits for Chat
1 parent ac604b7 commit 791f6c7

18 files changed

Lines changed: 244 additions & 24 deletions

File tree

‎apps/sim/app/api/copilot/api-keys/validate/route.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,7 @@ describe('POST /api/copilot/api-keys/validate billing protocols', () => {
292292
const res = await POST(request(SELF_HOSTED_WORKSPACELESS_VALIDATE_BODY))
293293

294294
expect(res.status).toBe(200)
295-
expect(mockCheckServerSideUsageLimits).toHaveBeenCalledWith('user-1')
295+
expect(mockCheckServerSideUsageLimits).toHaveBeenCalledWith('user-1', ACCOUNT_SUBSCRIPTION)
296296
expect(mockResolveLegacyV0BillingAttribution).not.toHaveBeenCalled()
297297
expect(mockCheckAttributedUsageLimits).not.toHaveBeenCalled()
298298
})

‎apps/sim/app/api/copilot/api-keys/validate/route.ts‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
serializeAccountBillingDecisionHeader,
2727
serializeBillingAttributionHeader,
2828
} from '@/lib/billing/core/attribution'
29+
import { payerHasChatAccess } from '@/lib/billing/core/chat-access'
2930
import {
3031
isFreeRunPayer,
3132
readMidRunAccountUsageVerdict,
@@ -34,7 +35,7 @@ import {
3435
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
3536
import { isEnterprisePlan } from '@/lib/billing/core/subscription'
3637
import { deriveBillingContext } from '@/lib/billing/core/usage-log'
37-
import { resolveUsageUpgradePayload } from '@/lib/billing/usage-upgrade'
38+
import { resolveUsageUpgradePayload, type UsageUpgradePayer } from '@/lib/billing/usage-upgrade'
3839
import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags'
3940
import { asOrchestrationError } from '@/lib/core/orchestration/types'
4041
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -246,11 +247,13 @@ function resolveContinuationBilling(
246247
}
247248
}
248249

250+
/** The admitted payer's usage standing, and the payer admission reads to decide Chat access. */
249251
async function checkAdmissionUsage(admission: AdmissionBillingDecision): Promise<{
250252
isExceeded: boolean
251253
currentUsage: number
252254
limit: number
253255
scope: string
256+
payer: UsageUpgradePayer
254257
accountBillingDecision?: AccountBillingDecision
255258
}> {
256259
if (admission.kind === 'attributed' || admission.kind === 'legacy-scoped') {
@@ -262,6 +265,7 @@ async function checkAdmissionUsage(admission: AdmissionBillingDecision): Promise
262265
currentUsage: enforcedUsage?.currentUsage ?? 0,
263266
limit: enforcedUsage?.limit ?? 0,
264267
scope: usage.scope ?? 'payer',
268+
payer: admission.attribution,
265269
}
266270
}
267271

@@ -276,6 +280,7 @@ async function checkAdmissionUsage(admission: AdmissionBillingDecision): Promise
276280
currentUsage: usage.currentUsage,
277281
limit: usage.limit,
278282
scope: 'account',
283+
payer: { billingEntity: billingContext.billingEntity, payerSubscription: subscription },
279284
accountBillingDecision: {
280285
userId: admission.userId,
281286
billingEntity: billingContext.billingEntity,
@@ -291,12 +296,17 @@ async function checkAdmissionUsage(admission: AdmissionBillingDecision): Promise
291296
}
292297
}
293298

294-
const usage = await checkServerSideUsageLimits(admission.userId)
299+
const subscription = await getHighestPrioritySubscription(admission.userId)
300+
const usage = await checkServerSideUsageLimits(admission.userId, subscription)
295301
return {
296302
isExceeded: usage.isExceeded,
297303
currentUsage: usage.currentUsage,
298304
limit: usage.limit,
299305
scope: 'account',
306+
payer: {
307+
billingEntity: deriveBillingContext(admission.userId, subscription).billingEntity,
308+
payerSubscription: subscription,
309+
},
300310
}
301311
}
302312

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

541+
// A self-hosted or key-based turn is billed to this hosted payer, whose free plan does
542+
// not include Chat. The refusal is the admission 402 every caller already renders as
543+
// the upgrade card.
544+
if (!(await payerHasChatAccess(usage.payer))) {
545+
logger.info('[API VALIDATION] Free plan does not include Chat', { userId })
546+
span.setAttribute(TraceAttr.CopilotValidateOutcome, CopilotValidateOutcome.UsageExceeded)
547+
span.setAttribute(TraceAttr.HttpStatusCode, 402)
548+
return new NextResponse(null, { status: 402 })
549+
}
550+
531551
if (usage.isExceeded) {
532552
logger.info('[API VALIDATION] Usage exceeded', {
533553
userId,

‎apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx‎

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,12 @@ import { useParams } from 'next/navigation'
3333
import { getOrganizationSettingsHref } from '@/components/settings/navigation'
3434
import { useSettingsNavigationTarget } from '@/components/settings/settings-navigation-provider'
3535
import { useSession } from '@/lib/auth/client'
36-
import { buildHostedUpgradeUrl, HOSTED_BILLING_SETTINGS_URL } from '@/lib/billing/upgrade-reasons'
36+
import { CHAT_PLAN_REQUIRED_REASON } from '@/lib/billing/chat-plan'
37+
import {
38+
buildHostedUpgradeUrl,
39+
buildUpgradeHref,
40+
HOSTED_BILLING_SETTINGS_URL,
41+
} from '@/lib/billing/upgrade-reasons'
3742
import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions'
3843
import { isBrowserAgentAvailable, sendBrowserPanelAction } from '@/lib/browser-agent/transport'
3944
import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
@@ -3491,7 +3496,10 @@ interface PlanUsageLimitDisplayProps {
34913496
data: UsageUpgradeTagData
34923497
}
34933498

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

35053514
// Self-hosted plan and limit both live on the hosted account, so local
35063515
// workspace billing roles say nothing about who may change them.
35073516
const href = hosted
35083517
? organizationContext
35093518
? getOrganizationSettingsHref(organizationContext.organization.id, 'billing')
3510-
: getSettingsHref({ section: 'billing' })
3519+
: planRequired && hostContext
3520+
? buildUpgradeHref(hostContext.workspace.id)
3521+
: getSettingsHref({ section: 'billing' })
35113522
: data.action === 'upgrade_plan'
35123523
? buildHostedUpgradeUrl()
35133524
: HOSTED_BILLING_SETTINGS_URL
@@ -3519,11 +3530,15 @@ function PlanUsageLimitDisplay({ data }: PlanUsageLimitDisplayProps) {
35193530
const usageGate = useWorkspaceUsageGate(
35203531
data.action === 'increase_limit' && !canManageBilling ? hostContext?.workspace.id : undefined
35213532
)
3522-
const unavailableMessage = organizationContext
3523-
? 'Contact an organization admin to manage usage limits.'
3524-
: hostContext?.hostOrganizationId
3525-
? 'Contact an organization admin to manage this workspace’s usage limits.'
3526-
: 'Only the workspace owner can manage this workspace’s usage limits.'
3533+
const action = planRequired
3534+
? 'upgrade this workspace’s plan'
3535+
: 'manage this workspace’s usage limits'
3536+
const unavailableMessage =
3537+
organizationContext && !planRequired
3538+
? 'Contact an organization admin to manage usage limits.'
3539+
: organizationContext || hostContext?.hostOrganizationId
3540+
? `Contact an organization admin to ${action}.`
3541+
: `Only the workspace owner can ${action}.`
35273542

35283543
return (
35293544
<InteractionCard
@@ -3533,7 +3548,7 @@ function PlanUsageLimitDisplay({ data }: PlanUsageLimitDisplayProps) {
35333548
aria-hidden
35343549
className='size-[14px] shrink-0 text-[var(--badge-amber-text)]'
35353550
/>
3536-
Usage Limit Reached
3551+
{planRequired ? 'Paid Plan Required' : 'Usage Limit Reached'}
35373552
</span>
35383553
}
35393554
>
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
'use client'
2+
3+
import { useCallback } from 'react'
4+
import { useToast } from '@sim/emcn'
5+
import { useRouter } from 'next/navigation'
6+
import { useSession } from '@/lib/auth/client'
7+
import { CHAT_PLAN_REQUIRED_MESSAGE } from '@/lib/billing/chat-plan'
8+
import { buildUpgradeHref } from '@/lib/billing/upgrade-reasons'
9+
import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions'
10+
import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
11+
12+
/**
13+
* Stops a send from a workspace whose payer may not use Chat before it starts a turn that run
14+
* admission would refuse. A member who can manage the workspace's billing goes to its upgrade
15+
* page; anyone else is told who can upgrade. The returned callback reports whether the send was
16+
* stopped.
17+
*
18+
* The verdict is the host context's `features.chat`, which the server resolves with the same
19+
* check admission runs. A surface without a workspace host (an organization conversation), or a
20+
* context that predates the flag, is not stopped here; admission still answers it with the
21+
* upgrade card.
22+
*/
23+
export function useChatPlanGate(): () => boolean {
24+
const hostContext = useOptionalWorkspaceHostContext()
25+
const { data: session } = useSession()
26+
const viewerId = session?.user?.id
27+
const router = useRouter()
28+
const { toast } = useToast()
29+
30+
return useCallback(() => {
31+
if (!hostContext || hostContext.features?.chat !== false) return false
32+
33+
if (canManageWorkspaceBilling(hostContext, viewerId)) {
34+
router.push(buildUpgradeHref(hostContext.workspace.id))
35+
} else {
36+
toast.error(
37+
hostContext.hostOrganizationId
38+
? `${CHAT_PLAN_REQUIRED_MESSAGE} Contact an organization admin to upgrade this workspace’s plan.`
39+
: `${CHAT_PLAN_REQUIRED_MESSAGE} Ask the workspace owner to upgrade this workspace’s plan.`
40+
)
41+
}
42+
return true
43+
}, [hostContext, viewerId, router, toast])
44+
}

‎apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ import {
119119
dispatchStreamEvent,
120120
finalizeResidualToolCalls,
121121
} from '@/app/workspace/[workspaceId]/home/hooks/stream'
122+
import { useChatPlanGate } from '@/app/workspace/[workspaceId]/home/hooks/use-chat-plan-gate'
122123
import { useNativeActiveTabIds } from '@/app/workspace/[workspaceId]/home/hooks/use-desktop-tab-resources'
123124
import { useMothershipChatOlderMessages } from '@/app/workspace/[workspaceId]/home/hooks/use-mothership-chat-older-messages'
124125
import { resolveEffectiveResourceId } from '@/app/workspace/[workspaceId]/home/resource-view-policy'
@@ -779,6 +780,7 @@ export function useChat(
779780
const pathname = usePathname()
780781
const router = useRouter()
781782
const queryClient = useQueryClient()
783+
const stopSendWithoutChatPlan = useChatPlanGate()
782784
const [pendingMessages, setPendingMessages] = useState<ChatMessage[]>([])
783785
const [completedTurn, setCompletedTurn] = useState<CompletedTurn | null>(null)
784786
const completedTurnRef = useRef<CompletedTurn | null>(null)
@@ -4437,6 +4439,7 @@ export function useChat(
44374439
options?: SendMessageOptions
44384440
) => {
44394441
if ((!message.trim() && !fileAttachments?.length) || !scopeKey) return
4442+
if (stopSendWithoutChatPlan()) return
44404443

44414444
const queueStore = useMothershipQueueStore.getState()
44424445
const activeChatKey = chatKeyRef.current
@@ -4524,6 +4527,7 @@ export function useChat(
45244527
},
45254528
[
45264529
workspaceId,
4530+
stopSendWithoutChatPlan,
45274531
createQueuedMessage,
45284532
startSendMessage,
45294533
handOffWithdrawnSend,

‎apps/sim/app/workspace/[workspaceId]/upgrade/components/comparison-table/comparison-data.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,10 @@ export const COMPARISON_SECTIONS: ComparisonSection[] = [
176176
{
177177
title: 'Features',
178178
rows: [
179+
{
180+
label: 'Chat with Sim',
181+
values: [false, true, true, true],
182+
},
179183
{
180184
label: 'Sim Mailer (Inbox)',
181185
values: [false, false, true, true],

‎apps/sim/app/workspace/[workspaceId]/upgrade/plan-configs.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export const ENTERPRISE_PLAN_CREDITS: PlanCredits = {
2727
}
2828

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

3738
export const MAX_PLAN_FEATURES: readonly string[] = [
39+
'Chat with Sim',
3840
`${DEFAULT_BILLING_CONCURRENCY_LIMITS.team.toLocaleString('en-US')} concurrent executions`,
3941
'Invite teammates',
4042
'Sim Mailer, KB Live Sync & Sandboxes',

‎apps/sim/lib/api/contracts/workspaces.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,11 @@ export const workspaceHostContextSchema = z.object({
288288
knowledgeMemberAccess: z.boolean().optional(),
289289
/** Optional for rolling compatibility with app versions that predate administrator mode. */
290290
knowledgeSourceMirroredAccess: z.boolean().optional(),
291+
/**
292+
* Whether the workspace payer may use Chat. Optional for rolling compatibility; absent
293+
* leaves the composer open, and run admission still decides.
294+
*/
295+
chat: z.boolean().optional(),
291296
})
292297
.optional(),
293298
/** Optional for rolling compatibility with app versions that predate deployment projection. */

‎apps/sim/lib/billing/chat-plan.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/** The upgrade card's `reason` for a payer whose plan does not include Chat. */
2+
export const CHAT_PLAN_REQUIRED_REASON = 'plan_required' as const
3+
4+
/** Lead sentence of every refusal that withholds Chat from a plan. */
5+
export const CHAT_PLAN_REQUIRED_MESSAGE = 'Chat is included on paid plans.'
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import { getCreditBalanceForEntity } from '@/lib/billing/credits/balance'
4+
import { isFree } from '@/lib/billing/plan-helpers'
5+
import type { UsageUpgradePayer } from '@/lib/billing/usage-upgrade'
6+
import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags'
7+
8+
const logger = createLogger('ChatAccess')
9+
10+
/**
11+
* Whether a payer may use Chat (talking to Sim). Every turn spends hosted model credits, so a
12+
* hosted deployment that bills withholds Chat from a payer on the free plan — unless that payer
13+
* holds prepaid credits, which an admin grants to customers billed outside a subscription.
14+
*
15+
* The plan is the payer's entitled subscription (active or past due), so a canceled
16+
* plan reads as free; a blocked payer is refused by the usage check every caller runs next.
17+
*
18+
* This is the single verdict behind every Chat plan gate: run admission, chat titles, scheduled
19+
* job dispatch, Copilot key admission, and the workspace host context the composer reads, so the
20+
* composer never offers a send that admission will refuse. A balance that cannot be read admits
21+
* the payer: a database blip must not lock a paying customer out, and a free payer stays bounded
22+
* by its usage limit.
23+
*/
24+
export async function payerHasChatAccess(payer: UsageUpgradePayer): Promise<boolean> {
25+
if (!isHosted || !isBillingEnabled) return true
26+
if (!isFree(payer.payerSubscription?.plan)) return true
27+
try {
28+
const balance = await getCreditBalanceForEntity(
29+
payer.billingEntity.type,
30+
payer.billingEntity.id
31+
)
32+
return balance > 0
33+
} catch (error) {
34+
logger.warn('Credit balance unavailable; admitting the payer to Chat', {
35+
billingEntity: payer.billingEntity,
36+
error: getErrorMessage(error),
37+
})
38+
return true
39+
}
40+
}

0 commit comments

Comments
 (0)