mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
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
This commit is contained in:
@@ -2,8 +2,9 @@ import crypto from 'crypto'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalApiKey } from '@/lib/copilot/utils'
|
||||
import { env } from '@/lib/env'
|
||||
import { isProd } from '@/lib/environment'
|
||||
import { isBillingEnabled, isProd } from '@/lib/environment'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
import { userStats } from '@/db/schema'
|
||||
@@ -11,7 +12,6 @@ import { calculateCost } from '@/providers/utils'
|
||||
|
||||
const logger = createLogger('billing-update-cost')
|
||||
|
||||
// Schema for the request body
|
||||
const UpdateCostSchema = z.object({
|
||||
userId: z.string().min(1, 'User ID is required'),
|
||||
input: z.number().min(0, 'Input tokens must be a non-negative number'),
|
||||
@@ -19,26 +19,6 @@ const UpdateCostSchema = z.object({
|
||||
model: z.string().min(1, 'Model is required'),
|
||||
})
|
||||
|
||||
// Authentication function (reused from copilot/methods route)
|
||||
function checkInternalApiKey(req: NextRequest) {
|
||||
const apiKey = req.headers.get('x-api-key')
|
||||
const expectedApiKey = env.INTERNAL_API_SECRET
|
||||
|
||||
if (!expectedApiKey) {
|
||||
return { success: false, error: 'Internal API key not configured' }
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
return { success: false, error: 'API key required' }
|
||||
}
|
||||
|
||||
if (apiKey !== expectedApiKey) {
|
||||
return { success: false, error: 'Invalid API key' }
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/billing/update-cost
|
||||
* Update user cost based on token usage with internal API key auth
|
||||
@@ -50,6 +30,19 @@ export async function POST(req: NextRequest) {
|
||||
try {
|
||||
logger.info(`[${requestId}] Update cost request started`)
|
||||
|
||||
if (!isBillingEnabled) {
|
||||
logger.debug(`[${requestId}] Billing is disabled, skipping cost update`)
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Billing disabled, cost update skipped',
|
||||
data: {
|
||||
billingEnabled: false,
|
||||
processedAt: new Date().toISOString(),
|
||||
requestId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Check authentication (internal API key)
|
||||
const authResult = checkInternalApiKey(req)
|
||||
if (!authResult.success) {
|
||||
|
||||
@@ -246,7 +246,10 @@ describe('Chat API Route', () => {
|
||||
NEXT_PUBLIC_APP_URL: 'http://localhost:3000',
|
||||
},
|
||||
isTruthy: (value: string | boolean | number | undefined) =>
|
||||
typeof value === 'string' ? value === 'true' || value === '1' : Boolean(value),
|
||||
typeof value === 'string'
|
||||
? value.toLowerCase() === 'true' || value === '1'
|
||||
: Boolean(value),
|
||||
getEnv: (variable: string) => process.env[variable],
|
||||
}))
|
||||
|
||||
const validData = {
|
||||
@@ -291,6 +294,7 @@ describe('Chat API Route', () => {
|
||||
},
|
||||
isTruthy: (value: string | boolean | number | undefined) =>
|
||||
typeof value === 'string' ? value === 'true' || value === '1' : Boolean(value),
|
||||
getEnv: (variable: string) => process.env[variable],
|
||||
}))
|
||||
|
||||
const validData = {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { copilotToolRegistry } from '@/lib/copilot/tools/server-tools/registry'
|
||||
import type { NotificationStatus } from '@/lib/copilot/types'
|
||||
import { env } from '@/lib/env'
|
||||
import { checkInternalApiKey } from '@/lib/copilot/utils'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getRedisClient } from '@/lib/redis'
|
||||
import { createErrorResponse } from '@/app/api/copilot/methods/utils'
|
||||
@@ -240,33 +240,12 @@ async function interruptHandler(toolCallId: string): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
// Schema for method execution
|
||||
const MethodExecutionSchema = z.object({
|
||||
methodId: z.string().min(1, 'Method ID is required'),
|
||||
params: z.record(z.any()).optional().default({}),
|
||||
toolCallId: z.string().nullable().optional().default(null),
|
||||
})
|
||||
|
||||
// Simple internal API key authentication
|
||||
function checkInternalApiKey(req: NextRequest) {
|
||||
const apiKey = req.headers.get('x-api-key')
|
||||
const expectedApiKey = env.INTERNAL_API_SECRET
|
||||
|
||||
if (!expectedApiKey) {
|
||||
return { success: false, error: 'Internal API key not configured' }
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
return { success: false, error: 'API key required' }
|
||||
}
|
||||
|
||||
if (apiKey !== expectedApiKey) {
|
||||
return { success: false, error: 'Invalid API key' }
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/copilot/methods
|
||||
* Execute a method based on methodId with internal API key auth
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { tasks } from '@trigger.dev/sdk/v3'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkServerSideUsageLimits } from '@/lib/billing'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
handleSlackChallenge,
|
||||
@@ -245,7 +246,44 @@ export async function POST(
|
||||
// Continue processing - better to risk rate limit bypass than fail webhook
|
||||
}
|
||||
|
||||
// --- PHASE 4: Queue webhook execution via trigger.dev ---
|
||||
// --- PHASE 4: Usage limit check ---
|
||||
try {
|
||||
const usageCheck = await checkServerSideUsageLimits(foundWorkflow.userId)
|
||||
if (usageCheck.isExceeded) {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${foundWorkflow.userId} has exceeded usage limits. Skipping webhook execution.`,
|
||||
{
|
||||
currentUsage: usageCheck.currentUsage,
|
||||
limit: usageCheck.limit,
|
||||
workflowId: foundWorkflow.id,
|
||||
provider: foundWebhook.provider,
|
||||
}
|
||||
)
|
||||
|
||||
// Return 200 to prevent webhook provider retries, but indicate usage limit exceeded
|
||||
if (foundWebhook.provider === 'microsoftteams') {
|
||||
// Microsoft Teams requires specific response format
|
||||
return NextResponse.json({
|
||||
type: 'message',
|
||||
text: 'Usage limit exceeded. Please upgrade your plan to continue.',
|
||||
})
|
||||
}
|
||||
|
||||
// Simple error response for other providers (return 200 to prevent retries)
|
||||
return NextResponse.json({ message: 'Usage limit exceeded' }, { status: 200 })
|
||||
}
|
||||
|
||||
logger.debug(`[${requestId}] Usage limit check passed for webhook`, {
|
||||
provider: foundWebhook.provider,
|
||||
currentUsage: usageCheck.currentUsage,
|
||||
limit: usageCheck.limit,
|
||||
})
|
||||
} catch (usageError) {
|
||||
logger.error(`[${requestId}] Error checking webhook usage limits:`, usageError)
|
||||
// Continue processing - better to risk usage limit bypass than fail webhook
|
||||
}
|
||||
|
||||
// --- PHASE 5: Queue webhook execution via trigger.dev ---
|
||||
try {
|
||||
// Queue the webhook execution task
|
||||
const handle = await tasks.trigger('webhook-execution', {
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
UserCircle,
|
||||
Users,
|
||||
} from 'lucide-react'
|
||||
import { getEnv } from '@/lib/env'
|
||||
import { isBillingEnabled } from '@/lib/environment'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSubscriptionStore } from '@/stores/subscription/store'
|
||||
|
||||
@@ -98,9 +98,6 @@ export function SettingsNavigation({
|
||||
const { getSubscriptionStatus } = useSubscriptionStore()
|
||||
const subscription = getSubscriptionStatus()
|
||||
|
||||
// Get billing status
|
||||
const isBillingEnabled = getEnv('NEXT_PUBLIC_BILLING_ENABLED') || false
|
||||
|
||||
const navigationItems = allNavigationItems.filter((item) => {
|
||||
if (item.hideWhenBillingDisabled && !isBillingEnabled) {
|
||||
return false
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { Button, Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui'
|
||||
import { getEnv } from '@/lib/env'
|
||||
import { isBillingEnabled } from '@/lib/environment'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
@@ -44,9 +44,6 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
const { activeOrganization } = useOrganizationStore()
|
||||
const hasLoadedInitialData = useRef(false)
|
||||
|
||||
// Get billing status
|
||||
const isBillingEnabled = getEnv('NEXT_PUBLIC_BILLING_ENABLED') || false
|
||||
|
||||
useEffect(() => {
|
||||
async function loadAllSettings() {
|
||||
if (!open) return
|
||||
|
||||
@@ -5,7 +5,7 @@ import { HelpCircle, LibraryBig, ScrollText, Search, Settings, Shapes } from 'lu
|
||||
import { useParams, usePathname, useRouter } from 'next/navigation'
|
||||
import { Button, ScrollArea, Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { getEnv } from '@/lib/env'
|
||||
import { isBillingEnabled } from '@/lib/environment'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { generateWorkspaceName } from '@/lib/naming'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -196,9 +196,6 @@ export function Sidebar() {
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
const isLoading = workflowsLoading || sessionLoading
|
||||
|
||||
// Get billing status
|
||||
const isBillingEnabled = getEnv('NEXT_PUBLIC_BILLING_ENABLED') || false
|
||||
|
||||
// Add state to prevent multiple simultaneous workflow creations
|
||||
const [isCreatingWorkflow, setIsCreatingWorkflow] = useState(false)
|
||||
// Add state to prevent multiple simultaneous workspace creations
|
||||
|
||||
@@ -187,7 +187,7 @@ export async function checkServerSideUsageLimits(userId: string): Promise<{
|
||||
return {
|
||||
isExceeded: false,
|
||||
currentUsage: 0,
|
||||
limit: 1000,
|
||||
limit: 99999,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ vi.mock('@/lib/env', () => ({
|
||||
TEAM_TIER_COST_LIMIT: 40,
|
||||
ENTERPRISE_TIER_COST_LIMIT: 200,
|
||||
},
|
||||
isTruthy: (value: string | boolean | number | undefined) =>
|
||||
typeof value === 'string' ? value.toLowerCase() === 'true' || value === '1' : Boolean(value),
|
||||
getEnv: (variable: string) => process.env[variable],
|
||||
}))
|
||||
|
||||
describe('Subscription Utilities', () => {
|
||||
|
||||
21
apps/sim/lib/copilot/utils.ts
Normal file
21
apps/sim/lib/copilot/utils.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { env } from '@/lib/env'
|
||||
|
||||
export function checkInternalApiKey(req: NextRequest) {
|
||||
const apiKey = req.headers.get('x-api-key')
|
||||
const expectedApiKey = env.INTERNAL_API_SECRET
|
||||
|
||||
if (!expectedApiKey) {
|
||||
return { success: false, error: 'Internal API key not configured' }
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
return { success: false, error: 'API key required' }
|
||||
}
|
||||
|
||||
if (apiKey !== expectedApiKey) {
|
||||
return { success: false, error: 'Invalid API key' }
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
@@ -12,6 +12,7 @@ vi.mock('@/lib/env', () => ({
|
||||
},
|
||||
isTruthy: (value: string | boolean | number | undefined) =>
|
||||
typeof value === 'string' ? value === 'true' || value === '1' : Boolean(value),
|
||||
getEnv: (variable: string) => process.env[variable],
|
||||
}))
|
||||
|
||||
describe('unsubscribe utilities', () => {
|
||||
|
||||
@@ -32,7 +32,6 @@ export const env = createEnv({
|
||||
REDIS_URL: z.string().url().optional(), // Redis connection string for caching/sessions
|
||||
|
||||
// Payment & Billing
|
||||
BILLING_ENABLED: z.boolean().optional(), // Enable billing enforcement and usage tracking
|
||||
STRIPE_SECRET_KEY: z.string().min(1).optional(), // Stripe secret key for payment processing
|
||||
STRIPE_BILLING_WEBHOOK_SECRET: z.string().min(1).optional(), // Webhook secret for billing events
|
||||
STRIPE_WEBHOOK_SECRET: z.string().min(1).optional(), // General Stripe webhook secret
|
||||
@@ -44,6 +43,7 @@ export const env = createEnv({
|
||||
TEAM_TIER_COST_LIMIT: z.number().optional(), // Cost limit for team tier users
|
||||
STRIPE_ENTERPRISE_PRICE_ID: z.string().min(1).optional(), // Stripe price ID for enterprise tier
|
||||
ENTERPRISE_TIER_COST_LIMIT: z.number().optional(), // Cost limit for enterprise tier users
|
||||
BILLING_ENABLED: z.boolean().optional(), // Enable billing enforcement and usage tracking
|
||||
|
||||
// Email & Communication
|
||||
RESEND_API_KEY: z.string().min(1).optional(), // Resend API key for transactional emails
|
||||
@@ -234,6 +234,6 @@ export const env = createEnv({
|
||||
|
||||
// Need this utility because t3-env is returning string for boolean values.
|
||||
export const isTruthy = (value: string | boolean | number | undefined) =>
|
||||
typeof value === 'string' ? value === 'true' || value === '1' : Boolean(value)
|
||||
typeof value === 'string' ? value.toLowerCase() === 'true' || value === '1' : Boolean(value)
|
||||
|
||||
export { getEnv }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Environment utility functions for consistent environment detection across the application
|
||||
*/
|
||||
import { env, isTruthy } from './env'
|
||||
import { env, getEnv, isTruthy } from './env'
|
||||
|
||||
/**
|
||||
* Is the application running in production mode
|
||||
@@ -26,7 +26,8 @@ export const isHosted = env.NEXT_PUBLIC_APP_URL === 'https://www.sim.ai'
|
||||
/**
|
||||
* Is billing enforcement enabled
|
||||
*/
|
||||
export const isBillingEnabled = isTruthy(env.BILLING_ENABLED)
|
||||
export const isBillingEnabled =
|
||||
isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED')) || isTruthy(env.BILLING_ENABLED)
|
||||
|
||||
/**
|
||||
* Get cost multiplier based on environment
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { getCostMultiplier } from '@/lib/environment'
|
||||
import { getCostMultiplier, isBillingEnabled } from '@/lib/environment'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { snapshotService } from '@/lib/logs/execution/snapshot/service'
|
||||
import type {
|
||||
@@ -274,6 +274,11 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
},
|
||||
trigger: ExecutionTrigger['type']
|
||||
): Promise<void> {
|
||||
if (!isBillingEnabled) {
|
||||
logger.debug('Billing is disabled, skipping user stats cost update')
|
||||
return
|
||||
}
|
||||
|
||||
if (costSummary.totalCost <= 0) {
|
||||
logger.debug('No cost to update in user stats')
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user