Skip to content

Commit 64efeaa

Browse files
authored
feat(admin): add credits endpoint to issue credits to users (#2954)
* feat(admin): add credits endpoint to issue credits to users * fix(admin): use existing credit functions and handle enterprise seats * fix(admin): reject NaN and Infinity in amount validation * styling * fix(admin): validate userId and email are strings
1 parent 9b72b52 commit 64efeaa

File tree

3 files changed

+216
-2
lines changed

3 files changed

+216
-2
lines changed
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
/**
2+
* POST /api/v1/admin/credits
3+
*
4+
* Issue credits to a user by user ID or email.
5+
*
6+
* Body:
7+
* - userId?: string - The user ID to issue credits to
8+
* - email?: string - The user email to issue credits to (alternative to userId)
9+
* - amount: number - The amount of credits to issue (in dollars)
10+
* - reason?: string - Reason for issuing credits (for audit logging)
11+
*
12+
* Response: AdminSingleResponse<{
13+
* success: true,
14+
* entityType: 'user' | 'organization',
15+
* entityId: string,
16+
* amount: number,
17+
* newCreditBalance: number,
18+
* newUsageLimit: number,
19+
* }>
20+
*
21+
* For Pro users: credits are added to user_stats.credit_balance
22+
* For Team users: credits are added to organization.credit_balance
23+
* Usage limits are updated accordingly to allow spending the credits.
24+
*/
25+
26+
import { db } from '@sim/db'
27+
import { organization, subscription, user, userStats } from '@sim/db/schema'
28+
import { createLogger } from '@sim/logger'
29+
import { and, eq } from 'drizzle-orm'
30+
import { nanoid } from 'nanoid'
31+
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
32+
import { addCredits } from '@/lib/billing/credits/balance'
33+
import { setUsageLimitForCredits } from '@/lib/billing/credits/purchase'
34+
import { getEffectiveSeats } from '@/lib/billing/subscriptions/utils'
35+
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
36+
import {
37+
badRequestResponse,
38+
internalErrorResponse,
39+
notFoundResponse,
40+
singleResponse,
41+
} from '@/app/api/v1/admin/responses'
42+
43+
const logger = createLogger('AdminCreditsAPI')
44+
45+
export const POST = withAdminAuth(async (request) => {
46+
try {
47+
const body = await request.json()
48+
const { userId, email, amount, reason } = body
49+
50+
if (!userId && !email) {
51+
return badRequestResponse('Either userId or email is required')
52+
}
53+
54+
if (userId && typeof userId !== 'string') {
55+
return badRequestResponse('userId must be a string')
56+
}
57+
58+
if (email && typeof email !== 'string') {
59+
return badRequestResponse('email must be a string')
60+
}
61+
62+
if (typeof amount !== 'number' || !Number.isFinite(amount) || amount <= 0) {
63+
return badRequestResponse('amount must be a positive number')
64+
}
65+
66+
let resolvedUserId: string
67+
let userEmail: string | null = null
68+
69+
if (userId) {
70+
const [userData] = await db
71+
.select({ id: user.id, email: user.email })
72+
.from(user)
73+
.where(eq(user.id, userId))
74+
.limit(1)
75+
76+
if (!userData) {
77+
return notFoundResponse('User')
78+
}
79+
resolvedUserId = userData.id
80+
userEmail = userData.email
81+
} else {
82+
const normalizedEmail = email.toLowerCase().trim()
83+
const [userData] = await db
84+
.select({ id: user.id, email: user.email })
85+
.from(user)
86+
.where(eq(user.email, normalizedEmail))
87+
.limit(1)
88+
89+
if (!userData) {
90+
return notFoundResponse('User with email')
91+
}
92+
resolvedUserId = userData.id
93+
userEmail = userData.email
94+
}
95+
96+
const userSubscription = await getHighestPrioritySubscription(resolvedUserId)
97+
98+
if (!userSubscription || !['pro', 'team', 'enterprise'].includes(userSubscription.plan)) {
99+
return badRequestResponse(
100+
'User must have an active Pro, Team, or Enterprise subscription to receive credits'
101+
)
102+
}
103+
104+
let entityType: 'user' | 'organization'
105+
let entityId: string
106+
const plan = userSubscription.plan
107+
let seats: number | null = null
108+
109+
if (plan === 'team' || plan === 'enterprise') {
110+
entityType = 'organization'
111+
entityId = userSubscription.referenceId
112+
113+
const [orgExists] = await db
114+
.select({ id: organization.id })
115+
.from(organization)
116+
.where(eq(organization.id, entityId))
117+
.limit(1)
118+
119+
if (!orgExists) {
120+
return notFoundResponse('Organization')
121+
}
122+
123+
const [subData] = await db
124+
.select()
125+
.from(subscription)
126+
.where(and(eq(subscription.referenceId, entityId), eq(subscription.status, 'active')))
127+
.limit(1)
128+
129+
seats = getEffectiveSeats(subData)
130+
} else {
131+
entityType = 'user'
132+
entityId = resolvedUserId
133+
134+
const [existingStats] = await db
135+
.select({ id: userStats.id })
136+
.from(userStats)
137+
.where(eq(userStats.userId, entityId))
138+
.limit(1)
139+
140+
if (!existingStats) {
141+
await db.insert(userStats).values({
142+
id: nanoid(),
143+
userId: entityId,
144+
})
145+
}
146+
}
147+
148+
await addCredits(entityType, entityId, amount)
149+
150+
let newCreditBalance: number
151+
if (entityType === 'organization') {
152+
const [orgData] = await db
153+
.select({ creditBalance: organization.creditBalance })
154+
.from(organization)
155+
.where(eq(organization.id, entityId))
156+
.limit(1)
157+
newCreditBalance = Number.parseFloat(orgData?.creditBalance || '0')
158+
} else {
159+
const [stats] = await db
160+
.select({ creditBalance: userStats.creditBalance })
161+
.from(userStats)
162+
.where(eq(userStats.userId, entityId))
163+
.limit(1)
164+
newCreditBalance = Number.parseFloat(stats?.creditBalance || '0')
165+
}
166+
167+
await setUsageLimitForCredits(entityType, entityId, plan, seats, newCreditBalance)
168+
169+
let newUsageLimit: number
170+
if (entityType === 'organization') {
171+
const [orgData] = await db
172+
.select({ orgUsageLimit: organization.orgUsageLimit })
173+
.from(organization)
174+
.where(eq(organization.id, entityId))
175+
.limit(1)
176+
newUsageLimit = Number.parseFloat(orgData?.orgUsageLimit || '0')
177+
} else {
178+
const [stats] = await db
179+
.select({ currentUsageLimit: userStats.currentUsageLimit })
180+
.from(userStats)
181+
.where(eq(userStats.userId, entityId))
182+
.limit(1)
183+
newUsageLimit = Number.parseFloat(stats?.currentUsageLimit || '0')
184+
}
185+
186+
logger.info('Admin API: Issued credits', {
187+
resolvedUserId,
188+
userEmail,
189+
entityType,
190+
entityId,
191+
amount,
192+
newCreditBalance,
193+
newUsageLimit,
194+
reason: reason || 'No reason provided',
195+
})
196+
197+
return singleResponse({
198+
success: true,
199+
userId: resolvedUserId,
200+
userEmail,
201+
entityType,
202+
entityId,
203+
amount,
204+
newCreditBalance,
205+
newUsageLimit,
206+
})
207+
} catch (error) {
208+
logger.error('Admin API: Failed to issue credits', { error })
209+
return internalErrorResponse('Failed to issue credits')
210+
}
211+
})

apps/sim/app/api/v1/admin/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@
6363
* GET /api/v1/admin/subscriptions/:id - Get subscription details
6464
* DELETE /api/v1/admin/subscriptions/:id - Cancel subscription (?atPeriodEnd=true for scheduled)
6565
*
66+
* Credits:
67+
* POST /api/v1/admin/credits - Issue credits to user (by userId or email)
68+
*
6669
* Access Control (Permission Groups):
6770
* GET /api/v1/admin/access-control - List permission groups (?organizationId=X)
6871
* DELETE /api/v1/admin/access-control - Delete permission groups for org (?organizationId=X)

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -688,7 +688,7 @@ export function AccessControl() {
688688
)}
689689
</div>
690690

691-
<div className='flex items-center justify-between rounded-[8px] border border-[var(--border)] px-[12px] py-[10px]'>
691+
<div className='flex items-center justify-between'>
692692
<div className='flex flex-col gap-[2px]'>
693693
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
694694
Auto-add new members
@@ -705,7 +705,7 @@ export function AccessControl() {
705705
</div>
706706

707707
<div className='min-h-0 flex-1 overflow-y-auto'>
708-
<div className='flex flex-col gap-[16px]'>
708+
<div className='flex flex-col gap-[8px]'>
709709
<div className='flex items-center justify-between'>
710710
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>
711711
Members

0 commit comments

Comments
 (0)