Skip to content

Commit c5cc336

Browse files
fix(subscription-state): remove dead code, change token route check (#4062)
* fix(subscription-state): remove dead code, change token route check * update tests * remove mock * improve ux past usage limit
1 parent 5f33432 commit c5cc336

File tree

9 files changed

+52
-192
lines changed

9 files changed

+52
-192
lines changed

apps/sim/app/api/speech/token/route.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { createLogger } from '@sim/logger'
44
import { eq } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
66
import { getSession } from '@/lib/auth'
7-
import { hasExceededCostLimit } from '@/lib/billing/core/subscription'
7+
import { checkServerSideUsageLimits } from '@/lib/billing/calculations/usage-monitor'
88
import { recordUsage } from '@/lib/billing/core/usage-log'
99
import { env } from '@/lib/core/config/env'
1010
import { getCostMultiplier, isBillingEnabled } from '@/lib/core/config/feature-flags'
@@ -110,11 +110,14 @@ export async function POST(request: NextRequest) {
110110
}
111111
}
112112

113-
if (billingUserId && isBillingEnabled) {
114-
const exceeded = await hasExceededCostLimit(billingUserId)
115-
if (exceeded) {
113+
if (billingUserId) {
114+
const usageCheck = await checkServerSideUsageLimits(billingUserId)
115+
if (usageCheck.isExceeded) {
116116
return NextResponse.json(
117-
{ error: 'Usage limit exceeded. Please upgrade your plan to continue.' },
117+
{
118+
error:
119+
usageCheck.message || 'Usage limit exceeded. Please upgrade your plan to continue.',
120+
},
118121
{ status: 402 }
119122
)
120123
}

apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
extractContextTokens,
4040
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
4141
import { useWorkflowMap } from '@/hooks/queries/workflows'
42+
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
4243
import { useSpeechToText } from '@/hooks/use-speech-to-text'
4344
import type { ChatContext } from '@/stores/panel'
4445

@@ -120,6 +121,7 @@ export function UserInput({
120121
onEnterWhileEmpty,
121122
}: UserInputProps) {
122123
const { workspaceId } = useParams<{ workspaceId: string }>()
124+
const { navigateToSettings } = useSettingsNavigation()
123125
const { data: workflowsById = {} } = useWorkflowMap(workspaceId)
124126
const { data: session } = useSession()
125127
const [value, setValue] = useState(defaultValue)
@@ -239,12 +241,19 @@ export function UserInput({
239241
valueRef.current = newVal
240242
}, [])
241243

244+
const handleUsageLimitExceeded = useCallback(() => {
245+
navigateToSettings({ section: 'subscription' })
246+
}, [navigateToSettings])
247+
242248
const {
243249
isListening,
244250
isSupported: isSttSupported,
245251
toggleListening: rawToggle,
246252
resetTranscript,
247-
} = useSpeechToText({ onTranscript: handleTranscript })
253+
} = useSpeechToText({
254+
onTranscript: handleTranscript,
255+
onUsageLimitExceeded: handleUsageLimitExceeded,
256+
})
248257

249258
const toggleListening = useCallback(() => {
250259
if (!isListening) {

apps/sim/hooks/use-speech-to-text.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export type PermissionState = 'prompt' | 'granted' | 'denied'
1818

1919
interface UseSpeechToTextProps {
2020
onTranscript: (text: string) => void
21+
onUsageLimitExceeded?: () => void
2122
language?: string
2223
}
2324

@@ -31,13 +32,15 @@ interface UseSpeechToTextReturn {
3132

3233
export function useSpeechToText({
3334
onTranscript,
35+
onUsageLimitExceeded,
3436
language,
3537
}: UseSpeechToTextProps): UseSpeechToTextReturn {
3638
const [isListening, setIsListening] = useState(false)
3739
const [isSupported, setIsSupported] = useState(false)
3840
const [permissionState, setPermissionState] = useState<PermissionState>('prompt')
3941

4042
const onTranscriptRef = useRef(onTranscript)
43+
const onUsageLimitExceededRef = useRef(onUsageLimitExceeded)
4144
const languageRef = useRef(language)
4245
const mountedRef = useRef(true)
4346
const startingRef = useRef(false)
@@ -55,6 +58,7 @@ export function useSpeechToText({
5558
const committedTextRef = useRef('')
5659

5760
onTranscriptRef.current = onTranscript
61+
onUsageLimitExceededRef.current = onUsageLimitExceeded
5862
languageRef.current = language
5963

6064
useEffect(() => {
@@ -165,6 +169,10 @@ export function useSpeechToText({
165169
})
166170

167171
if (!tokenResponse.ok) {
172+
if (tokenResponse.status === 402) {
173+
onUsageLimitExceededRef.current?.()
174+
return false
175+
}
168176
const body = await tokenResponse.json().catch(() => ({}))
169177
throw new Error(body.error || 'Failed to get speech token')
170178
}

apps/sim/lib/billing/core/subscription.ts

Lines changed: 1 addition & 145 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
import { db } from '@sim/db'
2-
import { member, subscription, user, userStats } from '@sim/db/schema'
2+
import { member, subscription, user } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { and, eq, inArray, sql } from 'drizzle-orm'
55
import { getEffectiveBillingStatus, isOrganizationBillingBlocked } from '@/lib/billing/core/access'
66
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
7-
import { getUserUsageLimit } from '@/lib/billing/core/usage'
87
import {
98
getPlanTierCredits,
10-
isOrgPlan,
119
isPro as isPlanPro,
1210
isTeam as isPlanTeam,
1311
} from '@/lib/billing/plan-helpers'
@@ -16,12 +14,9 @@ import {
1614
checkProPlan,
1715
checkTeamPlan,
1816
ENTITLED_SUBSCRIPTION_STATUSES,
19-
getFreeTierLimit,
20-
getPerUserMinimumLimit,
2117
hasUsableSubscriptionAccess,
2218
USABLE_SUBSCRIPTION_STATUSES,
2319
} from '@/lib/billing/subscriptions/utils'
24-
import type { UserSubscriptionState } from '@/lib/billing/types'
2520
import {
2621
isAccessControlEnabled,
2722
isBillingEnabled,
@@ -485,145 +480,6 @@ export async function hasLiveSyncAccess(userId: string): Promise<boolean> {
485480
}
486481
}
487482

488-
/**
489-
* Check if user has exceeded their cost limit based on current period usage
490-
*/
491-
export async function hasExceededCostLimit(userId: string): Promise<boolean> {
492-
try {
493-
if (!isBillingEnabled) {
494-
return false
495-
}
496-
497-
const subscription = await getHighestPrioritySubscription(userId)
498-
499-
let limit = getFreeTierLimit() // Default free tier limit
500-
501-
if (subscription) {
502-
// Team/Enterprise: Use organization limit
503-
if (isOrgPlan(subscription.plan)) {
504-
limit = await getUserUsageLimit(userId)
505-
logger.info('Using organization limit', {
506-
userId,
507-
plan: subscription.plan,
508-
limit,
509-
})
510-
} else {
511-
// Pro/Free: Use individual limit
512-
limit = getPerUserMinimumLimit(subscription)
513-
logger.info('Using subscription-based limit', {
514-
userId,
515-
plan: subscription.plan,
516-
limit,
517-
})
518-
}
519-
} else {
520-
logger.info('Using free tier limit', { userId, limit })
521-
}
522-
523-
// Get user stats to check current period usage
524-
const statsRecords = await db.select().from(userStats).where(eq(userStats.userId, userId))
525-
526-
if (statsRecords.length === 0) {
527-
return false
528-
}
529-
530-
// Use current period cost instead of total cost for accurate billing period tracking
531-
const currentCost = Number.parseFloat(
532-
statsRecords[0].currentPeriodCost?.toString() || statsRecords[0].totalCost.toString()
533-
)
534-
535-
logger.info('Checking cost limit', { userId, currentCost, limit })
536-
537-
return currentCost >= limit
538-
} catch (error) {
539-
logger.error('Error checking cost limit', { error, userId })
540-
return false // Be conservative in case of error
541-
}
542-
}
543-
544-
/**
545-
* Check if sharing features are enabled for user
546-
*/
547-
// Removed unused feature flag helpers: isSharingEnabled, isMultiplayerEnabled, isWorkspaceCollaborationEnabled
548-
549-
/**
550-
* Get comprehensive subscription state for a user
551-
* Single function to get all subscription information
552-
*/
553-
export async function getUserSubscriptionState(userId: string): Promise<UserSubscriptionState> {
554-
try {
555-
// Get subscription and user stats in parallel to minimize DB calls
556-
const [subscription, statsRecords] = await Promise.all([
557-
getHighestPrioritySubscription(userId),
558-
db.select().from(userStats).where(eq(userStats.userId, userId)).limit(1),
559-
])
560-
561-
// Determine plan types based on subscription (avoid redundant DB calls)
562-
const isPro =
563-
!isBillingEnabled ||
564-
!!(
565-
subscription &&
566-
(checkProPlan(subscription) ||
567-
checkTeamPlan(subscription) ||
568-
checkEnterprisePlan(subscription))
569-
)
570-
const isTeam =
571-
!isBillingEnabled ||
572-
!!(subscription && (checkTeamPlan(subscription) || checkEnterprisePlan(subscription)))
573-
const isEnterprise = !isBillingEnabled || !!(subscription && checkEnterprisePlan(subscription))
574-
const isFree = !isPro && !isTeam && !isEnterprise
575-
576-
// Determine plan name
577-
let planName = 'free'
578-
if (isEnterprise) planName = 'enterprise'
579-
else if (isTeam) planName = 'team'
580-
else if (isPro) planName = 'pro'
581-
582-
// Check cost limit using already-fetched user stats
583-
let hasExceededLimit = false
584-
if (isBillingEnabled && statsRecords.length > 0) {
585-
let limit = getFreeTierLimit() // Default free tier limit
586-
if (subscription) {
587-
// Team/Enterprise: Use organization limit
588-
if (isOrgPlan(subscription.plan)) {
589-
limit = await getUserUsageLimit(userId)
590-
} else {
591-
// Pro/Free: Use individual limit
592-
limit = getPerUserMinimumLimit(subscription)
593-
}
594-
}
595-
596-
const currentCost = Number.parseFloat(
597-
statsRecords[0].currentPeriodCost?.toString() || statsRecords[0].totalCost.toString()
598-
)
599-
hasExceededLimit = currentCost >= limit
600-
}
601-
602-
return {
603-
isPro,
604-
isTeam,
605-
isEnterprise,
606-
isFree,
607-
highestPrioritySubscription: subscription,
608-
hasExceededLimit,
609-
planName,
610-
}
611-
} catch (error) {
612-
logger.error('Error getting user subscription state', { error, userId })
613-
614-
// Return safe defaults in case of error
615-
return {
616-
isPro: false,
617-
isTeam: false,
618-
isEnterprise: false,
619-
isFree: true,
620-
highestPrioritySubscription: null,
621-
hasExceededLimit: false,
622-
planName: 'free',
623-
}
624-
}
625-
}
626-
627483
/**
628484
* Send welcome email for Pro and Team plan subscriptions
629485
*/

apps/sim/lib/billing/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ export * from '@/lib/billing/core/organization'
99
export * from '@/lib/billing/core/subscription'
1010
export {
1111
getHighestPrioritySubscription as getActiveSubscription,
12-
getUserSubscriptionState as getSubscriptionState,
1312
hasAccessControlAccess,
1413
hasCredentialSetsAccess,
1514
hasPaidSubscription,

apps/sim/lib/billing/types/index.ts

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -73,16 +73,6 @@ export interface BillingData {
7373
daysRemaining: number
7474
}
7575

76-
export interface UserSubscriptionState {
77-
isPro: boolean
78-
isTeam: boolean
79-
isEnterprise: boolean
80-
isFree: boolean
81-
highestPrioritySubscription: any | null
82-
hasExceededLimit: boolean
83-
planName: string
84-
}
85-
8676
export interface SubscriptionPlan {
8777
name: string
8878
priceId: string

apps/sim/lib/copilot/chat-payload.test.ts

Lines changed: 16 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,18 @@
33
*/
44
import { beforeEach, describe, expect, it, vi } from 'vitest'
55

6-
vi.mock('@sim/logger', () => {
7-
const createMockLogger = (): Record<string, any> => ({
8-
info: vi.fn(),
9-
warn: vi.fn(),
10-
error: vi.fn(),
11-
withMetadata: vi.fn(() => createMockLogger()),
12-
})
13-
return { createLogger: vi.fn(() => createMockLogger()) }
14-
})
6+
const { mockGetHighestPrioritySubscription } = vi.hoisted(() => ({
7+
mockGetHighestPrioritySubscription: vi.fn(),
8+
}))
159

1610
vi.mock('@/lib/billing/core/subscription', () => ({
17-
getUserSubscriptionState: vi.fn(),
11+
getHighestPrioritySubscription: mockGetHighestPrioritySubscription,
12+
}))
13+
14+
vi.mock('@/lib/billing/plan-helpers', () => ({
15+
isPaid: vi.fn(
16+
(plan: string | null) => plan === 'pro' || plan === 'team' || plan === 'enterprise'
17+
),
1818
}))
1919

2020
vi.mock('@/lib/copilot/chat-context', () => ({
@@ -57,48 +57,41 @@ vi.mock('@/tools/params', () => ({
5757
createUserToolSchema: vi.fn(() => ({ type: 'object', properties: {} })),
5858
}))
5959

60-
import { getUserSubscriptionState } from '@/lib/billing/core/subscription'
6160
import { buildIntegrationToolSchemas } from '@/lib/copilot/chat-payload'
6261

63-
const mockedGetUserSubscriptionState = getUserSubscriptionState as unknown as {
64-
mockResolvedValue: (value: unknown) => void
65-
mockRejectedValue: (value: unknown) => void
66-
mockClear: () => void
67-
}
68-
6962
describe('buildIntegrationToolSchemas', () => {
7063
beforeEach(() => {
7164
vi.clearAllMocks()
7265
})
7366

7467
it('appends the email footer prompt for free users', async () => {
75-
mockedGetUserSubscriptionState.mockResolvedValue({ isFree: true })
68+
mockGetHighestPrioritySubscription.mockResolvedValue(null)
7669

7770
const toolSchemas = await buildIntegrationToolSchemas('user-free')
7871
const gmailTool = toolSchemas.find((tool) => tool.name === 'gmail_send')
7972

80-
expect(getUserSubscriptionState).toHaveBeenCalledWith('user-free')
73+
expect(mockGetHighestPrioritySubscription).toHaveBeenCalledWith('user-free')
8174
expect(gmailTool?.description).toContain('sent with sim ai')
8275
})
8376

8477
it('does not append the email footer prompt for paid users', async () => {
85-
mockedGetUserSubscriptionState.mockResolvedValue({ isFree: false })
78+
mockGetHighestPrioritySubscription.mockResolvedValue({ plan: 'pro', status: 'active' })
8679

8780
const toolSchemas = await buildIntegrationToolSchemas('user-paid')
8881
const gmailTool = toolSchemas.find((tool) => tool.name === 'gmail_send')
8982

90-
expect(getUserSubscriptionState).toHaveBeenCalledWith('user-paid')
83+
expect(mockGetHighestPrioritySubscription).toHaveBeenCalledWith('user-paid')
9184
expect(gmailTool?.description).toBe('Send emails using Gmail')
9285
})
9386

9487
it('still builds integration tools when subscription lookup fails', async () => {
95-
mockedGetUserSubscriptionState.mockRejectedValue(new Error('db unavailable'))
88+
mockGetHighestPrioritySubscription.mockRejectedValue(new Error('db unavailable'))
9689

9790
const toolSchemas = await buildIntegrationToolSchemas('user-error')
9891
const gmailTool = toolSchemas.find((tool) => tool.name === 'gmail_send')
9992
const brandfetchTool = toolSchemas.find((tool) => tool.name === 'brandfetch_search')
10093

101-
expect(getUserSubscriptionState).toHaveBeenCalledWith('user-error')
94+
expect(mockGetHighestPrioritySubscription).toHaveBeenCalledWith('user-error')
10295
expect(gmailTool?.description).toBe('Send emails using Gmail')
10396
expect(brandfetchTool?.description).toBe('Search for brands by company name')
10497
})

0 commit comments

Comments
 (0)