Skip to content

Commit f1fe2f5

Browse files
authored
improvement(billing): add billing enforcement for webhook executions, consolidate helpers (#975)
* fix(billing): clinet-side envvar for billing * remove unrelated files * fix(billing): add billing enforcement for webhook executions, consolidate implementation * cleanup * add back server envvar
1 parent 7d05999 commit f1fe2f5

File tree

14 files changed

+100
-64
lines changed

14 files changed

+100
-64
lines changed

apps/sim/app/api/billing/update-cost/route.ts

Lines changed: 15 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,43 +2,23 @@ import crypto from 'crypto'
22
import { eq, sql } from 'drizzle-orm'
33
import { type NextRequest, NextResponse } from 'next/server'
44
import { z } from 'zod'
5+
import { checkInternalApiKey } from '@/lib/copilot/utils'
56
import { env } from '@/lib/env'
6-
import { isProd } from '@/lib/environment'
7+
import { isBillingEnabled, isProd } from '@/lib/environment'
78
import { createLogger } from '@/lib/logs/console/logger'
89
import { db } from '@/db'
910
import { userStats } from '@/db/schema'
1011
import { calculateCost } from '@/providers/utils'
1112

1213
const logger = createLogger('billing-update-cost')
1314

14-
// Schema for the request body
1515
const UpdateCostSchema = z.object({
1616
userId: z.string().min(1, 'User ID is required'),
1717
input: z.number().min(0, 'Input tokens must be a non-negative number'),
1818
output: z.number().min(0, 'Output tokens must be a non-negative number'),
1919
model: z.string().min(1, 'Model is required'),
2020
})
2121

22-
// Authentication function (reused from copilot/methods route)
23-
function checkInternalApiKey(req: NextRequest) {
24-
const apiKey = req.headers.get('x-api-key')
25-
const expectedApiKey = env.INTERNAL_API_SECRET
26-
27-
if (!expectedApiKey) {
28-
return { success: false, error: 'Internal API key not configured' }
29-
}
30-
31-
if (!apiKey) {
32-
return { success: false, error: 'API key required' }
33-
}
34-
35-
if (apiKey !== expectedApiKey) {
36-
return { success: false, error: 'Invalid API key' }
37-
}
38-
39-
return { success: true }
40-
}
41-
4222
/**
4323
* POST /api/billing/update-cost
4424
* Update user cost based on token usage with internal API key auth
@@ -50,6 +30,19 @@ export async function POST(req: NextRequest) {
5030
try {
5131
logger.info(`[${requestId}] Update cost request started`)
5232

33+
if (!isBillingEnabled) {
34+
logger.debug(`[${requestId}] Billing is disabled, skipping cost update`)
35+
return NextResponse.json({
36+
success: true,
37+
message: 'Billing disabled, cost update skipped',
38+
data: {
39+
billingEnabled: false,
40+
processedAt: new Date().toISOString(),
41+
requestId,
42+
},
43+
})
44+
}
45+
5346
// Check authentication (internal API key)
5447
const authResult = checkInternalApiKey(req)
5548
if (!authResult.success) {

apps/sim/app/api/chat/route.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,10 @@ describe('Chat API Route', () => {
246246
NEXT_PUBLIC_APP_URL: 'http://localhost:3000',
247247
},
248248
isTruthy: (value: string | boolean | number | undefined) =>
249-
typeof value === 'string' ? value === 'true' || value === '1' : Boolean(value),
249+
typeof value === 'string'
250+
? value.toLowerCase() === 'true' || value === '1'
251+
: Boolean(value),
252+
getEnv: (variable: string) => process.env[variable],
250253
}))
251254

252255
const validData = {
@@ -291,6 +294,7 @@ describe('Chat API Route', () => {
291294
},
292295
isTruthy: (value: string | boolean | number | undefined) =>
293296
typeof value === 'string' ? value === 'true' || value === '1' : Boolean(value),
297+
getEnv: (variable: string) => process.env[variable],
294298
}))
295299

296300
const validData = {

apps/sim/app/api/copilot/methods/route.ts

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { type NextRequest, NextResponse } from 'next/server'
22
import { z } from 'zod'
33
import { copilotToolRegistry } from '@/lib/copilot/tools/server-tools/registry'
44
import type { NotificationStatus } from '@/lib/copilot/types'
5-
import { env } from '@/lib/env'
5+
import { checkInternalApiKey } from '@/lib/copilot/utils'
66
import { createLogger } from '@/lib/logs/console/logger'
77
import { getRedisClient } from '@/lib/redis'
88
import { createErrorResponse } from '@/app/api/copilot/methods/utils'
@@ -240,33 +240,12 @@ async function interruptHandler(toolCallId: string): Promise<{
240240
}
241241
}
242242

243-
// Schema for method execution
244243
const MethodExecutionSchema = z.object({
245244
methodId: z.string().min(1, 'Method ID is required'),
246245
params: z.record(z.any()).optional().default({}),
247246
toolCallId: z.string().nullable().optional().default(null),
248247
})
249248

250-
// Simple internal API key authentication
251-
function checkInternalApiKey(req: NextRequest) {
252-
const apiKey = req.headers.get('x-api-key')
253-
const expectedApiKey = env.INTERNAL_API_SECRET
254-
255-
if (!expectedApiKey) {
256-
return { success: false, error: 'Internal API key not configured' }
257-
}
258-
259-
if (!apiKey) {
260-
return { success: false, error: 'API key required' }
261-
}
262-
263-
if (apiKey !== expectedApiKey) {
264-
return { success: false, error: 'Invalid API key' }
265-
}
266-
267-
return { success: true }
268-
}
269-
270249
/**
271250
* POST /api/copilot/methods
272251
* Execute a method based on methodId with internal API key auth

apps/sim/app/api/webhooks/trigger/[path]/route.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { tasks } from '@trigger.dev/sdk/v3'
22
import { and, eq } from 'drizzle-orm'
33
import { type NextRequest, NextResponse } from 'next/server'
4+
import { checkServerSideUsageLimits } from '@/lib/billing'
45
import { createLogger } from '@/lib/logs/console/logger'
56
import {
67
handleSlackChallenge,
@@ -245,7 +246,44 @@ export async function POST(
245246
// Continue processing - better to risk rate limit bypass than fail webhook
246247
}
247248

248-
// --- PHASE 4: Queue webhook execution via trigger.dev ---
249+
// --- PHASE 4: Usage limit check ---
250+
try {
251+
const usageCheck = await checkServerSideUsageLimits(foundWorkflow.userId)
252+
if (usageCheck.isExceeded) {
253+
logger.warn(
254+
`[${requestId}] User ${foundWorkflow.userId} has exceeded usage limits. Skipping webhook execution.`,
255+
{
256+
currentUsage: usageCheck.currentUsage,
257+
limit: usageCheck.limit,
258+
workflowId: foundWorkflow.id,
259+
provider: foundWebhook.provider,
260+
}
261+
)
262+
263+
// Return 200 to prevent webhook provider retries, but indicate usage limit exceeded
264+
if (foundWebhook.provider === 'microsoftteams') {
265+
// Microsoft Teams requires specific response format
266+
return NextResponse.json({
267+
type: 'message',
268+
text: 'Usage limit exceeded. Please upgrade your plan to continue.',
269+
})
270+
}
271+
272+
// Simple error response for other providers (return 200 to prevent retries)
273+
return NextResponse.json({ message: 'Usage limit exceeded' }, { status: 200 })
274+
}
275+
276+
logger.debug(`[${requestId}] Usage limit check passed for webhook`, {
277+
provider: foundWebhook.provider,
278+
currentUsage: usageCheck.currentUsage,
279+
limit: usageCheck.limit,
280+
})
281+
} catch (usageError) {
282+
logger.error(`[${requestId}] Error checking webhook usage limits:`, usageError)
283+
// Continue processing - better to risk usage limit bypass than fail webhook
284+
}
285+
286+
// --- PHASE 5: Queue webhook execution via trigger.dev ---
249287
try {
250288
// Queue the webhook execution task
251289
const handle = await tasks.trigger('webhook-execution', {

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/settings-navigation/settings-navigation.tsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
UserCircle,
99
Users,
1010
} from 'lucide-react'
11-
import { getEnv } from '@/lib/env'
11+
import { isBillingEnabled } from '@/lib/environment'
1212
import { cn } from '@/lib/utils'
1313
import { useSubscriptionStore } from '@/stores/subscription/store'
1414

@@ -98,9 +98,6 @@ export function SettingsNavigation({
9898
const { getSubscriptionStatus } = useSubscriptionStore()
9999
const subscription = getSubscriptionStatus()
100100

101-
// Get billing status
102-
const isBillingEnabled = getEnv('NEXT_PUBLIC_BILLING_ENABLED') || false
103-
104101
const navigationItems = allNavigationItems.filter((item) => {
105102
if (item.hideWhenBillingDisabled && !isBillingEnabled) {
106103
return false

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { useEffect, useRef, useState } from 'react'
44
import { X } from 'lucide-react'
55
import { Button, Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui'
6-
import { getEnv } from '@/lib/env'
6+
import { isBillingEnabled } from '@/lib/environment'
77
import { createLogger } from '@/lib/logs/console/logger'
88
import { cn } from '@/lib/utils'
99
import {
@@ -44,9 +44,6 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
4444
const { activeOrganization } = useOrganizationStore()
4545
const hasLoadedInitialData = useRef(false)
4646

47-
// Get billing status
48-
const isBillingEnabled = getEnv('NEXT_PUBLIC_BILLING_ENABLED') || false
49-
5047
useEffect(() => {
5148
async function loadAllSettings() {
5249
if (!open) return

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { HelpCircle, LibraryBig, ScrollText, Search, Settings, Shapes } from 'lu
55
import { useParams, usePathname, useRouter } from 'next/navigation'
66
import { Button, ScrollArea, Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui'
77
import { useSession } from '@/lib/auth-client'
8-
import { getEnv } from '@/lib/env'
8+
import { isBillingEnabled } from '@/lib/environment'
99
import { createLogger } from '@/lib/logs/console/logger'
1010
import { generateWorkspaceName } from '@/lib/naming'
1111
import { cn } from '@/lib/utils'
@@ -196,9 +196,6 @@ export function Sidebar() {
196196
const userPermissions = useUserPermissionsContext()
197197
const isLoading = workflowsLoading || sessionLoading
198198

199-
// Get billing status
200-
const isBillingEnabled = getEnv('NEXT_PUBLIC_BILLING_ENABLED') || false
201-
202199
// Add state to prevent multiple simultaneous workflow creations
203200
const [isCreatingWorkflow, setIsCreatingWorkflow] = useState(false)
204201
// Add state to prevent multiple simultaneous workspace creations

apps/sim/lib/billing/calculations/usage-monitor.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ export async function checkServerSideUsageLimits(userId: string): Promise<{
187187
return {
188188
isExceeded: false,
189189
currentUsage: 0,
190-
limit: 1000,
190+
limit: 99999,
191191
}
192192
}
193193

apps/sim/lib/billing/subscriptions/utils.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ vi.mock('@/lib/env', () => ({
88
TEAM_TIER_COST_LIMIT: 40,
99
ENTERPRISE_TIER_COST_LIMIT: 200,
1010
},
11+
isTruthy: (value: string | boolean | number | undefined) =>
12+
typeof value === 'string' ? value.toLowerCase() === 'true' || value === '1' : Boolean(value),
13+
getEnv: (variable: string) => process.env[variable],
1114
}))
1215

1316
describe('Subscription Utilities', () => {

apps/sim/lib/copilot/utils.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { NextRequest } from 'next/server'
2+
import { env } from '@/lib/env'
3+
4+
export function checkInternalApiKey(req: NextRequest) {
5+
const apiKey = req.headers.get('x-api-key')
6+
const expectedApiKey = env.INTERNAL_API_SECRET
7+
8+
if (!expectedApiKey) {
9+
return { success: false, error: 'Internal API key not configured' }
10+
}
11+
12+
if (!apiKey) {
13+
return { success: false, error: 'API key required' }
14+
}
15+
16+
if (apiKey !== expectedApiKey) {
17+
return { success: false, error: 'Invalid API key' }
18+
}
19+
20+
return { success: true }
21+
}

0 commit comments

Comments
 (0)