Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions apps/sim/app/api/billing/credits/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { getCreditBalance } from '@/lib/billing/credits/balance'
import { purchaseCredits } from '@/lib/billing/credits/purchase'
import { createLogger } from '@/lib/logs/console/logger'

const logger = createLogger('CreditsAPI')

const PurchaseSchema = z.object({
amount: z.number().min(10).max(1000),
requestId: z.string().uuid(),
})

export async function GET() {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

try {
const { balance, entityType, entityId } = await getCreditBalance(session.user.id)
return NextResponse.json({
success: true,
data: { balance, entityType, entityId },
})
} catch (error) {
logger.error('Failed to get credit balance', { error, userId: session.user.id })
return NextResponse.json({ error: 'Failed to get credit balance' }, { status: 500 })
}
}

export async function POST(request: NextRequest) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

try {
const body = await request.json()
const validation = PurchaseSchema.safeParse(body)

if (!validation.success) {
return NextResponse.json(
{ error: 'Invalid amount. Must be between $10 and $1000' },
{ status: 400 }
)
}

const result = await purchaseCredits({
userId: session.user.id,
amountDollars: validation.data.amount,
requestId: validation.data.requestId,
})

if (!result.success) {
return NextResponse.json({ error: result.error }, { status: 400 })
}

return NextResponse.json({ success: true })
} catch (error) {
logger.error('Failed to purchase credits', { error, userId: session.user.id })
return NextResponse.json({ error: 'Failed to purchase credits' }, { status: 500 })
}
}
96 changes: 82 additions & 14 deletions apps/sim/app/api/billing/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,76 @@ import { getSimplifiedBillingSummary } from '@/lib/billing/core/billing'
import { getOrganizationBillingData } from '@/lib/billing/core/organization'
import { createLogger } from '@/lib/logs/console/logger'

/**
* Gets the effective billing blocked status for a user.
* If user is in an org, also checks if the org owner is blocked.
*/
async function getEffectiveBillingStatus(userId: string): Promise<{
billingBlocked: boolean
billingBlockedReason: 'payment_failed' | 'dispute' | null
blockedByOrgOwner: boolean
}> {
// Check user's own status
const userStatsRows = await db
.select({
blocked: userStats.billingBlocked,
blockedReason: userStats.billingBlockedReason,
})
.from(userStats)
.where(eq(userStats.userId, userId))
.limit(1)

const userBlocked = userStatsRows.length > 0 ? !!userStatsRows[0].blocked : false
const userBlockedReason = userStatsRows.length > 0 ? userStatsRows[0].blockedReason : null

if (userBlocked) {
return {
billingBlocked: true,
billingBlockedReason: userBlockedReason,
blockedByOrgOwner: false,
}
}

// Check if user is in an org where owner is blocked
const memberships = await db
.select({ organizationId: member.organizationId })
.from(member)
.where(eq(member.userId, userId))

for (const m of memberships) {
const owners = await db
.select({ userId: member.userId })
.from(member)
.where(and(eq(member.organizationId, m.organizationId), eq(member.role, 'owner')))
.limit(1)

if (owners.length > 0 && owners[0].userId !== userId) {
const ownerStats = await db
.select({
blocked: userStats.billingBlocked,
blockedReason: userStats.billingBlockedReason,
})
.from(userStats)
.where(eq(userStats.userId, owners[0].userId))
.limit(1)

if (ownerStats.length > 0 && ownerStats[0].blocked) {
return {
billingBlocked: true,
billingBlockedReason: ownerStats[0].blockedReason,
blockedByOrgOwner: true,
}
}
}
}

return {
billingBlocked: false,
billingBlockedReason: null,
blockedByOrgOwner: false,
}
}

const logger = createLogger('UnifiedBillingAPI')

