From 1e89d147ed265af445bbe2fea46f86d8eab97531 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 23 Jan 2026 09:59:10 -0800 Subject: [PATCH 1/5] 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 0000000000..63021a6d30 --- /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 720c897d82..fe6006fadb 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) From 3bb85f22186411c942b96b19083a215e93dbea91 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 23 Jan 2026 10:44:03 -0800 Subject: [PATCH 2/5] fix(admin): use existing credit functions and handle enterprise seats --- apps/sim/app/api/v1/admin/credits/route.ts | 158 ++++++++------------- 1 file changed, 63 insertions(+), 95 deletions(-) diff --git a/apps/sim/app/api/v1/admin/credits/route.ts b/apps/sim/app/api/v1/admin/credits/route.ts index 63021a6d30..7ec2a81f87 100644 --- a/apps/sim/app/api/v1/admin/credits/route.ts +++ b/apps/sim/app/api/v1/admin/credits/route.ts @@ -26,10 +26,12 @@ 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 { and, eq } from 'drizzle-orm' import { nanoid } from 'nanoid' -import { getPlanPricing } from '@/lib/billing/core/billing' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' +import { addCredits } from '@/lib/billing/credits/balance' +import { setUsageLimitForCredits } from '@/lib/billing/credits/purchase' +import { getEffectiveSeats } from '@/lib/billing/subscriptions/utils' import { withAdminAuth } from '@/app/api/v1/admin/middleware' import { badRequestResponse, @@ -85,15 +87,20 @@ export const POST = withAdminAuth(async (request) => { const userSubscription = await getHighestPrioritySubscription(resolvedUserId) + if (!userSubscription || !['pro', 'team', 'enterprise'].includes(userSubscription.plan)) { + return badRequestResponse( + 'User must have an active Pro, Team, or Enterprise subscription to receive credits' + ) + } + let entityType: 'user' | 'organization' let entityId: string - let plan: string + const plan = userSubscription.plan let seats: number | null = null - if (userSubscription?.plan === 'team' || userSubscription?.plan === 'enterprise') { + if (plan === 'team' || plan === 'enterprise') { entityType = 'organization' entityId = userSubscription.referenceId - plan = userSubscription.plan const [orgExists] = await db .select({ id: organization.id }) @@ -106,106 +113,67 @@ export const POST = withAdminAuth(async (request) => { } const [subData] = await db - .select({ seats: subscription.seats }) + .select() .from(subscription) .where(and(eq(subscription.referenceId, entityId), eq(subscription.status, 'active'))) .limit(1) - seats = subData?.seats ?? null - } else if (userSubscription?.plan === 'pro') { + seats = getEffectiveSeats(subData) + } else { 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 - } + const [existingStats] = await db + .select({ id: userStats.id }) + .from(userStats) + .where(eq(userStats.userId, entityId)) + .limit(1) + + if (!existingStats) { + await db.insert(userStats).values({ + id: nanoid(), + userId: entityId, + }) } + } - return { newCreditBalance, newUsageLimit } - }) + await addCredits(entityType, entityId, amount) - const { newCreditBalance, newUsageLimit } = result + let newCreditBalance: number + if (entityType === 'organization') { + const [orgData] = await db + .select({ creditBalance: organization.creditBalance }) + .from(organization) + .where(eq(organization.id, entityId)) + .limit(1) + newCreditBalance = Number.parseFloat(orgData?.creditBalance || '0') + } else { + const [stats] = await db + .select({ creditBalance: userStats.creditBalance }) + .from(userStats) + .where(eq(userStats.userId, entityId)) + .limit(1) + newCreditBalance = Number.parseFloat(stats?.creditBalance || '0') + } + + await setUsageLimitForCredits(entityType, entityId, plan, seats, newCreditBalance) + + let newUsageLimit: number + if (entityType === 'organization') { + const [orgData] = await db + .select({ orgUsageLimit: organization.orgUsageLimit }) + .from(organization) + .where(eq(organization.id, entityId)) + .limit(1) + newUsageLimit = Number.parseFloat(orgData?.orgUsageLimit || '0') + } else { + const [stats] = await db + .select({ currentUsageLimit: userStats.currentUsageLimit }) + .from(userStats) + .where(eq(userStats.userId, entityId)) + .limit(1) + newUsageLimit = Number.parseFloat(stats?.currentUsageLimit || '0') + } logger.info('Admin API: Issued credits', { resolvedUserId, From 4f09d073839f38aeab124c320f6259284b6a3cf3 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 23 Jan 2026 10:46:29 -0800 Subject: [PATCH 3/5] fix(admin): reject NaN and Infinity in amount validation --- apps/sim/app/api/v1/admin/credits/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/app/api/v1/admin/credits/route.ts b/apps/sim/app/api/v1/admin/credits/route.ts index 7ec2a81f87..e6b2819643 100644 --- a/apps/sim/app/api/v1/admin/credits/route.ts +++ b/apps/sim/app/api/v1/admin/credits/route.ts @@ -51,7 +51,7 @@ export const POST = withAdminAuth(async (request) => { return badRequestResponse('Either userId or email is required') } - if (typeof amount !== 'number' || amount <= 0) { + if (typeof amount !== 'number' || !Number.isFinite(amount) || amount <= 0) { return badRequestResponse('amount must be a positive number') } From aed076620783a4132e74c52f25083beea92dd112 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 23 Jan 2026 11:06:36 -0800 Subject: [PATCH 4/5] styling --- .../components/access-control/access-control.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/access-control/access-control.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/access-control/access-control.tsx index 9b3fd8a02f..af7db3fcc9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/access-control/access-control.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/access-control/access-control.tsx @@ -688,7 +688,7 @@ export function AccessControl() { )} -
+
Auto-add new members @@ -705,7 +705,7 @@ export function AccessControl() {
-
+
Members From 932aef3c1b4f5f76837793e47ca01018e6e0774d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 23 Jan 2026 11:07:41 -0800 Subject: [PATCH 5/5] fix(admin): validate userId and email are strings --- apps/sim/app/api/v1/admin/credits/route.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/sim/app/api/v1/admin/credits/route.ts b/apps/sim/app/api/v1/admin/credits/route.ts index e6b2819643..e2bdfbe079 100644 --- a/apps/sim/app/api/v1/admin/credits/route.ts +++ b/apps/sim/app/api/v1/admin/credits/route.ts @@ -51,6 +51,14 @@ export const POST = withAdminAuth(async (request) => { return badRequestResponse('Either userId or email is required') } + if (userId && typeof userId !== 'string') { + return badRequestResponse('userId must be a string') + } + + if (email && typeof email !== 'string') { + return badRequestResponse('email must be a string') + } + if (typeof amount !== 'number' || !Number.isFinite(amount) || amount <= 0) { return badRequestResponse('amount must be a positive number') }