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:
Waleed Latif
2025-08-15 12:28:34 -07:00
committed by GitHub
parent 7d05999a70
commit f1fe2f52cc
14 changed files with 100 additions and 64 deletions

View File

@@ -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) {

View File

@@ -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 = {

View File

@@ -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

View File

@@ -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', {

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -187,7 +187,7 @@ export async function checkServerSideUsageLimits(userId: string): Promise<{
return {
isExceeded: false,
currentUsage: 0,
limit: 1000,
limit: 99999,
}
}

View File

@@ -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', () => {

View 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 }
}

View File

@@ -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', () => {

View File

@@ -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 }

View File

@@ -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

View File

@@ -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