diff --git a/apps/sim/app/api/billing/credits/route.ts b/apps/sim/app/api/billing/credits/route.ts
new file mode 100644
index 00000000000..31d9089f5e6
--- /dev/null
+++ b/apps/sim/app/api/billing/credits/route.ts
@@ -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 })
+ }
+}
diff --git a/apps/sim/app/api/billing/route.ts b/apps/sim/app/api/billing/route.ts
index b9c7bb4b7ff..33e1559afa8 100644
--- a/apps/sim/app/api/billing/route.ts
+++ b/apps/sim/app/api/billing/route.ts
@@ -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,
})
}
diff --git a/apps/sim/app/api/billing/update-cost/route.ts b/apps/sim/app/api/billing/update-cost/route.ts
index c22a21cdbb7..3beed143f4d 100644
--- a/apps/sim/app/api/billing/update-cost/route.ts
+++ b/apps/sim/app/api/billing/update-cost/route.ts
@@ -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(),
}
diff --git a/apps/sim/app/api/usage/route.ts b/apps/sim/app/api/usage/route.ts
index 90909c571af..4ca818e7878 100644
--- a/apps/sim/app/api/usage/route.ts
+++ b/apps/sim/app/api/usage/route.ts
@@ -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!)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/footer-navigation/footer-navigation.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/footer-navigation/footer-navigation.tsx
index b973e85d1ac..1c6dc3d60a8 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/footer-navigation/footer-navigation.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/footer-navigation/footer-navigation.tsx
@@ -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',
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/shared/usage-header.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/shared/usage-header.tsx
index 371395d6c4d..090664a1264 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/shared/usage-header.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/shared/usage-header.tsx
@@ -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({
{/* Status messages */}
- {isBlocked && (
+ {isBlocked && blockedReason === 'dispute' && (
+
+
+ Account frozen. Please contact support to resolve this issue.
+
+ {onContactSupport && (
+
+ Get help
+
+ )}
+
+ )}
+
+ {isBlocked && blockedReason !== 'dispute' && !blockedByOrgOwner && (
Payment failed. Please update your payment method.
@@ -131,6 +154,22 @@ export function UsageHeader({
)}
+ {isBlocked && blockedByOrgOwner && blockedReason !== 'dispute' && (
+
+
+ Organization billing issue. Please contact your organization owner.
+
+
+ )}
+
+ {isBlocked && blockedByOrgOwner && blockedReason === 'dispute' && (
+
+
+ Organization account frozen. Please contact support.
+
+
+ )}
+
{!isBlocked && status === 'exceeded' && (
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/components/credit-balance/credit-balance.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/components/credit-balance/credit-balance.tsx
new file mode 100644
index 00000000000..337df1f68d7
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/components/credit-balance/credit-balance.tsx
@@ -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(null)
+ const [success, setSuccess] = useState(false)
+ const [requestId, setRequestId] = useState(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 (
+
+
+ Credit Balance
+ {isLoading ? '...' : `$${balance.toFixed(2)}`}
+
+
+ {canPurchase && (
+
+
+ Add Credits
+
+
+ Add Credits
+
+
+ Credits are used before overage charges. Min $10, max $1,000.
+
+
+
+ {success ? (
+
+
+ Credits added successfully!
+
+
+ ) : (
+
+
+
+ Amount (USD)
+
+
+
+ $
+
+ handleAmountChange(e.target.value)}
+ placeholder='50'
+ className='pl-7'
+ disabled={isPurchasing}
+ />
+
+ {error &&
{error} }
+
+
+
+
+ Credits are non-refundable and don't expire. They'll be applied automatically to
+ your {entityType === 'organization' ? 'team' : ''} usage.
+
+
+
+ )}
+
+ {!success && (
+
+
+
+ Cancel
+
+
+
+ {isPurchasing ? 'Processing...' : 'Purchase'}
+
+
+ )}
+
+
+ )}
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/components/credit-balance/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/components/credit-balance/index.ts
new file mode 100644
index 00000000000..0b11de6a165
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/components/credit-balance/index.ts
@@ -0,0 +1 @@
+export { CreditBalance } from './credit-balance'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/components/index.ts
index ab9d7604fba..064e4ae7efd 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/components/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/components/index.ts
@@ -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'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/subscription.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/subscription.tsx
index 1d8d4afe946..6a627db9509 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/subscription.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/subscription.tsx
@@ -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) {
)}
- {/* 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' && (
-
-
-
- )}
- */}
-
{/* Team Member Notice */}
{permissions.showTeamMemberView && (
@@ -535,9 +520,20 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
)}
+ {/* Credit Balance */}
+ {subscription.isPaid && (
+ refetchSubscription()}
+ />
+ )}
+
{/* Next Billing Date */}
{subscription.isPaid && subscriptionData?.data?.periodEnd && (
-
+
Next Billing Date
{new Date(subscriptionData.data.periodEnd).toLocaleDateString()}
@@ -617,7 +613,7 @@ function BillingUsageNotificationsToggle() {
const isLoading = updateSetting.isPending
return (
-
+
Usage notifications
Email me when I reach 80% usage
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/settings-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/settings-modal.tsx
index 05456870dd8..e51b202f122 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/settings-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/settings-modal.tsx
@@ -434,9 +434,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
)}
{activeSection === 'apikeys' &&
}
{activeSection === 'files' &&
}
- {isBillingEnabled && activeSection === 'subscription' && (
-
- )}
+ {isBillingEnabled && activeSection === 'subscription' &&
}
{isBillingEnabled && activeSection === 'team' &&
}
{activeSection === 'sso' &&
}
{activeSection === 'copilot' &&
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/usage-indicator/usage-indicator.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/usage-indicator/usage-indicator.tsx
index 45f1d556d2a..34c148ad9bc 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/usage-indicator/usage-indicator.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/usage-indicator/usage-indicator.tsx
@@ -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) {
{isBlocked ? (
- <>
- Payment
- Required
- >
+ isDispute ? (
+ <>
+ Account
+ Frozen
+ >
+ ) : (
+ <>
+ Payment
+ Required
+ >
+ )
) : (
<>
@@ -292,7 +316,9 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
}`}
onClick={handleClick}
>
- {isBlocked ? 'Fix Now' : 'Upgrade'}
+
+ {isBlocked ? (isDispute ? 'Get Help' : 'Fix Now') : 'Upgrade'}
+
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/page.tsx b/apps/sim/app/workspace/[workspaceId]/w/page.tsx
index 9cf0382ca80..a8f3605700b 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/page.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/page.tsx
@@ -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"
diff --git a/apps/sim/components/emails/billing/credit-purchase-email.tsx b/apps/sim/components/emails/billing/credit-purchase-email.tsx
new file mode 100644
index 00000000000..8668ef9f443
--- /dev/null
+++ b/apps/sim/components/emails/billing/credit-purchase-email.tsx
@@ -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 (
+
+
+
{previewText}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {userName ? `Hi ${userName},` : 'Hi,'}
+
+
+ Your credit purchase of ${amount.toFixed(2)} has been confirmed.
+
+
+
+ Amount Added
+
+ ${amount.toFixed(2)}
+
+ New Balance
+
+ ${newBalance.toFixed(2)}
+
+
+
+
+ These credits will be applied automatically to your workflow executions. Credits are
+ consumed before any overage charges apply.
+
+
+
+ View Dashboard
+
+
+
+
+
+ You can view your credit balance and purchase history in Settings → Subscription.
+
+
+
+ Best regards,
+
+ The Sim Team
+
+
+
+ Purchased on {purchaseDate.toLocaleDateString()}
+
+
+
+
+
+
+ )
+}
+
+export default CreditPurchaseEmail
diff --git a/apps/sim/components/emails/render-email.ts b/apps/sim/components/emails/render-email.ts
index 48fd0bbc120..15efb1cf1ba 100644
--- a/apps/sim/components/emails/render-email.ts
+++ b/apps/sim/components/emails/render-email.ts
@@ -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
{
+ return await render(
+ CreditPurchaseEmail({
+ userName: params.userName,
+ amount: params.amount,
+ newBalance: params.newBalance,
+ purchaseDate: new Date(),
+ })
+ )
+}
diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts
index 934e76dc3da..2a577d66b8d 100644
--- a/apps/sim/lib/auth/auth.ts
+++ b/apps/sim/lib/auth/auth.ts
@@ -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,
@@ -1980,7 +1981,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,
diff --git a/apps/sim/lib/billing/calculations/usage-monitor.ts b/apps/sim/lib/billing/calculations/usage-monitor.ts
index c048c41a680..daddf286acf 100644
--- a/apps/sim/lib/billing/calculations/usage-monitor.ts
+++ b/apps/sim/lib/billing/calculations/usage-monitor.ts
@@ -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,
+ }
+ }
}
}
diff --git a/apps/sim/lib/billing/core/billing.ts b/apps/sim/lib/billing/core/billing.ts
index 2c670a1c52e..5feac6bab6b 100644
--- a/apps/sim/lib/billing/core/billing.ts
+++ b/apps/sim/lib/billing/core/billing.ts
@@ -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,
diff --git a/apps/sim/lib/billing/core/usage.ts b/apps/sim/lib/billing/core/usage.ts
index 80ec17fff1d..ff52e3fb44f 100644
--- a/apps/sim/lib/billing/core/usage.ts
+++ b/apps/sim/lib/billing/core/usage.ts
@@ -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 {
.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 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 {
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
}
diff --git a/apps/sim/lib/billing/credits/balance.ts b/apps/sim/lib/billing/credits/balance.ts
new file mode 100644
index 00000000000..f1f32824fe4
--- /dev/null
+++ b/apps/sim/lib/billing/credits/balance.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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'
+}
diff --git a/apps/sim/lib/billing/credits/purchase.ts b/apps/sim/lib/billing/credits/purchase.ts
new file mode 100644
index 00000000000..08792e1973b
--- /dev/null
+++ b/apps/sim/lib/billing/credits/purchase.ts
@@ -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 {
+ 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 {
+ 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 }
+ }
+}
diff --git a/apps/sim/lib/billing/index.ts b/apps/sim/lib/billing/index.ts
index dbc285f0a93..cfa17f87856 100644
--- a/apps/sim/lib/billing/index.ts
+++ b/apps/sim/lib/billing/index.ts
@@ -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'
diff --git a/apps/sim/lib/billing/subscriptions/utils.ts b/apps/sim/lib/billing/subscriptions/utils.ts
index 3a8d1b63844..ef54ba83264 100644
--- a/apps/sim/lib/billing/subscriptions/utils.ts
+++ b/apps/sim/lib/billing/subscriptions/utils.ts
@@ -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 }
+ }
+}
diff --git a/apps/sim/lib/billing/threshold-billing.ts b/apps/sim/lib/billing/threshold-billing.ts
index 40b97aa64ef..72b1d033e16 100644
--- a/apps/sim/lib/billing/threshold-billing.ts
+++ b/apps/sim/lib/billing/threshold-billing.ts
@@ -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 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 {
+ // 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,
})
})
diff --git a/apps/sim/lib/billing/webhooks/disputes.ts b/apps/sim/lib/billing/webhooks/disputes.ts
new file mode 100644
index 00000000000..7637a0b55be
--- /dev/null
+++ b/apps/sim/lib/billing/webhooks/disputes.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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,
+ })
+ }
+ }
+}
diff --git a/apps/sim/lib/billing/webhooks/invoices.ts b/apps/sim/lib/billing/webhooks/invoices.ts
index 8d272aad627..3110f60af94 100644
--- a/apps/sim/lib/billing/webhooks/invoices.ts
+++ b/apps/sim/lib/billing/webhooks/invoices.ts
@@ -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 {
+ 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,
})
diff --git a/apps/sim/lib/logs/execution/logger.ts b/apps/sim/lib/logs/execution/logger.ts
index 046cd77e69e..53d6b604b97 100644
--- a/apps/sim/lib/logs/execution/logger.ts
+++ b/apps/sim/lib/logs/execution/logger.ts
@@ -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}`,
diff --git a/apps/sim/tsconfig.json b/apps/sim/tsconfig.json
index f48d70e63d9..abaee989791 100644
--- a/apps/sim/tsconfig.json
+++ b/apps/sim/tsconfig.json
@@ -35,7 +35,7 @@
"resolveJsonModule": true,
"isolatedModules": true,
"allowImportingTsExtensions": true,
- "jsx": "react-jsx",
+ "jsx": "preserve",
"plugins": [
{
"name": "next"
diff --git a/packages/db/schema.ts b/packages/db/schema.ts
index 815f1299307..ae7a7190a25 100644
--- a/packages/db/schema.ts
+++ b/packages/db/schema.ts
@@ -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(),
})