Skip to content
Closed
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
93 changes: 93 additions & 0 deletions apps/sim/app/api/billing/credits/purchase/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { createCreditPurchaseCheckout } from '@/lib/billing/credits/purchase'
import { createLogger } from '@/lib/logs/console/logger'

const logger = createLogger('CreditPurchaseAPI')

/**
* POST /api/billing/credits/purchase
* Creates a Stripe Checkout session for purchasing prepaid credits.
*
* Request body:
* - amount: number (minimum $50, maximum $10,000)
* - referenceId: string (userId for Pro, organizationId for Team)
* - referenceType: 'user' | 'organization'
*/
export async function POST(request: NextRequest) {
const session = await getSession()

try {
if (!session?.user?.id || !session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

const body = await request.json()
const { amount, referenceId, referenceType } = body

// Validate request body
if (typeof amount !== 'number' || amount < 50) {
return NextResponse.json({ error: 'Amount must be at least $50' }, { status: 400 })
}

if (amount > 10000) {
return NextResponse.json(
{ error: 'Amount cannot exceed $10,000. Please contact support for larger purchases.' },
{ status: 400 }
)
}

if (!referenceId || typeof referenceId !== 'string') {
return NextResponse.json({ error: 'referenceId is required' }, { status: 400 })
}

if (!referenceType || !['user', 'organization'].includes(referenceType)) {
return NextResponse.json(
{ error: 'referenceType must be "user" or "organization"' },
{ status: 400 }
)
}

// Create checkout session
const result = await createCreditPurchaseCheckout({
amount,
referenceId,
referenceType: referenceType as 'user' | 'organization',
currentUser: {
id: session.user.id,
email: session.user.email,
},
})

logger.info('Credit purchase checkout created', {
userId: session.user.id,
amount,
referenceType,
referenceId,
sessionId: result.sessionId,
})

return NextResponse.json(result)
} catch (error: any) {
logger.error('Error creating credit purchase checkout', {
userId: session?.user?.id,
error: error.message,
stack: error.stack,
})

// Return user-friendly error messages
const statusCode =
error.message.includes('only available') || error.message.includes('only')
? 403
: error.message.includes('not found')
? 404
: 500

return NextResponse.json(
{
error: error.message || 'Failed to create credit purchase checkout',
},
{ status: statusCode }
)
}
}
26 changes: 25 additions & 1 deletion apps/sim/app/api/billing/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,28 @@ 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)

// Get prepaid credits data
const { getHighestPrioritySubscription } = await import('@/lib/billing/core/subscription')
const { getPrepaidCreditsBalance } = await import('@/lib/billing/credits/deduction')
const subscription = await getHighestPrioritySubscription(session.user.id)

let creditsData = null
if (subscription) {
creditsData = await getPrepaidCreditsBalance({ userId: session.user.id, subscription })
}

billingData = {
...billingData,
billingBlocked: stats.length > 0 ? !!stats[0].blocked : false,
credits: creditsData,
}
} else {
// Get user role in organization for permission checks first
Expand Down Expand Up @@ -111,10 +124,21 @@ export async function GET(request: NextRequest) {
.where(eq(userStats.userId, session.user.id))
.limit(1)

// Merge blocked flag into data for convenience
// Get organization prepaid credits data
const { getHighestPrioritySubscription } = await import('@/lib/billing/core/subscription')
const { getPrepaidCreditsBalance } = await import('@/lib/billing/credits/deduction')
const subscription = await getHighestPrioritySubscription(session.user.id)

let creditsData = null
if (subscription && (subscription.plan === 'team' || subscription.plan === 'enterprise')) {
creditsData = await getPrepaidCreditsBalance({ userId: session.user.id, subscription })
}

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

return NextResponse.json({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ interface UsageHeaderProps {
onResolvePayment?: () => void
status?: 'ok' | 'warning' | 'exceeded' | 'blocked'
percentUsed?: number
creditsAvailable?: number // Show when user has prepaid credits
}

export function UsageHeader({
Expand All @@ -40,6 +41,7 @@ export function UsageHeader({
onResolvePayment,
status,
percentUsed,
creditsAvailable,
}: UsageHeaderProps) {
const progress = progressValue ?? (limit > 0 ? Math.min((current / limit) * 100, 100) : 0)

Expand Down Expand Up @@ -107,6 +109,13 @@ export function UsageHeader({
})}
</div>

{/* Credits info - only show when credits are available */}
{creditsAvailable !== undefined && creditsAvailable > 0 && (
<div className='text-muted-foreground text-xs'>
${creditsAvailable.toFixed(2)} in credits • Applied before overage charges
</div>
)}

{/* Status messages */}
{isBlocked && (
<div className='flex items-center justify-between rounded-[6px] bg-destructive/10 px-2 py-1'>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export { CancelSubscription } from './cancel-subscription'
export { CostBreakdown } from './cost-breakdown'
export { PlanCard, type PlanCardProps, type PlanFeature } from './plan-card'
export { PrepaidCredits } from './prepaid-credits'
export type { UsageLimitRef } from './usage-limit'
export { UsageLimit } from './usage-limit'
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { PrepaidCredits } from './prepaid-credits'
export { PurchaseModal } from './purchase-modal'
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
'use client'

import { useState } from 'react'
import { Badge, Button } from '@/components/emcn'
import { PurchaseModal } from './purchase-modal'

interface PrepaidCreditsProps {
balance: number
totalPurchased: number
totalUsed: number
lastPurchaseAt: Date | string | null
context: 'user' | 'organization'
referenceId: string
}

function formatCurrency(amount: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount)
}

function formatDate(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
}).format(d)
}

export function PrepaidCredits({
balance,
totalPurchased,
totalUsed,
lastPurchaseAt,
context,
referenceId,
}: PrepaidCreditsProps) {
const [showPurchaseModal, setShowPurchaseModal] = useState(false)

return (
<>
<div className='rounded-[8px] border bg-background p-3 shadow-xs'>
<div className='mb-2 flex items-center justify-between'>
<div className='flex items-center gap-2'>
<span className='font-medium text-sm'>Prepaid Credits</span>
<Badge variant='outline' className='text-xs'>
{formatCurrency(balance)} available
</Badge>
</div>
<Button variant='outline' onClick={() => setShowPurchaseModal(true)}>
Purchase Credits
</Button>
</div>

<div className='grid grid-cols-3 gap-4 text-muted-foreground text-xs'>
<div>
<div className='font-medium text-foreground tabular-nums'>
{formatCurrency(totalPurchased)}
</div>
<div>Total Purchased</div>
</div>
<div>
<div className='font-medium text-foreground tabular-nums'>
{formatCurrency(totalUsed)}
</div>
<div>Total Used</div>
</div>
<div>
<div className='font-medium text-foreground'>
{lastPurchaseAt ? formatDate(lastPurchaseAt) : 'Never'}
</div>
<div>Last Purchase</div>
</div>
</div>

<div className='mt-2 text-muted-foreground text-xs'>
Credits are used automatically before subscription charges
</div>
</div>

<PurchaseModal
open={showPurchaseModal}
onClose={() => setShowPurchaseModal(false)}
referenceId={referenceId}
referenceType={context}
/>
</>
)
}
Loading