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 <emirkarabeg@berkeley.edu>
This commit is contained in:
Vikhyath Mondreti
2025-12-06 19:11:58 -08:00
committed by GitHub
parent 92c03b825b
commit 9f884c151c
29 changed files with 1555 additions and 137 deletions

View File

@@ -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 })
}
}

View File

@@ -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')
/**
@@ -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
@@ -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({
@@ -123,6 +189,8 @@ export async function GET(request: NextRequest) {
data: billingData,
userRole,
billingBlocked: billingData.billingBlocked,
billingBlockedReason: billingData.billingBlockedReason,
blockedByOrgOwner: billingData.blockedByOrgOwner,
})
}

View File

@@ -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'
@@ -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(),
}

View File

@@ -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!)

View File

@@ -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'
@@ -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',

View File

@@ -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
}
@@ -37,7 +40,10 @@ export function UsageHeader({
progressValue,
seatsText,
isBlocked,
blockedReason,
blockedByOrgOwner,
onResolvePayment,
onContactSupport,
status,
percentUsed,
}: UsageHeaderProps) {
@@ -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.
@@ -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'>

View File

@@ -0,0 +1,186 @@
'use client'
import { useState } from 'react'
import {
Button,
Input,
Modal,
ModalClose,
ModalContent,
ModalFooter,
ModalHeader,
ModalTrigger,
} from '@/components/emcn'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('CreditBalance')
interface CreditBalanceProps {
balance: number
canPurchase: boolean
entityType: 'user' | 'organization'
isLoading?: boolean
onPurchaseComplete?: () => void
}
export function CreditBalance({
balance,
canPurchase,
entityType,
isLoading,
onPurchaseComplete,
}: CreditBalanceProps) {
const [isOpen, setIsOpen] = useState(false)
const [amount, setAmount] = useState('')
const [isPurchasing, setIsPurchasing] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
const [requestId, setRequestId] = useState<string | null>(null)
const handleAmountChange = (value: string) => {
const numericValue = value.replace(/[^0-9]/g, '')
setAmount(numericValue)
setError(null)
}
const handlePurchase = async () => {
if (!requestId || isPurchasing) return
const numAmount = Number.parseInt(amount, 10)
if (Number.isNaN(numAmount) || numAmount < 10) {
setError('Minimum purchase is $10')
return
}
if (numAmount > 1000) {
setError('Maximum purchase is $1,000')
return
}
setIsPurchasing(true)
setError(null)
try {
const response = await fetch('/api/billing/credits', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: numAmount, requestId }),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to purchase credits')
}
setSuccess(true)
setTimeout(() => {
setIsOpen(false)
onPurchaseComplete?.()
}, 1500)
} catch (err) {
logger.error('Credit purchase failed', { error: err })
setError(err instanceof Error ? err.message : 'Failed to purchase credits')
} finally {
setIsPurchasing(false)
}
}
const handleOpenChange = (open: boolean) => {
setIsOpen(open)
if (open) {
// Generate new requestId when modal opens - same ID used for entire session
setRequestId(crypto.randomUUID())
} else {
setAmount('')
setError(null)
setSuccess(false)
setRequestId(null)
}
}
return (
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<span className='text-muted-foreground text-sm'>Credit Balance</span>
<span className='font-medium text-sm'>{isLoading ? '...' : `$${balance.toFixed(2)}`}</span>
</div>
{canPurchase && (
<Modal open={isOpen} onOpenChange={handleOpenChange}>
<ModalTrigger asChild>
<Button variant='outline'>Add Credits</Button>
</ModalTrigger>
<ModalContent>
<ModalHeader>Add Credits</ModalHeader>
<div className='px-4'>
<p className='text-[13px] text-[var(--text-secondary)]'>
Credits are used before overage charges. Min $10, max $1,000.
</p>
</div>
{success ? (
<div className='py-4 text-center'>
<p className='text-[14px] text-[var(--text-primary)]'>
Credits added successfully!
</p>
</div>
) : (
<div className='flex flex-col gap-3 py-2'>
<div className='flex flex-col gap-1'>
<label
htmlFor='credit-amount'
className='text-[12px] text-[var(--text-secondary)]'
>
Amount (USD)
</label>
<div className='relative'>
<span className='-translate-y-1/2 absolute top-1/2 left-3 text-[var(--text-secondary)]'>
$
</span>
<Input
id='credit-amount'
type='text'
inputMode='numeric'
value={amount}
onChange={(e) => handleAmountChange(e.target.value)}
placeholder='50'
className='pl-7'
disabled={isPurchasing}
/>
</div>
{error && <span className='text-[11px] text-red-500'>{error}</span>}
</div>
<div className='rounded-[4px] bg-[var(--surface-5)] p-2'>
<p className='text-[11px] text-[var(--text-tertiary)]'>
Credits are non-refundable and don't expire. They'll be applied automatically to
your {entityType === 'organization' ? 'team' : ''} usage.
</p>
</div>
</div>
)}
{!success && (
<ModalFooter>
<ModalClose asChild>
<Button variant='ghost' disabled={isPurchasing}>
Cancel
</Button>
</ModalClose>
<Button
variant='primary'
onClick={handlePurchase}
disabled={isPurchasing || !amount}
>
{isPurchasing ? 'Processing...' : 'Purchase'}
</Button>
</ModalFooter>
)}
</ModalContent>
</Modal>
)}
</div>
)
}

View File

@@ -1,5 +1,6 @@
export { CancelSubscription } from './cancel-subscription'
export { CostBreakdown } from './cost-breakdown'
export { CreditBalance } from './credit-balance'
export { PlanCard, type PlanCardProps, type PlanFeature } from './plan-card'
export type { UsageLimitRef } from './usage-limit'
export { UsageLimit } from './usage-limit'

View File

@@ -22,6 +22,7 @@ import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/provide
import { UsageHeader } from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/shared/usage-header'
import {
CancelSubscription,
CreditBalance,
PlanCard,
UsageLimit,
type UsageLimitRef,
@@ -49,17 +50,8 @@ const CONSTANTS = {
INITIAL_TEAM_SEATS: 1,
} as const
const STYLES = {
GRADIENT_BADGE:
'gradient-text h-[1.125rem] rounded-[6px] border-gradient-primary/20 bg-gradient-to-b from-gradient-primary via-gradient-secondary to-gradient-primary px-2 py-0 font-medium text-xs cursor-pointer',
} as const
type TargetPlan = 'pro' | 'team'
interface SubscriptionProps {
onOpenChange: (open: boolean) => void
}
/**
* Skeleton component for subscription loading state.
*/
@@ -159,7 +151,7 @@ const formatPlanName = (plan: string): string => plan.charAt(0).toUpperCase() +
* Subscription management component
* Handles plan display, upgrades, and billing management
*/
export function Subscription({ onOpenChange }: SubscriptionProps) {
export function Subscription() {
const { data: session } = useSession()
const { handleUpgrade } = useSubscriptionUpgrade()
const params = useParams()
@@ -168,7 +160,11 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
const canManageWorkspaceKeys = userPermissions.canAdmin
const logger = createLogger('Subscription')
const { data: subscriptionData, isLoading: isSubscriptionLoading } = useSubscriptionData()
const {
data: subscriptionData,
isLoading: isSubscriptionLoading,
refetch: refetchSubscription,
} = useSubscriptionData()
const { data: usageLimitResponse, isLoading: isUsageLimitLoading } = useUsageLimitData()
const { data: workspaceData, isLoading: isWorkspaceLoading } = useWorkspaceSettings(workspaceId)
const updateWorkspaceMutation = useUpdateWorkspaceSettings()
@@ -392,6 +388,8 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
: usage.limit
}
isBlocked={Boolean(subscriptionData?.data?.billingBlocked)}
blockedReason={subscriptionData?.data?.billingBlockedReason}
blockedByOrgOwner={Boolean(subscriptionData?.data?.blockedByOrgOwner)}
status={billingStatus}
percentUsed={
subscription.isEnterprise || subscription.isTeam
@@ -404,6 +402,9 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
: usage.percentUsed
: usage.percentUsed
}
onContactSupport={() => {
window.dispatchEvent(new CustomEvent('open-help-modal'))
}}
onResolvePayment={async () => {
try {
const res = await fetch('/api/billing/portal', {
@@ -463,22 +464,6 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
</div>
)}
{/* Cost Breakdown */}
{/* TODO: Re-enable CostBreakdown component in the next billing period
once sufficient copilot cost data has been collected for accurate display.
Currently hidden to avoid confusion with initial zero values.
*/}
{/*
{subscriptionData?.usage && typeof subscriptionData.usage.copilotCost === 'number' && (
<div className='mb-2'>
<CostBreakdown
copilotCost={subscriptionData.usage.copilotCost}
totalCost={subscriptionData.usage.current}
/>
</div>
)}
*/}
{/* Team Member Notice */}
{permissions.showTeamMemberView && (
<div className='text-center'>
@@ -535,9 +520,20 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
</div>
)}
{/* Credit Balance */}
{subscription.isPaid && (
<CreditBalance
balance={subscriptionData?.data?.creditBalance ?? 0}
canPurchase={permissions.canEditUsageLimit}
entityType={subscription.isTeam || subscription.isEnterprise ? 'organization' : 'user'}
isLoading={isLoading}
onPurchaseComplete={() => refetchSubscription()}
/>
)}
{/* Next Billing Date */}
{subscription.isPaid && subscriptionData?.data?.periodEnd && (
<div className='mt-[16px] flex items-center justify-between'>
<div className='flex items-center justify-between'>
<span className='font-medium text-[13px]'>Next Billing Date</span>
<span className='text-[13px] text-[var(--text-muted)]'>
{new Date(subscriptionData.data.periodEnd).toLocaleDateString()}
@@ -617,7 +613,7 @@ function BillingUsageNotificationsToggle() {
const isLoading = updateSetting.isPending
return (
<div className='mt-[16px] flex items-center justify-between'>
<div className='flex items-center justify-between'>
<div className='flex flex-col'>
<span className='font-medium text-[13px]'>Usage notifications</span>
<span className='text-[var(--text-muted)] text-xs'>Email me when I reach 80% usage</span>

View File

@@ -434,9 +434,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
)}
{activeSection === 'apikeys' && <ApiKeys onOpenChange={onOpenChange} />}
{activeSection === 'files' && <FileUploads />}
{isBillingEnabled && activeSection === 'subscription' && (
<Subscription onOpenChange={onOpenChange} />
)}
{isBillingEnabled && activeSection === 'subscription' && <Subscription />}
{isBillingEnabled && activeSection === 'team' && <TeamManagement />}
{activeSection === 'sso' && <SSO />}
{activeSection === 'copilot' && <Copilot />}

View File

@@ -128,6 +128,11 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
const billingStatus = getBillingStatus(subscriptionData?.data)
const isBlocked = billingStatus === 'blocked'
const blockedReason = subscriptionData?.data?.billingBlockedReason as
| 'payment_failed'
| 'dispute'
| null
const isDispute = isBlocked && blockedReason === 'dispute'
const showUpgradeButton =
(planType === 'free' || isBlocked || progressPercentage >= 80) && planType !== 'enterprise'
@@ -209,8 +214,20 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
}
const blocked = getBillingStatus(subscriptionData?.data) === 'blocked'
const reason = subscriptionData?.data?.billingBlockedReason as
| 'payment_failed'
| 'dispute'
| null
const canUpg = canUpgrade(subscriptionData?.data)
// For disputes, open help modal instead of billing portal
if (blocked && reason === 'dispute') {
window.dispatchEvent(new CustomEvent('open-help-modal'))
logger.info('Opened help modal for disputed account')
return
}
// For payment failures, open billing portal
if (blocked) {
try {
const context = subscription.isTeam || subscription.isEnterprise ? 'organization' : 'user'
@@ -265,10 +282,17 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
<div className='h-[14px] w-[1.5px] flex-shrink-0 bg-[var(--divider)]' />
<div className='flex min-w-0 flex-1 items-center gap-[4px]'>
{isBlocked ? (
<>
<span className='font-medium text-[12px] text-red-400'>Payment</span>
<span className='font-medium text-[12px] text-red-400'>Required</span>
</>
isDispute ? (
<>
<span className='font-medium text-[12px] text-red-400'>Account</span>
<span className='font-medium text-[12px] text-red-400'>Frozen</span>
</>
) : (
<>
<span className='font-medium text-[12px] text-red-400'>Payment</span>
<span className='font-medium text-[12px] text-red-400'>Required</span>
</>
)
) : (
<>
<span className='font-medium text-[12px] text-[var(--text-tertiary)] tabular-nums'>
@@ -292,7 +316,9 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
}`}
onClick={handleClick}
>
<span className='font-medium text-[12px]'>{isBlocked ? 'Fix Now' : 'Upgrade'}</span>
<span className='font-medium text-[12px]'>
{isBlocked ? (isDispute ? 'Get Help' : 'Fix Now') : 'Upgrade'}
</span>
</Button>
)}
</div>

View File

@@ -1,6 +1,6 @@
'use client'
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { Loader2 } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { createLogger } from '@/lib/logs/console/logger'
@@ -14,12 +14,21 @@ export default function WorkflowsPage() {
const { workflows, setActiveWorkflow } = useWorkflowRegistry()
const params = useParams()
const workspaceId = params.workspaceId as string
const [isMounted, setIsMounted] = useState(false)
// Fetch workflows using React Query
const { isLoading, isError } = useWorkflows(workspaceId)
// Handle redirection once workflows are loaded
// Track when component is mounted to avoid hydration issues
useEffect(() => {
setIsMounted(true)
}, [])
// Handle redirection once workflows are loaded and component is mounted
useEffect(() => {
// Wait for component to be mounted to avoid hydration mismatches
if (!isMounted) return
// Only proceed if workflows are done loading
if (isLoading) return
@@ -41,7 +50,7 @@ export default function WorkflowsPage() {
const firstWorkflowId = workspaceWorkflows[0]
router.replace(`/workspace/${workspaceId}/w/${firstWorkflowId}`)
}
}, [isLoading, workflows, workspaceId, router, setActiveWorkflow, isError])
}, [isMounted, isLoading, workflows, workspaceId, router, setActiveWorkflow, isError])
// Always show loading state until redirect happens
// There should always be a default workflow, so we never show "no workflows found"

View File

@@ -0,0 +1,125 @@
import {
Body,
Column,
Container,
Head,
Hr,
Html,
Img,
Link,
Preview,
Row,
Section,
Text,
} from '@react-email/components'
import { baseStyles } from '@/components/emails/base-styles'
import EmailFooter from '@/components/emails/footer'
import { getBrandConfig } from '@/lib/branding/branding'
import { getBaseUrl } from '@/lib/core/utils/urls'
interface CreditPurchaseEmailProps {
userName?: string
amount: number
newBalance: number
purchaseDate?: Date
}
export function CreditPurchaseEmail({
userName,
amount,
newBalance,
purchaseDate = new Date(),
}: CreditPurchaseEmailProps) {
const brand = getBrandConfig()
const baseUrl = getBaseUrl()
const previewText = `${brand.name}: $${amount.toFixed(2)} in credits added to your account`
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Body style={baseStyles.main}>
<Container style={baseStyles.container}>
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={brand.logoUrl || `${baseUrl}/logo/reverse/text/medium.png`}
width='114'
alt={brand.name}
style={{
margin: '0 auto',
}}
/>
</Column>
</Row>
</Section>
<Section style={baseStyles.sectionsBorders}>
<Row>
<Column style={baseStyles.sectionBorder} />
<Column style={baseStyles.sectionCenter} />
<Column style={baseStyles.sectionBorder} />
</Row>
</Section>
<Section style={baseStyles.content}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>
<Text style={baseStyles.paragraph}>
Your credit purchase of <strong>${amount.toFixed(2)}</strong> has been confirmed.
</Text>
<Section
style={{
background: '#f4f4f5',
borderRadius: '8px',
padding: '16px',
margin: '24px 0',
}}
>
<Text style={{ margin: 0, fontSize: '14px', color: '#71717a' }}>Amount Added</Text>
<Text style={{ margin: '4px 0 16px', fontSize: '24px', fontWeight: 'bold' }}>
${amount.toFixed(2)}
</Text>
<Text style={{ margin: 0, fontSize: '14px', color: '#71717a' }}>New Balance</Text>
<Text style={{ margin: '4px 0 0', fontSize: '24px', fontWeight: 'bold' }}>
${newBalance.toFixed(2)}
</Text>
</Section>
<Text style={baseStyles.paragraph}>
These credits will be applied automatically to your workflow executions. Credits are
consumed before any overage charges apply.
</Text>
<Link href={`${baseUrl}/workspace`} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>View Dashboard</Text>
</Link>
<Hr />
<Text style={baseStyles.paragraph}>
You can view your credit balance and purchase history in Settings Subscription.
</Text>
<Text style={baseStyles.paragraph}>
Best regards,
<br />
The Sim Team
</Text>
<Text style={{ ...baseStyles.paragraph, fontSize: '12px', color: '#666' }}>
Purchased on {purchaseDate.toLocaleDateString()}
</Text>
</Section>
</Container>
<EmailFooter baseUrl={baseUrl} />
</Body>
</Html>
)
}
export default CreditPurchaseEmail

View File

@@ -9,6 +9,7 @@ import {
ResetPasswordEmail,
UsageThresholdEmail,
} from '@/components/emails'
import CreditPurchaseEmail from '@/components/emails/billing/credit-purchase-email'
import FreeTierUpgradeEmail from '@/components/emails/billing/free-tier-upgrade-email'
import { getBrandConfig } from '@/lib/branding/branding'
import { getBaseUrl } from '@/lib/core/utils/urls'
@@ -158,6 +159,7 @@ export function getEmailSubject(
| 'free-tier-upgrade'
| 'plan-welcome-pro'
| 'plan-welcome-team'
| 'credit-purchase'
): string {
const brandName = getBrandConfig().name
@@ -186,6 +188,8 @@ export function getEmailSubject(
return `Your Pro plan is now active on ${brandName}`
case 'plan-welcome-team':
return `Your Team plan is now active on ${brandName}`
case 'credit-purchase':
return `Credits added to your ${brandName} account`
default:
return brandName
}
@@ -205,3 +209,18 @@ export async function renderPlanWelcomeEmail(params: {
})
)
}
export async function renderCreditPurchaseEmail(params: {
userName?: string
amount: number
newBalance: number
}): Promise<string> {
return await render(
CreditPurchaseEmail({
userName: params.userName,
amount: params.amount,
newBalance: params.newBalance,
purchaseDate: new Date(),
})
)
}

View File

@@ -28,6 +28,7 @@ import { handleNewUser } from '@/lib/billing/core/usage'
import { syncSubscriptionUsageLimits } from '@/lib/billing/organization'
import { getPlans } from '@/lib/billing/plans'
import { syncSeatsFromStripeQuantity } from '@/lib/billing/validation/seat-management'
import { handleChargeDispute, handleDisputeClosed } from '@/lib/billing/webhooks/disputes'
import { handleManualEnterpriseSubscription } from '@/lib/billing/webhooks/enterprise'
import {
handleInvoiceFinalized,
@@ -2023,7 +2024,14 @@ export const auth = betterAuth({
await handleManualEnterpriseSubscription(event)
break
}
// Note: customer.subscription.deleted is handled by better-auth's onSubscriptionDeleted callback above
case 'charge.dispute.created': {
await handleChargeDispute(event)
break
}
case 'charge.dispute.closed': {
await handleDisputeClosed(event)
break
}
default:
logger.info('[onEvent] Ignoring unsupported webhook event', {
eventId: event.id,

View File

@@ -1,6 +1,6 @@
import { db } from '@sim/db'
import { member, organization, userStats } from '@sim/db/schema'
import { eq, inArray } from 'drizzle-orm'
import { and, eq, inArray } from 'drizzle-orm'
import { getOrganizationSubscription, getPlanPricing } from '@/lib/billing/core/billing'
import { getUserUsageLimit } from '@/lib/billing/core/usage'
import { isBillingEnabled } from '@/lib/core/config/environment'
@@ -255,24 +255,72 @@ export async function checkServerSideUsageLimits(userId: string): Promise<{
logger.info('Server-side checking usage limits for user', { userId })
// Check user's own blocked status
const stats = await db
.select({
blocked: userStats.billingBlocked,
blockedReason: userStats.billingBlockedReason,
current: userStats.currentPeriodCost,
total: userStats.totalCost,
})
.from(userStats)
.where(eq(userStats.userId, userId))
.limit(1)
const currentUsage =
stats.length > 0
? Number.parseFloat(stats[0].current?.toString() || stats[0].total.toString())
: 0
if (stats.length > 0 && stats[0].blocked) {
const currentUsage = Number.parseFloat(
stats[0].current?.toString() || stats[0].total.toString()
)
const message =
stats[0].blockedReason === 'dispute'
? 'Account frozen. Please contact support to resolve this issue.'
: 'Billing issue detected. Please update your payment method to continue.'
return {
isExceeded: true,
currentUsage,
limit: 0,
message: 'Billing issue detected. Please update your payment method to continue.',
message,
}
}
// Check if user is in an org where the owner is blocked
const memberships = await db
.select({ organizationId: member.organizationId })
.from(member)
.where(eq(member.userId, userId))
for (const m of memberships) {
// Find the owner of this org
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) {
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) {
const message =
ownerStats[0].blockedReason === 'dispute'
? 'Organization account frozen. Please contact support to resolve this issue.'
: 'Organization billing issue. Please contact your organization owner.'
return {
isExceeded: true,
currentUsage,
limit: 0,
message,
}
}
}
}

View File

@@ -3,11 +3,11 @@ import { member, organization, subscription, user, userStats } from '@sim/db/sch
import { and, eq } from 'drizzle-orm'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { getUserUsageData } from '@/lib/billing/core/usage'
import {
getFreeTierLimit,
getProTierLimit,
getTeamTierLimitPerSeat,
} from '@/lib/billing/subscriptions/utils'
import { getCreditBalance } from '@/lib/billing/credits/balance'
import { getFreeTierLimit, getPlanPricing } from '@/lib/billing/subscriptions/utils'
export { getPlanPricing }
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('Billing')
@@ -38,24 +38,6 @@ export async function getOrganizationSubscription(organizationId: string) {
* 4. Usage resets, next month they pay $20 again + any overages
*/
/**
* Get plan pricing information
*/
export function getPlanPricing(plan: string): {
basePrice: number // What they pay upfront via Stripe subscription
} {
switch (plan) {
case 'free':
return { basePrice: 0 } // Free plan has no charges
case 'pro':
return { basePrice: getProTierLimit() }
case 'team':
return { basePrice: getTeamTierLimitPerSeat() } // Per-seat pricing
default:
return { basePrice: 0 }
}
}
/**
* Calculate overage billing for a user
* Returns only the amount that exceeds their subscription base price
@@ -223,6 +205,7 @@ export async function getSimplifiedBillingSummary(
isWarning: boolean
isExceeded: boolean
daysRemaining: number
creditBalance: number
// Subscription details
isPaid: boolean
isPro: boolean
@@ -333,6 +316,8 @@ export async function getSimplifiedBillingSummary(
)
: 0
const orgCredits = await getCreditBalance(userId)
return {
type: 'organization',
plan: subscription.plan,
@@ -345,6 +330,7 @@ export async function getSimplifiedBillingSummary(
isWarning: percentUsed >= 80 && percentUsed < 100,
isExceeded: usageData.currentUsage >= usageData.limit,
daysRemaining,
creditBalance: orgCredits.balance,
// Subscription details
isPaid,
isPro,
@@ -456,6 +442,8 @@ export async function getSimplifiedBillingSummary(
)
: 0
const userCredits = await getCreditBalance(userId)
return {
type: 'individual',
plan,
@@ -468,6 +456,7 @@ export async function getSimplifiedBillingSummary(
isWarning: percentUsed >= 80 && percentUsed < 100,
isExceeded: currentUsage >= usageData.limit,
daysRemaining,
creditBalance: userCredits.balance,
// Subscription details
isPaid,
isPro,
@@ -516,6 +505,7 @@ function getDefaultBillingSummary(type: 'individual' | 'organization') {
isWarning: false,
isExceeded: false,
daysRemaining: 0,
creditBalance: 0,
// Subscription details
isPaid: false,
isPro: false,

View File

@@ -11,6 +11,7 @@ import {
canEditUsageLimit,
getFreeTierLimit,
getPerUserMinimumLimit,
getPlanPricing,
} from '@/lib/billing/subscriptions/utils'
import type { BillingData, UsageData, UsageLimitInfo } from '@/lib/billing/types'
import { isBillingEnabled } from '@/lib/core/config/environment'
@@ -93,7 +94,6 @@ export async function getUserUsageData(userId: string): Promise<UsageData> {
.where(eq(organization.id, subscription.referenceId))
.limit(1)
const { getPlanPricing } = await import('@/lib/billing/core/billing')
const { basePrice } = getPlanPricing(subscription.plan)
const minimum = (subscription.seats ?? 0) * basePrice
@@ -166,7 +166,6 @@ export async function getUserUsageLimitInfo(userId: string): Promise<UsageLimitI
.where(eq(organization.id, subscription.referenceId))
.limit(1)
const { getPlanPricing } = await import('@/lib/billing/core/billing')
const { basePrice } = getPlanPricing(subscription.plan)
const minimum = (subscription.seats ?? 0) * basePrice
@@ -270,28 +269,6 @@ export async function updateUserUsageLimit(
}
}
// Get current usage to validate against
const userStatsRecord = await db
.select()
.from(userStats)
.where(eq(userStats.userId, userId))
.limit(1)
if (userStatsRecord.length > 0) {
const currentUsage = Number.parseFloat(
userStatsRecord[0].currentPeriodCost?.toString() || userStatsRecord[0].totalCost.toString()
)
// Validate new limit is not below current usage
if (newLimit < currentUsage) {
return {
success: false,
error: `Usage limit cannot be below current usage of $${currentUsage.toFixed(2)}`,
}
}
}
// Update the usage limit
await db
.update(userStats)
.set({
@@ -359,14 +336,12 @@ export async function getUserUsageLimit(userId: string): Promise<number> {
if (orgData[0].orgUsageLimit) {
const configured = Number.parseFloat(orgData[0].orgUsageLimit)
const { getPlanPricing } = await import('@/lib/billing/core/billing')
const { basePrice } = getPlanPricing(subscription.plan)
const minimum = (subscription.seats ?? 0) * basePrice
return Math.max(configured, minimum)
}
// If org hasn't set a custom limit, use minimum (seats × cost per seat)
const { getPlanPricing } = await import('@/lib/billing/core/billing')
const { basePrice } = getPlanPricing(subscription.plan)
return (subscription.seats ?? 0) * basePrice
}

View File

@@ -0,0 +1,194 @@
import { db } from '@sim/db'
import { member, organization, userStats } from '@sim/db/schema'
import { and, eq, sql } from 'drizzle-orm'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('CreditBalance')
export interface CreditBalanceInfo {
balance: number
entityType: 'user' | 'organization'
entityId: string
}
export async function getCreditBalance(userId: string): Promise<CreditBalanceInfo> {
const subscription = await getHighestPrioritySubscription(userId)
if (subscription?.plan === 'team' || subscription?.plan === 'enterprise') {
const orgRows = await db
.select({ creditBalance: organization.creditBalance })
.from(organization)
.where(eq(organization.id, subscription.referenceId))
.limit(1)
return {
balance: orgRows.length > 0 ? Number.parseFloat(orgRows[0].creditBalance || '0') : 0,
entityType: 'organization',
entityId: subscription.referenceId,
}
}
const userRows = await db
.select({ creditBalance: userStats.creditBalance })
.from(userStats)
.where(eq(userStats.userId, userId))
.limit(1)
return {
balance: userRows.length > 0 ? Number.parseFloat(userRows[0].creditBalance || '0') : 0,
entityType: 'user',
entityId: userId,
}
}
export async function addCredits(
entityType: 'user' | 'organization',
entityId: string,
amount: number
): Promise<void> {
if (entityType === 'organization') {
await db
.update(organization)
.set({ creditBalance: sql`${organization.creditBalance} + ${amount}` })
.where(eq(organization.id, entityId))
logger.info('Added credits to organization', { organizationId: entityId, amount })
} else {
await db
.update(userStats)
.set({ creditBalance: sql`${userStats.creditBalance} + ${amount}` })
.where(eq(userStats.userId, entityId))
logger.info('Added credits to user', { userId: entityId, amount })
}
}
export async function removeCredits(
entityType: 'user' | 'organization',
entityId: string,
amount: number
): Promise<void> {
if (entityType === 'organization') {
await db
.update(organization)
.set({ creditBalance: sql`GREATEST(0, ${organization.creditBalance} - ${amount})` })
.where(eq(organization.id, entityId))
logger.info('Removed credits from organization', { organizationId: entityId, amount })
} else {
await db
.update(userStats)
.set({ creditBalance: sql`GREATEST(0, ${userStats.creditBalance} - ${amount})` })
.where(eq(userStats.userId, entityId))
logger.info('Removed credits from user', { userId: entityId, amount })
}
}
export interface DeductResult {
creditsUsed: number
overflow: number
}
async function atomicDeductUserCredits(userId: string, cost: number): Promise<number> {
const costStr = cost.toFixed(6)
// Use raw SQL with CTE to capture old balance before update
const result = await db.execute<{ old_balance: string; new_balance: string }>(sql`
WITH old_balance AS (
SELECT credit_balance FROM user_stats WHERE user_id = ${userId}
)
UPDATE user_stats
SET credit_balance = CASE
WHEN credit_balance >= ${costStr}::decimal THEN credit_balance - ${costStr}::decimal
ELSE 0
END
WHERE user_id = ${userId} AND credit_balance >= 0
RETURNING
(SELECT credit_balance FROM old_balance) as old_balance,
credit_balance as new_balance
`)
const rows = Array.from(result)
if (rows.length === 0) return 0
const oldBalance = Number.parseFloat(rows[0].old_balance || '0')
return Math.min(oldBalance, cost)
}
async function atomicDeductOrgCredits(orgId: string, cost: number): Promise<number> {
const costStr = cost.toFixed(6)
// Use raw SQL with CTE to capture old balance before update
const result = await db.execute<{ old_balance: string; new_balance: string }>(sql`
WITH old_balance AS (
SELECT credit_balance FROM organization WHERE id = ${orgId}
)
UPDATE organization
SET credit_balance = CASE
WHEN credit_balance >= ${costStr}::decimal THEN credit_balance - ${costStr}::decimal
ELSE 0
END
WHERE id = ${orgId} AND credit_balance >= 0
RETURNING
(SELECT credit_balance FROM old_balance) as old_balance,
credit_balance as new_balance
`)
const rows = Array.from(result)
if (rows.length === 0) return 0
const oldBalance = Number.parseFloat(rows[0].old_balance || '0')
return Math.min(oldBalance, cost)
}
export async function deductFromCredits(userId: string, cost: number): Promise<DeductResult> {
if (cost <= 0) {
return { creditsUsed: 0, overflow: 0 }
}
const subscription = await getHighestPrioritySubscription(userId)
const isTeamOrEnterprise = subscription?.plan === 'team' || subscription?.plan === 'enterprise'
let creditsUsed: number
if (isTeamOrEnterprise && subscription?.referenceId) {
creditsUsed = await atomicDeductOrgCredits(subscription.referenceId, cost)
} else {
creditsUsed = await atomicDeductUserCredits(userId, cost)
}
const overflow = Math.max(0, cost - creditsUsed)
if (creditsUsed > 0) {
logger.info('Deducted credits atomically', {
userId,
creditsUsed,
overflow,
entityType: isTeamOrEnterprise ? 'organization' : 'user',
})
}
return { creditsUsed, overflow }
}
export async function canPurchaseCredits(userId: string): Promise<boolean> {
const subscription = await getHighestPrioritySubscription(userId)
if (!subscription || subscription.status !== 'active') {
return false
}
// Enterprise users must contact support to purchase credits
return subscription.plan === 'pro' || subscription.plan === 'team'
}
export async function isOrgAdmin(userId: string, organizationId: string): Promise<boolean> {
const memberRows = await db
.select({ role: member.role })
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.userId, userId)))
.limit(1)
if (memberRows.length === 0) return false
return memberRows[0].role === 'owner' || memberRows[0].role === 'admin'
}

View File

@@ -0,0 +1,231 @@
import { db } from '@sim/db'
import { organization, userStats } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import type Stripe from 'stripe'
import { getPlanPricing } from '@/lib/billing/core/billing'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { canPurchaseCredits, isOrgAdmin } from '@/lib/billing/credits/balance'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('CreditPurchase')
/**
* Sets usage limit to planBase + creditBalance.
* This ensures users can use their plan's included amount plus any prepaid credits.
*/
export async function setUsageLimitForCredits(
entityType: 'user' | 'organization',
entityId: string,
plan: string,
seats: number | null,
creditBalance: number
): Promise<void> {
try {
const { basePrice } = getPlanPricing(plan)
const planBase =
entityType === 'organization' ? Number(basePrice) * (seats || 1) : Number(basePrice)
const creditBalanceNum = Number(creditBalance)
const newLimit = planBase + creditBalanceNum
if (entityType === 'organization') {
const orgRows = await db
.select({ orgUsageLimit: organization.orgUsageLimit })
.from(organization)
.where(eq(organization.id, entityId))
.limit(1)
const currentLimit =
orgRows.length > 0 ? Number.parseFloat(orgRows[0].orgUsageLimit || '0') : 0
if (newLimit > currentLimit) {
await db
.update(organization)
.set({ orgUsageLimit: newLimit.toString() })
.where(eq(organization.id, entityId))
logger.info('Set org usage limit to planBase + credits', {
organizationId: entityId,
plan,
seats,
planBase,
creditBalance,
previousLimit: currentLimit,
newLimit,
})
}
} else {
const userStatsRows = await db
.select({ currentUsageLimit: userStats.currentUsageLimit })
.from(userStats)
.where(eq(userStats.userId, entityId))
.limit(1)
const currentLimit =
userStatsRows.length > 0 ? Number.parseFloat(userStatsRows[0].currentUsageLimit || '0') : 0
if (newLimit > currentLimit) {
await db
.update(userStats)
.set({ currentUsageLimit: newLimit.toString() })
.where(eq(userStats.userId, entityId))
logger.info('Set user usage limit to planBase + credits', {
userId: entityId,
plan,
planBase,
creditBalance,
previousLimit: currentLimit,
newLimit,
})
}
}
} catch (error) {
logger.error('Failed to set usage limit for credits', { entityType, entityId, error })
}
}
export interface PurchaseCreditsParams {
userId: string
amountDollars: number
requestId: string
}
export interface PurchaseResult {
success: boolean
error?: string
}
function getPaymentMethodId(
pm: string | Stripe.PaymentMethod | null | undefined
): string | undefined {
return typeof pm === 'string' ? pm : pm?.id
}
export async function purchaseCredits(params: PurchaseCreditsParams): Promise<PurchaseResult> {
const { userId, amountDollars, requestId } = params
if (amountDollars < 10 || amountDollars > 1000) {
return { success: false, error: 'Amount must be between $10 and $1000' }
}
const canPurchase = await canPurchaseCredits(userId)
if (!canPurchase) {
return { success: false, error: 'Only Pro and Team users can purchase credits' }
}
const subscription = await getHighestPrioritySubscription(userId)
if (!subscription || !subscription.stripeSubscriptionId) {
return { success: false, error: 'No active subscription found' }
}
// Enterprise users must contact support
if (subscription.plan === 'enterprise') {
return { success: false, error: 'Enterprise users must contact support to purchase credits' }
}
let entityType: 'user' | 'organization' = 'user'
let entityId = userId
if (subscription.plan === 'team') {
const isAdmin = await isOrgAdmin(userId, subscription.referenceId)
if (!isAdmin) {
return { success: false, error: 'Only organization owners and admins can purchase credits' }
}
entityType = 'organization'
entityId = subscription.referenceId
}
try {
const stripe = requireStripeClient()
// Get customer ID and payment method from subscription
const stripeSub = await stripe.subscriptions.retrieve(subscription.stripeSubscriptionId)
const customerId =
typeof stripeSub.customer === 'string' ? stripeSub.customer : stripeSub.customer.id
// Get default payment method
let defaultPaymentMethod: string | undefined
const subPm = getPaymentMethodId(stripeSub.default_payment_method)
if (subPm) {
defaultPaymentMethod = subPm
} else {
const customer = await stripe.customers.retrieve(customerId)
if (customer && !('deleted' in customer)) {
defaultPaymentMethod = getPaymentMethodId(customer.invoice_settings?.default_payment_method)
}
}
if (!defaultPaymentMethod) {
return {
success: false,
error: 'No payment method on file. Please update your billing info.',
}
}
const amountCents = Math.round(amountDollars * 100)
const idempotencyKey = `credit-purchase:${requestId}`
const creditMetadata = {
type: 'credit_purchase',
entityType,
entityId,
amountDollars: amountDollars.toString(),
purchasedBy: userId,
}
// Create invoice
const invoice = await stripe.invoices.create(
{
customer: customerId,
collection_method: 'charge_automatically',
auto_advance: false,
description: `Credit purchase - $${amountDollars}`,
metadata: creditMetadata,
default_payment_method: defaultPaymentMethod,
},
{ idempotencyKey: `${idempotencyKey}-invoice` }
)
// Add line item
await stripe.invoiceItems.create(
{
customer: customerId,
invoice: invoice.id,
amount: amountCents,
currency: 'usd',
description: `Prepaid credits ($${amountDollars})`,
metadata: creditMetadata,
},
{ idempotencyKey }
)
// Finalize and pay
if (!invoice.id) {
return { success: false, error: 'Failed to create invoice' }
}
const finalized = await stripe.invoices.finalizeInvoice(invoice.id)
if (finalized.status === 'open' && finalized.id) {
await stripe.invoices.pay(finalized.id, {
payment_method: defaultPaymentMethod,
})
// Credits are added via webhook (handleInvoicePaymentSucceeded) after payment confirmation
}
logger.info('Credit purchase invoice created and paid', {
invoiceId: invoice.id,
entityType,
entityId,
amountDollars,
purchasedBy: userId,
})
return { success: true }
} catch (error) {
logger.error('Failed to purchase credits', { error, userId, amountDollars })
const message = error instanceof Error ? error.message : 'Failed to process payment'
return { success: false, error: message }
}
}

View File

@@ -23,6 +23,8 @@ export {
getUserUsageLimit as getUsageLimit,
updateUserUsageLimit as updateUsageLimit,
} from '@/lib/billing/core/usage'
export * from '@/lib/billing/credits/balance'
export * from '@/lib/billing/credits/purchase'
export * from '@/lib/billing/subscriptions/utils'
export { canEditUsageLimit as canEditLimit } from '@/lib/billing/subscriptions/utils'
export * from '@/lib/billing/types'

View File

@@ -121,3 +121,21 @@ export function canEditUsageLimit(subscription: any): boolean {
// Enterprise has fixed limits that match their monthly cost
return subscription.plan === 'pro' || subscription.plan === 'team'
}
/**
* Get pricing info for a plan
*/
export function getPlanPricing(plan: string): { basePrice: number } {
switch (plan) {
case 'free':
return { basePrice: 0 }
case 'pro':
return { basePrice: getProTierLimit() }
case 'team':
return { basePrice: getTeamTierLimitPerSeat() }
case 'enterprise':
return { basePrice: getEnterpriseTierLimitPerSeat() }
default:
return { basePrice: 0 }
}
}

View File

@@ -1,5 +1,5 @@
import { db } from '@sim/db'
import { member, subscription, userStats } from '@sim/db/schema'
import { member, organization, subscription, userStats } from '@sim/db/schema'
import { and, eq, inArray, sql } from 'drizzle-orm'
import type Stripe from 'stripe'
import { DEFAULT_OVERAGE_THRESHOLD } from '@/lib/billing/constants'
@@ -161,7 +161,46 @@ export async function checkAndBillOverageThreshold(userId: string): Promise<void
return
}
const amountToBill = unbilledOverage
// Apply credits to reduce the amount to bill (use stats from locked row)
let amountToBill = unbilledOverage
let creditsApplied = 0
const creditBalance = Number.parseFloat(stats.creditBalance?.toString() || '0')
if (creditBalance > 0) {
creditsApplied = Math.min(creditBalance, amountToBill)
// Update credit balance within the transaction
await tx
.update(userStats)
.set({
creditBalance: sql`GREATEST(0, ${userStats.creditBalance} - ${creditsApplied})`,
})
.where(eq(userStats.userId, userId))
amountToBill = amountToBill - creditsApplied
logger.info('Applied credits to reduce threshold overage', {
userId,
creditBalance,
creditsApplied,
remainingToBill: amountToBill,
})
}
// If credits covered everything, just update the billed amount but don't create invoice
if (amountToBill <= 0) {
await tx
.update(userStats)
.set({
billedOverageThisPeriod: sql`${userStats.billedOverageThisPeriod} + ${unbilledOverage}`,
})
.where(eq(userStats.userId, userId))
logger.info('Credits fully covered threshold overage', {
userId,
creditsApplied,
unbilledOverage,
})
return
}
const stripeSubscriptionId = userSubscription.stripeSubscriptionId
if (!stripeSubscriptionId) {
@@ -214,15 +253,17 @@ export async function checkAndBillOverageThreshold(userId: string): Promise<void
await tx
.update(userStats)
.set({
billedOverageThisPeriod: sql`${userStats.billedOverageThisPeriod} + ${amountToBill}`,
billedOverageThisPeriod: sql`${userStats.billedOverageThisPeriod} + ${unbilledOverage}`,
})
.where(eq(userStats.userId, userId))
logger.info('Successfully created and finalized threshold overage invoice', {
userId,
creditsApplied,
amountBilled: amountToBill,
totalProcessed: unbilledOverage,
invoiceId,
newBilledTotal: billedOverageThisPeriod + amountToBill,
newBilledTotal: billedOverageThisPeriod + unbilledOverage,
})
})
} catch (error) {
@@ -298,6 +339,7 @@ export async function checkAndBillOrganizationOverageThreshold(
})
await db.transaction(async (tx) => {
// Lock both owner stats and organization rows
const ownerStatsLock = await tx
.select()
.from(userStats)
@@ -305,13 +347,26 @@ export async function checkAndBillOrganizationOverageThreshold(
.for('update')
.limit(1)
const orgLock = await tx
.select()
.from(organization)
.where(eq(organization.id, organizationId))
.for('update')
.limit(1)
if (ownerStatsLock.length === 0) {
logger.error('Owner stats not found', { organizationId, ownerId: owner.userId })
return
}
if (orgLock.length === 0) {
logger.error('Organization not found', { organizationId })
return
}
let totalTeamUsage = parseDecimal(ownerStatsLock[0].currentPeriodCost)
const totalBilledOverage = parseDecimal(ownerStatsLock[0].billedOverageThisPeriod)
const orgCreditBalance = Number.parseFloat(orgLock[0].creditBalance?.toString() || '0')
const nonOwnerIds = members.filter((m) => m.userId !== owner.userId).map((m) => m.userId)
@@ -348,7 +403,45 @@ export async function checkAndBillOrganizationOverageThreshold(
return
}
const amountToBill = unbilledOverage
// Apply credits to reduce the amount to bill (use locked org's balance)
let amountToBill = unbilledOverage
let creditsApplied = 0
if (orgCreditBalance > 0) {
creditsApplied = Math.min(orgCreditBalance, amountToBill)
// Update credit balance within the transaction
await tx
.update(organization)
.set({
creditBalance: sql`GREATEST(0, ${organization.creditBalance} - ${creditsApplied})`,
})
.where(eq(organization.id, organizationId))
amountToBill = amountToBill - creditsApplied
logger.info('Applied org credits to reduce threshold overage', {
organizationId,
creditBalance: orgCreditBalance,
creditsApplied,
remainingToBill: amountToBill,
})
}
// If credits covered everything, just update the billed amount but don't create invoice
if (amountToBill <= 0) {
await tx
.update(userStats)
.set({
billedOverageThisPeriod: sql`${userStats.billedOverageThisPeriod} + ${unbilledOverage}`,
})
.where(eq(userStats.userId, owner.userId))
logger.info('Credits fully covered org threshold overage', {
organizationId,
creditsApplied,
unbilledOverage,
})
return
}
const stripeSubscriptionId = orgSubscription.stripeSubscriptionId
if (!stripeSubscriptionId) {
@@ -375,6 +468,7 @@ export async function checkAndBillOrganizationOverageThreshold(
logger.info('Creating organization threshold overage invoice', {
organizationId,
amountToBill,
creditsApplied,
billingPeriod,
})
@@ -399,14 +493,16 @@ export async function checkAndBillOrganizationOverageThreshold(
await tx
.update(userStats)
.set({
billedOverageThisPeriod: sql`${userStats.billedOverageThisPeriod} + ${amountToBill}`,
billedOverageThisPeriod: sql`${userStats.billedOverageThisPeriod} + ${unbilledOverage}`,
})
.where(eq(userStats.userId, owner.userId))
logger.info('Successfully created and finalized organization threshold overage invoice', {
organizationId,
ownerId: owner.userId,
creditsApplied,
amountBilled: amountToBill,
totalProcessed: unbilledOverage,
invoiceId,
})
})

View File

@@ -0,0 +1,150 @@
import { db } from '@sim/db'
import { member, subscription, user, userStats } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import type Stripe from 'stripe'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('DisputeWebhooks')
async function getCustomerIdFromDispute(dispute: Stripe.Dispute): Promise<string | null> {
const chargeId = typeof dispute.charge === 'string' ? dispute.charge : dispute.charge?.id
if (!chargeId) return null
const stripe = requireStripeClient()
const charge = await stripe.charges.retrieve(chargeId)
return typeof charge.customer === 'string' ? charge.customer : (charge.customer?.id ?? null)
}
/**
* Handles charge.dispute.created - blocks the responsible user
*/
export async function handleChargeDispute(event: Stripe.Event): Promise<void> {
const dispute = event.data.object as Stripe.Dispute
const customerId = await getCustomerIdFromDispute(dispute)
if (!customerId) {
logger.warn('No customer ID found in dispute', { disputeId: dispute.id })
return
}
// Find user by stripeCustomerId (Pro plans)
const users = await db
.select({ id: user.id })
.from(user)
.where(eq(user.stripeCustomerId, customerId))
.limit(1)
if (users.length > 0) {
await db
.update(userStats)
.set({ billingBlocked: true, billingBlockedReason: 'dispute' })
.where(eq(userStats.userId, users[0].id))
logger.warn('Blocked user due to dispute', {
disputeId: dispute.id,
userId: users[0].id,
})
return
}
// Find subscription by stripeCustomerId (Team/Enterprise)
const subs = await db
.select({ referenceId: subscription.referenceId })
.from(subscription)
.where(eq(subscription.stripeCustomerId, customerId))
.limit(1)
if (subs.length > 0) {
const orgId = subs[0].referenceId
const owners = await db
.select({ userId: member.userId })
.from(member)
.where(and(eq(member.organizationId, orgId), eq(member.role, 'owner')))
.limit(1)
if (owners.length > 0) {
await db
.update(userStats)
.set({ billingBlocked: true, billingBlockedReason: 'dispute' })
.where(eq(userStats.userId, owners[0].userId))
logger.warn('Blocked org owner due to dispute', {
disputeId: dispute.id,
ownerId: owners[0].userId,
organizationId: orgId,
})
}
}
}
/**
* Handles charge.dispute.closed - unblocks user if dispute was won
*/
export async function handleDisputeClosed(event: Stripe.Event): Promise<void> {
const dispute = event.data.object as Stripe.Dispute
if (dispute.status !== 'won') {
logger.info('Dispute not won, user remains blocked', {
disputeId: dispute.id,
status: dispute.status,
})
return
}
const customerId = await getCustomerIdFromDispute(dispute)
if (!customerId) {
return
}
// Find and unblock user (Pro plans)
const users = await db
.select({ id: user.id })
.from(user)
.where(eq(user.stripeCustomerId, customerId))
.limit(1)
if (users.length > 0) {
await db
.update(userStats)
.set({ billingBlocked: false, billingBlockedReason: null })
.where(eq(userStats.userId, users[0].id))
logger.info('Unblocked user after winning dispute', {
disputeId: dispute.id,
userId: users[0].id,
})
return
}
// Find and unblock org owner (Team/Enterprise)
const subs = await db
.select({ referenceId: subscription.referenceId })
.from(subscription)
.where(eq(subscription.stripeCustomerId, customerId))
.limit(1)
if (subs.length > 0) {
const orgId = subs[0].referenceId
const owners = await db
.select({ userId: member.userId })
.from(member)
.where(and(eq(member.organizationId, orgId), eq(member.role, 'owner')))
.limit(1)
if (owners.length > 0) {
await db
.update(userStats)
.set({ billingBlocked: false, billingBlockedReason: null })
.where(eq(userStats.userId, owners[0].userId))
logger.info('Unblocked org owner after winning dispute', {
disputeId: dispute.id,
ownerId: owners[0].userId,
organizationId: orgId,
})
}
}
}

View File

@@ -10,7 +10,10 @@ import {
import { and, eq, inArray } from 'drizzle-orm'
import type Stripe from 'stripe'
import PaymentFailedEmail from '@/components/emails/billing/payment-failed-email'
import { getEmailSubject, renderCreditPurchaseEmail } from '@/components/emails/render-email'
import { calculateSubscriptionOverage } from '@/lib/billing/core/billing'
import { addCredits, getCreditBalance, removeCredits } from '@/lib/billing/credits/balance'
import { setUsageLimitForCredits } from '@/lib/billing/credits/purchase'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { createLogger } from '@/lib/logs/console/logger'
@@ -335,21 +338,131 @@ export async function resetUsageForSubscription(sub: { plan: string | null; refe
}
/**
* Handle invoice payment succeeded webhook
* We unblock any previously blocked users for this subscription.
* Handle credit purchase invoice payment succeeded.
*/
async function handleCreditPurchaseSuccess(invoice: Stripe.Invoice): Promise<void> {
const { entityType, entityId, amountDollars, purchasedBy } = invoice.metadata || {}
if (!entityType || !entityId || !amountDollars) {
logger.error('Missing metadata in credit purchase invoice', {
invoiceId: invoice.id,
metadata: invoice.metadata,
})
return
}
if (entityType !== 'user' && entityType !== 'organization') {
logger.error('Invalid entityType in credit purchase', { invoiceId: invoice.id, entityType })
return
}
const amount = Number.parseFloat(amountDollars)
if (Number.isNaN(amount) || amount <= 0) {
logger.error('Invalid amount in credit purchase', { invoiceId: invoice.id, amountDollars })
return
}
await addCredits(entityType, entityId, amount)
const subscription = await db
.select()
.from(subscriptionTable)
.where(eq(subscriptionTable.referenceId, entityId))
.limit(1)
if (subscription.length > 0) {
const sub = subscription[0]
const { balance: newCreditBalance } = await getCreditBalance(entityId)
await setUsageLimitForCredits(entityType, entityId, sub.plan, sub.seats, newCreditBalance)
}
logger.info('Credit purchase completed via webhook', {
invoiceId: invoice.id,
entityType,
entityId,
amount,
purchasedBy,
})
// Send confirmation emails
try {
const { balance: newBalance } = await getCreditBalance(
entityType === 'organization' ? entityId : purchasedBy || entityId
)
let recipients: Array<{ email: string; name: string | null }> = []
if (entityType === 'organization') {
const members = await db
.select({ userId: member.userId, role: member.role })
.from(member)
.where(eq(member.organizationId, entityId))
const ownerAdminIds = members
.filter((m) => m.role === 'owner' || m.role === 'admin')
.map((m) => m.userId)
if (ownerAdminIds.length > 0) {
recipients = await db
.select({ email: user.email, name: user.name })
.from(user)
.where(inArray(user.id, ownerAdminIds))
}
} else if (purchasedBy) {
const users = await db
.select({ email: user.email, name: user.name })
.from(user)
.where(eq(user.id, purchasedBy))
.limit(1)
recipients = users
}
for (const recipient of recipients) {
if (!recipient.email) continue
const emailHtml = await renderCreditPurchaseEmail({
userName: recipient.name || undefined,
amount,
newBalance,
})
await sendEmail({
to: recipient.email,
subject: getEmailSubject('credit-purchase'),
html: emailHtml,
emailType: 'transactional',
})
logger.info('Sent credit purchase confirmation email', {
email: recipient.email,
invoiceId: invoice.id,
})
}
} catch (emailError) {
logger.error('Failed to send credit purchase emails', { emailError, invoiceId: invoice.id })
}
}
/**
* Handle invoice payment succeeded webhook.
* Handles both credit purchases and subscription payments.
*/
export async function handleInvoicePaymentSucceeded(event: Stripe.Event) {
try {
const invoice = event.data.object as Stripe.Invoice
// Handle credit purchase invoices
if (invoice.metadata?.type === 'credit_purchase') {
await handleCreditPurchaseSuccess(invoice)
return
}
// Handle subscription invoices
const subscription = invoice.parent?.subscription_details?.subscription
const stripeSubscriptionId = typeof subscription === 'string' ? subscription : subscription?.id
if (!stripeSubscriptionId) {
logger.info('No subscription found on invoice; skipping payment succeeded handler', {
invoiceId: invoice.id,
})
return
}
const records = await db
.select()
.from(subscriptionTable)
@@ -392,16 +505,28 @@ export async function handleInvoicePaymentSucceeded(event: Stripe.Event) {
const memberIds = members.map((m) => m.userId)
if (memberIds.length > 0) {
// Only unblock users blocked for payment_failed, not disputes
await db
.update(userStats)
.set({ billingBlocked: false })
.where(inArray(userStats.userId, memberIds))
.set({ billingBlocked: false, billingBlockedReason: null })
.where(
and(
inArray(userStats.userId, memberIds),
eq(userStats.billingBlockedReason, 'payment_failed')
)
)
}
} else {
// Only unblock users blocked for payment_failed, not disputes
await db
.update(userStats)
.set({ billingBlocked: false })
.where(eq(userStats.userId, sub.referenceId))
.set({ billingBlocked: false, billingBlockedReason: null })
.where(
and(
eq(userStats.userId, sub.referenceId),
eq(userStats.billingBlockedReason, 'payment_failed')
)
)
}
if (wasBlocked) {
@@ -496,7 +621,7 @@ export async function handleInvoicePaymentFailed(event: Stripe.Event) {
if (memberIds.length > 0) {
await db
.update(userStats)
.set({ billingBlocked: true })
.set({ billingBlocked: true, billingBlockedReason: 'payment_failed' })
.where(inArray(userStats.userId, memberIds))
}
logger.info('Blocked team/enterprise members due to payment failure', {
@@ -507,7 +632,7 @@ export async function handleInvoicePaymentFailed(event: Stripe.Event) {
} else {
await db
.update(userStats)
.set({ billingBlocked: true })
.set({ billingBlocked: true, billingBlockedReason: 'payment_failed' })
.where(eq(userStats.userId, sub.referenceId))
logger.info('Blocked user due to payment failure', {
userId: sub.referenceId,
@@ -592,12 +717,34 @@ export async function handleInvoiceFinalized(event: Stripe.Event) {
const billedOverage = await getBilledOverageForSubscription(sub)
// Only bill the remaining unbilled overage
const remainingOverage = Math.max(0, totalOverage - billedOverage)
let remainingOverage = Math.max(0, totalOverage - billedOverage)
// Apply credits to reduce overage at end of cycle
let creditsApplied = 0
if (remainingOverage > 0) {
const entityType = sub.plan === 'team' || sub.plan === 'enterprise' ? 'organization' : 'user'
const entityId = sub.referenceId
const { balance: creditBalance } = await getCreditBalance(entityId)
if (creditBalance > 0) {
creditsApplied = Math.min(creditBalance, remainingOverage)
await removeCredits(entityType, entityId, creditsApplied)
remainingOverage = remainingOverage - creditsApplied
logger.info('Applied credits to reduce overage at cycle end', {
subscriptionId: sub.id,
creditBalance,
creditsApplied,
remainingOverageAfterCredits: remainingOverage,
})
}
}
logger.info('Invoice finalized overage calculation', {
subscriptionId: sub.id,
totalOverage,
billedOverage,
creditsApplied,
remainingOverage,
billingPeriod,
})

View File

@@ -566,6 +566,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
return
}
// All costs go to currentPeriodCost - credits are applied at end of billing cycle
const updateFields: any = {
totalTokensUsed: sql`total_tokens_used + ${costSummary.totalTokens}`,
totalCost: sql`total_cost + ${costToStore}`,

View File

@@ -35,7 +35,7 @@
"resolveJsonModule": true,
"isolatedModules": true,
"allowImportingTsExtensions": true,
"jsx": "react-jsx",
"jsx": "preserve",
"plugins": [
{
"name": "next"

View File

@@ -627,6 +627,11 @@ export const marketplace = pgTable('marketplace', {
updatedAt: timestamp('updated_at').notNull().defaultNow(),
})
export const billingBlockedReasonEnum = pgEnum('billing_blocked_reason', [
'payment_failed',
'dispute',
])
export const userStats = pgTable('user_stats', {
id: text('id').primaryKey(),
userId: text('user_id')
@@ -648,6 +653,8 @@ export const userStats = pgTable('user_stats', {
billedOverageThisPeriod: decimal('billed_overage_this_period').notNull().default('0'), // Amount of overage already billed via threshold billing
// Pro usage snapshot when joining a team (to prevent double-billing)
proPeriodCostSnapshot: decimal('pro_period_cost_snapshot').default('0'), // Snapshot of Pro usage when joining team
// Pre-purchased credits (for Pro users only)
creditBalance: decimal('credit_balance').notNull().default('0'),
// Copilot usage tracking
totalCopilotCost: decimal('total_copilot_cost').notNull().default('0'),
currentPeriodCopilotCost: decimal('current_period_copilot_cost').notNull().default('0'),
@@ -658,6 +665,7 @@ export const userStats = pgTable('user_stats', {
storageUsedBytes: bigint('storage_used_bytes', { mode: 'number' }).notNull().default(0),
lastActive: timestamp('last_active').notNull().defaultNow(),
billingBlocked: boolean('billing_blocked').notNull().default(false),
billingBlockedReason: billingBlockedReasonEnum('billing_blocked_reason'),
})
export const customTools = pgTable(
@@ -765,6 +773,7 @@ export const organization = pgTable('organization', {
orgUsageLimit: decimal('org_usage_limit'),
storageUsedBytes: bigint('storage_used_bytes', { mode: 'number' }).notNull().default(0),
departedMemberUsage: decimal('departed_member_usage').notNull().default('0'),
creditBalance: decimal('credit_balance').notNull().default('0'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
})