/**
Expand Down Expand Up @@ -45,15 +115,13 @@ export async function GET(request: NextRequest) {
if (context === 'user') {
// Get user billing (may include organization if they're part of one)
billingData = await getSimplifiedBillingSummary(session.user.id, contextId || undefined)
// Attach billingBlocked status for the current user
const stats = await db
.select({ blocked: userStats.billingBlocked })
.from(userStats)
.where(eq(userStats.userId, session.user.id))
.limit(1)
// Attach effective billing blocked status (includes org owner check)
const billingStatus = await getEffectiveBillingStatus(session.user.id)
billingData = {
...billingData,
billingBlocked: stats.length > 0 ? !!stats[0].blocked : false,
billingBlocked: billingStatus.billingBlocked,
billingBlockedReason: billingStatus.billingBlockedReason,
blockedByOrgOwner: billingStatus.blockedByOrgOwner,
}
} else {
// Get user role in organization for permission checks first
Expand Down Expand Up @@ -104,17 +172,15 @@ export async function GET(request: NextRequest) {

const userRole = memberRecord[0].role

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

// Merge blocked flag into data for convenience
billingData = {
...billingData,
billingBlocked: stats.length > 0 ? !!stats[0].blocked : false,
billingBlocked: billingStatus.billingBlocked,
billingBlockedReason: billingStatus.billingBlockedReason,
blockedByOrgOwner: billingStatus.blockedByOrgOwner,
}

return NextResponse.json({
Expand All @@ -123,6 +189,8 @@ export async function GET(request: NextRequest) {
data: billingData,
userRole,
billingBlocked: billingData.billingBlocked,
billingBlockedReason: billingData.billingBlockedReason,
blockedByOrgOwner: billingData.blockedByOrgOwner,
})
}

Expand Down
18 changes: 12 additions & 6 deletions apps/sim/app/api/billing/update-cost/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { userStats } from '@sim/db/schema'
import { eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { deductFromCredits } from '@/lib/billing/credits/balance'
import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
import { checkInternalApiKey } from '@/lib/copilot/utils'
import { isBillingEnabled } from '@/lib/core/config/environment'
Expand Down Expand Up @@ -90,13 +91,18 @@ export async function POST(req: NextRequest) {
)
return NextResponse.json({ error: 'User stats record not found' }, { status: 500 })
}
// Update existing user stats record

const { creditsUsed, overflow } = await deductFromCredits(userId, cost)
if (creditsUsed > 0) {
logger.info(`[${requestId}] Deducted cost from credits`, { userId, creditsUsed, overflow })
}
const costToStore = overflow

const updateFields = {
totalCost: sql`total_cost + ${cost}`,
currentPeriodCost: sql`current_period_cost + ${cost}`,
// Copilot usage tracking increments
totalCopilotCost: sql`total_copilot_cost + ${cost}`,
currentPeriodCopilotCost: sql`current_period_copilot_cost + ${cost}`,
totalCost: sql`total_cost + ${costToStore}`,
currentPeriodCost: sql`current_period_cost + ${costToStore}`,
totalCopilotCost: sql`total_copilot_cost + ${costToStore}`,
currentPeriodCopilotCost: sql`current_period_copilot_cost + ${costToStore}`,
totalCopilotCalls: sql`total_copilot_calls + 1`,
lastActive: new Date(),
}
Expand Down
5 changes: 4 additions & 1 deletion apps/sim/app/api/usage/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,10 @@ export async function PUT(request: NextRequest) {
const userId = session.user.id

if (context === 'user') {
await updateUserUsageLimit(userId, limit)
const result = await updateUserUsageLimit(userId, limit)
if (!result.success) {
return NextResponse.json({ error: result.error }, { status: 400 })
}
} else if (context === 'organization') {
// organizationId is guaranteed to exist by Zod refinement
const hasPermission = await isOrganizationOwnerOrAdmin(session.user.id, organizationId!)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

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

// Listen for external events to open modals
useEffect(() => {
const handleOpenHelpModal = () => setIsHelpModalOpen(true)
window.addEventListener('open-help-modal', handleOpenHelpModal)
return () => window.removeEventListener('open-help-modal', handleOpenHelpModal)
}, [])

const navigationItems: FooterNavigationItem[] = [
{
id: 'logs',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ interface UsageHeaderProps {
progressValue?: number
seatsText?: string
isBlocked?: boolean
blockedReason?: 'payment_failed' | 'dispute' | null
blockedByOrgOwner?: boolean
onResolvePayment?: () => void
onContactSupport?: () => void
status?: 'ok' | 'warning' | 'exceeded' | 'blocked'
percentUsed?: number
}
Expand All @@ -37,7 +40,10 @@ export function UsageHeader({
progressValue,
seatsText,
isBlocked,
blockedReason,
blockedByOrgOwner,
onResolvePayment,
onContactSupport,
status,
percentUsed,
}: UsageHeaderProps) {
Expand Down Expand Up @@ -114,7 +120,24 @@ export function UsageHeader({
</div>

{/* Status messages */}
{isBlocked && (
{isBlocked && blockedReason === 'dispute' && (
<div className='flex items-center justify-between rounded-[6px] bg-destructive/10 px-2 py-1'>
<span className='text-destructive text-xs'>
Account frozen. Please contact support to resolve this issue.
</span>
{onContactSupport && (
<button
type='button'
className='font-medium text-destructive text-xs underline underline-offset-2'
onClick={onContactSupport}
>
Get help
</button>
)}
</div>
)}

{isBlocked && blockedReason !== 'dispute' && !blockedByOrgOwner && (
<div className='flex items-center justify-between rounded-[6px] bg-destructive/10 px-2 py-1'>
<span className='text-destructive text-xs'>
Payment failed. Please update your payment method.
Expand All @@ -131,6 +154,22 @@ export function UsageHeader({
</div>
)}

{isBlocked && blockedByOrgOwner && blockedReason !== 'dispute' && (
<div className='rounded-[6px] bg-destructive/10 px-2 py-1'>
<span className='text-destructive text-xs'>
Organization billing issue. Please contact your organization owner.
</span>
</div>
)}

{isBlocked && blockedByOrgOwner && blockedReason === 'dispute' && (
<div className='rounded-[6px] bg-destructive/10 px-2 py-1'>
<span className='text-destructive text-xs'>
Organization account frozen. Please contact support.
</span>
</div>
)}

{!isBlocked && status === 'exceeded' && (
<div className='rounded-[6px] bg-amber-900/10 px-2 py-1'>
<span className='text-amber-600 text-xs'>
Expand Down
Loading