From 1e89d147ed265af445bbe2fea46f86d8eab97531 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 23 Jan 2026 09:59:10 -0800 Subject: [PATCH] feat(admin): add credits endpoint to issue credits to users --- apps/sim/app/api/v1/admin/credits/route.ts | 235 +++++++++++++++++++++ apps/sim/app/api/v1/admin/index.ts | 3 + 2 files changed, 238 insertions(+) create mode 100644 apps/sim/app/api/v1/admin/credits/route.ts diff --git a/apps/sim/app/api/v1/admin/credits/route.ts b/apps/sim/app/api/v1/admin/credits/route.ts new file mode 100644 index 000000000..63021a6d3 --- /dev/null +++ b/apps/sim/app/api/v1/admin/credits/route.ts @@ -0,0 +1,235 @@ +/** + * POST /api/v1/admin/credits + * + * Issue credits to a user by user ID or email. + * + * Body: + * - userId?: string - The user ID to issue credits to + * - email?: string - The user email to issue credits to (alternative to userId) + * - amount: number - The amount of credits to issue (in dollars) + * - reason?: string - Reason for issuing credits (for audit logging) + * + * Response: AdminSingleResponse<{ + * success: true, + * entityType: 'user' | 'organization', + * entityId: string, + * amount: number, + * newCreditBalance: number, + * newUsageLimit: number, + * }> + * + * For Pro users: credits are added to user_stats.credit_balance + * For Team users: credits are added to organization.credit_balance + * Usage limits are updated accordingly to allow spending the credits. + */ + +import { db } from '@sim/db' +import { organization, subscription, user, userStats } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq, sql } from 'drizzle-orm' +import { nanoid } from 'nanoid' +import { getPlanPricing } from '@/lib/billing/core/billing' +import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' +import { withAdminAuth } from '@/app/api/v1/admin/middleware' +import { + badRequestResponse, + internalErrorResponse, + notFoundResponse, + singleResponse, +} from '@/app/api/v1/admin/responses' + +const logger = createLogger('AdminCreditsAPI') + +export const POST = withAdminAuth(async (request) => { + try { + const body = await request.json() + const { userId, email, amount, reason } = body + + if (!userId && !email) { + return badRequestResponse('Either userId or email is required') + } + + if (typeof amount !== 'number' || amount <= 0) { + return badRequestResponse('amount must be a positive number') + } + + let resolvedUserId: string + let userEmail: string | null = null + + if (userId) { + const [userData] = await db + .select({ id: user.id, email: user.email }) + .from(user) + .where(eq(user.id, userId)) + .limit(1) + + if (!userData) { + return notFoundResponse('User') + } + resolvedUserId = userData.id + userEmail = userData.email + } else { + const normalizedEmail = email.toLowerCase().trim() + const [userData] = await db + .select({ id: user.id, email: user.email }) + .from(user) + .where(eq(user.email, normalizedEmail)) + .limit(1) + + if (!userData) { + return notFoundResponse('User with email') + } + resolvedUserId = userData.id + userEmail = userData.email + } + + const userSubscription = await getHighestPrioritySubscription(resolvedUserId) + + let entityType: 'user' | 'organization' + let entityId: string + let plan: string + let seats: number | null = null + + if (userSubscription?.plan === 'team' || userSubscription?.plan === 'enterprise') { + entityType = 'organization' + entityId = userSubscription.referenceId + plan = userSubscription.plan + + const [orgExists] = await db + .select({ id: organization.id }) + .from(organization) + .where(eq(organization.id, entityId)) + .limit(1) + + if (!orgExists) { + return notFoundResponse('Organization') + } + + const [subData] = await db + .select({ seats: subscription.seats }) + .from(subscription) + .where(and(eq(subscription.referenceId, entityId), eq(subscription.status, 'active'))) + .limit(1) + + seats = subData?.seats ?? null + } else if (userSubscription?.plan === 'pro') { + entityType = 'user' + entityId = resolvedUserId + plan = 'pro' + } else { + return badRequestResponse( + 'User must have an active Pro or Team subscription to receive credits' + ) + } + + const { basePrice } = getPlanPricing(plan) + + const result = await db.transaction(async (tx) => { + let newCreditBalance: number + let newUsageLimit: number + + if (entityType === 'organization') { + await tx + .update(organization) + .set({ creditBalance: sql`${organization.creditBalance} + ${amount}` }) + .where(eq(organization.id, entityId)) + + const [orgData] = await tx + .select({ + creditBalance: organization.creditBalance, + orgUsageLimit: organization.orgUsageLimit, + }) + .from(organization) + .where(eq(organization.id, entityId)) + .limit(1) + + newCreditBalance = Number.parseFloat(orgData?.creditBalance || '0') + const currentLimit = Number.parseFloat(orgData?.orgUsageLimit || '0') + const planBase = Number(basePrice) * (seats || 1) + const calculatedLimit = planBase + newCreditBalance + + if (calculatedLimit > currentLimit) { + await tx + .update(organization) + .set({ orgUsageLimit: calculatedLimit.toString() }) + .where(eq(organization.id, entityId)) + newUsageLimit = calculatedLimit + } else { + newUsageLimit = currentLimit + } + } else { + const [existingStats] = await tx + .select({ id: userStats.id }) + .from(userStats) + .where(eq(userStats.userId, entityId)) + .limit(1) + + if (!existingStats) { + await tx.insert(userStats).values({ + id: nanoid(), + userId: entityId, + creditBalance: amount.toString(), + }) + } else { + await tx + .update(userStats) + .set({ creditBalance: sql`${userStats.creditBalance} + ${amount}` }) + .where(eq(userStats.userId, entityId)) + } + + const [stats] = await tx + .select({ + creditBalance: userStats.creditBalance, + currentUsageLimit: userStats.currentUsageLimit, + }) + .from(userStats) + .where(eq(userStats.userId, entityId)) + .limit(1) + + newCreditBalance = Number.parseFloat(stats?.creditBalance || '0') + const currentLimit = Number.parseFloat(stats?.currentUsageLimit || '0') + const planBase = Number(basePrice) + const calculatedLimit = planBase + newCreditBalance + + if (calculatedLimit > currentLimit) { + await tx + .update(userStats) + .set({ currentUsageLimit: calculatedLimit.toString() }) + .where(eq(userStats.userId, entityId)) + newUsageLimit = calculatedLimit + } else { + newUsageLimit = currentLimit + } + } + + return { newCreditBalance, newUsageLimit } + }) + + const { newCreditBalance, newUsageLimit } = result + + logger.info('Admin API: Issued credits', { + resolvedUserId, + userEmail, + entityType, + entityId, + amount, + newCreditBalance, + newUsageLimit, + reason: reason || 'No reason provided', + }) + + return singleResponse({ + success: true, + userId: resolvedUserId, + userEmail, + entityType, + entityId, + amount, + newCreditBalance, + newUsageLimit, + }) + } catch (error) { + logger.error('Admin API: Failed to issue credits', { error }) + return internalErrorResponse('Failed to issue credits') + } +}) diff --git a/apps/sim/app/api/v1/admin/index.ts b/apps/sim/app/api/v1/admin/index.ts index 720c897d8..fe6006fad 100644 --- a/apps/sim/app/api/v1/admin/index.ts +++ b/apps/sim/app/api/v1/admin/index.ts @@ -63,6 +63,9 @@ * GET /api/v1/admin/subscriptions/:id - Get subscription details * DELETE /api/v1/admin/subscriptions/:id - Cancel subscription (?atPeriodEnd=true for scheduled) * + * Credits: + * POST /api/v1/admin/credits - Issue credits to user (by userId or email) + * * Access Control (Permission Groups): * GET /api/v1/admin/access-control - List permission groups (?organizationId=X) * DELETE /api/v1/admin/access-control - Delete permission groups for org (?organizationId=X)