Skip to content

Commit 9f884c1

Browse files
feat(credits): prepurchase credits (#2174)
* add credit balances * add migrations * remove handling for disputes * fix idempotency key * prep merge into staging * code cleanup * add back migration + prevent enterprise from purchasing credits * remove circular import * add dispute blocking * fix lint * fix: hydration error * remove migration before merge staging ' * moved credits addition to invoice payment success --------- Co-authored-by: Emir Karabeg <[email protected]>
1 parent 92c03b8 commit 9f884c1

File tree

29 files changed

+1555
-137
lines changed

29 files changed

+1555
-137
lines changed
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { type NextRequest, NextResponse } from 'next/server'
2+
import { z } from 'zod'
3+
import { getSession } from '@/lib/auth'
4+
import { getCreditBalance } from '@/lib/billing/credits/balance'
5+
import { purchaseCredits } from '@/lib/billing/credits/purchase'
6+
import { createLogger } from '@/lib/logs/console/logger'
7+
8+
const logger = createLogger('CreditsAPI')
9+
10+
const PurchaseSchema = z.object({
11+
amount: z.number().min(10).max(1000),
12+
requestId: z.string().uuid(),
13+
})
14+
15+
export async function GET() {
16+
const session = await getSession()
17+
if (!session?.user?.id) {
18+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
19+
}
20+
21+
try {
22+
const { balance, entityType, entityId } = await getCreditBalance(session.user.id)
23+
return NextResponse.json({
24+
success: true,
25+
data: { balance, entityType, entityId },
26+
})
27+
} catch (error) {
28+
logger.error('Failed to get credit balance', { error, userId: session.user.id })
29+
return NextResponse.json({ error: 'Failed to get credit balance' }, { status: 500 })
30+
}
31+
}
32+
33+
export async function POST(request: NextRequest) {
34+
const session = await getSession()
35+
if (!session?.user?.id) {
36+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
37+
}
38+
39+
try {
40+
const body = await request.json()
41+
const validation = PurchaseSchema.safeParse(body)
42+
43+
if (!validation.success) {
44+
return NextResponse.json(
45+
{ error: 'Invalid amount. Must be between $10 and $1000' },
46+
{ status: 400 }
47+
)
48+
}
49+
50+
const result = await purchaseCredits({
51+
userId: session.user.id,
52+
amountDollars: validation.data.amount,
53+
requestId: validation.data.requestId,
54+
})
55+
56+
if (!result.success) {
57+
return NextResponse.json({ error: result.error }, { status: 400 })
58+
}
59+
60+
return NextResponse.json({ success: true })
61+
} catch (error) {
62+
logger.error('Failed to purchase credits', { error, userId: session.user.id })
63+
return NextResponse.json({ error: 'Failed to purchase credits' }, { status: 500 })
64+
}
65+
}

apps/sim/app/api/billing/route.ts

Lines changed: 82 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,76 @@ import { getSimplifiedBillingSummary } from '@/lib/billing/core/billing'
77
import { getOrganizationBillingData } from '@/lib/billing/core/organization'
88
import { createLogger } from '@/lib/logs/console/logger'
99

10+
/**
11+
* Gets the effective billing blocked status for a user.
12+
* If user is in an org, also checks if the org owner is blocked.
13+
*/
14+
async function getEffectiveBillingStatus(userId: string): Promise<{
15+
billingBlocked: boolean
16+
billingBlockedReason: 'payment_failed' | 'dispute' | null
17+
blockedByOrgOwner: boolean
18+
}> {
19+
// Check user's own status
20+
const userStatsRows = await db
21+
.select({
22+
blocked: userStats.billingBlocked,
23+
blockedReason: userStats.billingBlockedReason,
24+
})
25+
.from(userStats)
26+
.where(eq(userStats.userId, userId))
27+
.limit(1)
28+
29+
const userBlocked = userStatsRows.length > 0 ? !!userStatsRows[0].blocked : false
30+
const userBlockedReason = userStatsRows.length > 0 ? userStatsRows[0].blockedReason : null
31+
32+
if (userBlocked) {
33+
return {
34+
billingBlocked: true,
35+
billingBlockedReason: userBlockedReason,
36+
blockedByOrgOwner: false,
37+
}
38+
}
39+
40+
// Check if user is in an org where owner is blocked
41+
const memberships = await db
42+
.select({ organizationId: member.organizationId })
43+
.from(member)
44+
.where(eq(member.userId, userId))
45+
46+
for (const m of memberships) {
47+
const owners = await db
48+
.select({ userId: member.userId })
49+
.from(member)
50+
.where(and(eq(member.organizationId, m.organizationId), eq(member.role, 'owner')))
51+
.limit(1)
52+
53+
if (owners.length > 0 && owners[0].userId !== userId) {
54+
const ownerStats = await db
55+
.select({
56+
blocked: userStats.billingBlocked,
57+
blockedReason: userStats.billingBlockedReason,
58+
})
59+
.from(userStats)
60+
.where(eq(userStats.userId, owners[0].userId))
61+
.limit(1)
62+
63+
if (ownerStats.length > 0 && ownerStats[0].blocked) {
64+
return {
65+
billingBlocked: true,
66+
billingBlockedReason: ownerStats[0].blockedReason,
67+
blockedByOrgOwner: true,
68+
}
69+
}
70+
}
71+
}
72+
73+
return {
74+
billingBlocked: false,
75+
billingBlockedReason: null,
76+
blockedByOrgOwner: false,
77+
}
78+
}
79+
1080
const logger = createLogger('UnifiedBillingAPI')
1181

1282
/**
@@ -45,15 +115,13 @@ export async function GET(request: NextRequest) {
45115
if (context === 'user') {
46116
// Get user billing (may include organization if they're part of one)
47117
billingData = await getSimplifiedBillingSummary(session.user.id, contextId || undefined)
48-
// Attach billingBlocked status for the current user
49-
const stats = await db
50-
.select({ blocked: userStats.billingBlocked })
51-
.from(userStats)
52-
.where(eq(userStats.userId, session.user.id))
53-
.limit(1)
118+
// Attach effective billing blocked status (includes org owner check)
119+
const billingStatus = await getEffectiveBillingStatus(session.user.id)
54120
billingData = {
55121
...billingData,
56-
billingBlocked: stats.length > 0 ? !!stats[0].blocked : false,
122+
billingBlocked: billingStatus.billingBlocked,
123+
billingBlockedReason: billingStatus.billingBlockedReason,
124+
blockedByOrgOwner: billingStatus.blockedByOrgOwner,
57125
}
58126
} else {
59127
// Get user role in organization for permission checks first
@@ -104,17 +172,15 @@ export async function GET(request: NextRequest) {
104172

105173
const userRole = memberRecord[0].role
106174

107-
// Include the requesting user's blocked flag as well so UI can reflect it
108-
const stats = await db
109-
.select({ blocked: userStats.billingBlocked })
110-
.from(userStats)
111-
.where(eq(userStats.userId, session.user.id))
112-
.limit(1)
175+
// Get effective billing blocked status (includes org owner check)
176+
const billingStatus = await getEffectiveBillingStatus(session.user.id)
113177

114178
// Merge blocked flag into data for convenience
115179
billingData = {
116180
...billingData,
117-
billingBlocked: stats.length > 0 ? !!stats[0].blocked : false,
181+
billingBlocked: billingStatus.billingBlocked,
182+
billingBlockedReason: billingStatus.billingBlockedReason,
183+
blockedByOrgOwner: billingStatus.blockedByOrgOwner,
118184
}
119185

120186
return NextResponse.json({
@@ -123,6 +189,8 @@ export async function GET(request: NextRequest) {
123189
data: billingData,
124190
userRole,
125191
billingBlocked: billingData.billingBlocked,
192+
billingBlockedReason: billingData.billingBlockedReason,
193+
blockedByOrgOwner: billingData.blockedByOrgOwner,
126194
})
127195
}
128196

apps/sim/app/api/billing/update-cost/route.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { userStats } from '@sim/db/schema'
33
import { eq, sql } from 'drizzle-orm'
44
import { type NextRequest, NextResponse } from 'next/server'
55
import { z } from 'zod'
6+
import { deductFromCredits } from '@/lib/billing/credits/balance'
67
import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
78
import { checkInternalApiKey } from '@/lib/copilot/utils'
89
import { isBillingEnabled } from '@/lib/core/config/environment'
@@ -90,13 +91,18 @@ export async function POST(req: NextRequest) {
9091
)
9192
return NextResponse.json({ error: 'User stats record not found' }, { status: 500 })
9293
}
93-
// Update existing user stats record
94+
95+
const { creditsUsed, overflow } = await deductFromCredits(userId, cost)
96+
if (creditsUsed > 0) {
97+
logger.info(`[${requestId}] Deducted cost from credits`, { userId, creditsUsed, overflow })
98+
}
99+
const costToStore = overflow
100+
94101
const updateFields = {
95-
totalCost: sql`total_cost + ${cost}`,
96-
currentPeriodCost: sql`current_period_cost + ${cost}`,
97-
// Copilot usage tracking increments
98-
totalCopilotCost: sql`total_copilot_cost + ${cost}`,
99-
currentPeriodCopilotCost: sql`current_period_copilot_cost + ${cost}`,
102+
totalCost: sql`total_cost + ${costToStore}`,
103+
currentPeriodCost: sql`current_period_cost + ${costToStore}`,
104+
totalCopilotCost: sql`total_copilot_cost + ${costToStore}`,
105+
currentPeriodCopilotCost: sql`current_period_copilot_cost + ${costToStore}`,
100106
totalCopilotCalls: sql`total_copilot_calls + 1`,
101107
lastActive: new Date(),
102108
}

apps/sim/app/api/usage/route.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,10 @@ export async function PUT(request: NextRequest) {
111111
const userId = session.user.id
112112

113113
if (context === 'user') {
114-
await updateUserUsageLimit(userId, limit)
114+
const result = await updateUserUsageLimit(userId, limit)
115+
if (!result.success) {
116+
return NextResponse.json({ error: result.error }, { status: 400 })
117+
}
115118
} else if (context === 'organization') {
116119
// organizationId is guaranteed to exist by Zod refinement
117120
const hasPermission = await isOrganizationOwnerOrAdmin(session.user.id, organizationId!)

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/footer-navigation/footer-navigation.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { useCallback, useState } from 'react'
3+
import { useCallback, useEffect, useState } from 'react'
44
import clsx from 'clsx'
55
import { Database, HelpCircle, Layout, LibraryBig, Settings } from 'lucide-react'
66
import Link from 'next/link'
@@ -33,6 +33,13 @@ export function FooterNavigation() {
3333
const [isHelpModalOpen, setIsHelpModalOpen] = useState(false)
3434
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false)
3535

36+
// Listen for external events to open modals
37+
useEffect(() => {
38+
const handleOpenHelpModal = () => setIsHelpModalOpen(true)
39+
window.addEventListener('open-help-modal', handleOpenHelpModal)
40+
return () => window.removeEventListener('open-help-modal', handleOpenHelpModal)
41+
}, [])
42+
3643
const navigationItems: FooterNavigationItem[] = [
3744
{
3845
id: 'logs',

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/shared/usage-header.tsx

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ interface UsageHeaderProps {
2020
progressValue?: number
2121
seatsText?: string
2222
isBlocked?: boolean
23+
blockedReason?: 'payment_failed' | 'dispute' | null
24+
blockedByOrgOwner?: boolean
2325
onResolvePayment?: () => void
26+
onContactSupport?: () => void
2427
status?: 'ok' | 'warning' | 'exceeded' | 'blocked'
2528
percentUsed?: number
2629
}
@@ -37,7 +40,10 @@ export function UsageHeader({
3740
progressValue,
3841
seatsText,
3942
isBlocked,
43+
blockedReason,
44+
blockedByOrgOwner,
4045
onResolvePayment,
46+
onContactSupport,
4147
status,
4248
percentUsed,
4349
}: UsageHeaderProps) {
@@ -114,7 +120,24 @@ export function UsageHeader({
114120
</div>
115121

116122
{/* Status messages */}
117-
{isBlocked && (
123+
{isBlocked && blockedReason === 'dispute' && (
124+
<div className='flex items-center justify-between rounded-[6px] bg-destructive/10 px-2 py-1'>
125+
<span className='text-destructive text-xs'>
126+
Account frozen. Please contact support to resolve this issue.
127+
</span>
128+
{onContactSupport && (
129+
<button
130+
type='button'
131+
className='font-medium text-destructive text-xs underline underline-offset-2'
132+
onClick={onContactSupport}
133+
>
134+
Get help
135+
</button>
136+
)}
137+
</div>
138+
)}
139+
140+
{isBlocked && blockedReason !== 'dispute' && !blockedByOrgOwner && (
118141
<div className='flex items-center justify-between rounded-[6px] bg-destructive/10 px-2 py-1'>
119142
<span className='text-destructive text-xs'>
120143
Payment failed. Please update your payment method.
@@ -131,6 +154,22 @@ export function UsageHeader({
131154
</div>
132155
)}
133156

157+
{isBlocked && blockedByOrgOwner && blockedReason !== 'dispute' && (
158+
<div className='rounded-[6px] bg-destructive/10 px-2 py-1'>
159+
<span className='text-destructive text-xs'>
160+
Organization billing issue. Please contact your organization owner.
161+
</span>
162+
</div>
163+
)}
164+
165+
{isBlocked && blockedByOrgOwner && blockedReason === 'dispute' && (
166+
<div className='rounded-[6px] bg-destructive/10 px-2 py-1'>
167+
<span className='text-destructive text-xs'>
168+
Organization account frozen. Please contact support.
169+
</span>
170+
</div>
171+
)}
172+
134173
{!isBlocked && status === 'exceeded' && (
135174
<div className='rounded-[6px] bg-amber-900/10 px-2 py-1'>
136175
<span className='text-amber-600 text-xs'>

0 commit comments

Comments
 (0)