diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx
index ee8ec7499..7581e8d4f 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx
@@ -6,8 +6,8 @@ import { useParams } from 'next/navigation'
import { Combobox, Label, Switch, Tooltip } from '@/components/emcn'
import { Skeleton } from '@/components/ui'
import { useSession } from '@/lib/auth/auth-client'
+import { USAGE_THRESHOLDS } from '@/lib/billing/client/consts'
import { useSubscriptionUpgrade } from '@/lib/billing/client/upgrade'
-import { USAGE_THRESHOLDS } from '@/lib/billing/client/usage-visualization'
import { getEffectiveSeats } from '@/lib/billing/subscriptions/utils'
import { cn } from '@/lib/core/utils/cn'
import { getBaseUrl } from '@/lib/core/utils/urls'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/usage-header/usage-header.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/usage-header/usage-header.tsx
index eeae01faf..1ced1b516 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/usage-header/usage-header.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/usage-header/usage-header.tsx
@@ -2,11 +2,7 @@
import type { ReactNode } from 'react'
import { Badge } from '@/components/emcn'
-import {
- getFilledPillColor,
- USAGE_PILL_COLORS,
- USAGE_THRESHOLDS,
-} from '@/lib/billing/client/usage-visualization'
+import { getFilledPillColor, USAGE_PILL_COLORS, USAGE_THRESHOLDS } from '@/lib/billing/client'
const PILL_COUNT = 5
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/usage-indicator/usage-indicator.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/usage-indicator/usage-indicator.tsx
index b24435924..71ef8060c 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/usage-indicator/usage-indicator.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/usage-indicator/usage-indicator.tsx
@@ -5,13 +5,14 @@ import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { Badge } from '@/components/emcn'
import { Skeleton } from '@/components/ui'
+import { USAGE_PILL_COLORS, USAGE_THRESHOLDS } from '@/lib/billing/client/consts'
import { useSubscriptionUpgrade } from '@/lib/billing/client/upgrade'
import {
+ getBillingStatus,
getFilledPillColor,
- USAGE_PILL_COLORS,
- USAGE_THRESHOLDS,
-} from '@/lib/billing/client/usage-visualization'
-import { getBillingStatus, getSubscriptionStatus, getUsage } from '@/lib/billing/client/utils'
+ getSubscriptionStatus,
+ getUsage,
+} from '@/lib/billing/client/utils'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { useSocket } from '@/app/workspace/providers/socket-provider'
import { subscriptionKeys, useSubscriptionData } from '@/hooks/queries/subscription'
diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts
index bc1c10c88..0a70d7903 100644
--- a/apps/sim/lib/auth/auth.ts
+++ b/apps/sim/lib/auth/auth.ts
@@ -256,6 +256,17 @@ export const auth = betterAuth({
return { data: account }
},
after: async (account) => {
+ try {
+ const { ensureUserStatsExists } = await import('@/lib/billing/core/usage')
+ await ensureUserStatsExists(account.userId)
+ } catch (error) {
+ logger.error('[databaseHooks.account.create.after] Failed to ensure user stats', {
+ userId: account.userId,
+ accountId: account.id,
+ error,
+ })
+ }
+
if (account.providerId === 'salesforce') {
const updates: {
accessTokenExpiresAt?: Date
@@ -462,7 +473,6 @@ export const auth = betterAuth({
},
emailVerification: {
autoSignInAfterVerification: true,
- // onEmailVerification is called by the emailOTP plugin when email is verified via OTP
onEmailVerification: async (user) => {
if (isHosted && user.email) {
try {
diff --git a/apps/sim/lib/billing/client/consts.ts b/apps/sim/lib/billing/client/consts.ts
new file mode 100644
index 000000000..32cbec806
--- /dev/null
+++ b/apps/sim/lib/billing/client/consts.ts
@@ -0,0 +1,28 @@
+/**
+ * Number of pills to display in usage indicators.
+ */
+export const USAGE_PILL_COUNT = 8
+
+/**
+ * Usage percentage thresholds for visual states.
+ */
+export const USAGE_THRESHOLDS = {
+ /** Warning threshold (yellow/orange state) */
+ WARNING: 75,
+ /** Critical threshold (red state) */
+ CRITICAL: 90,
+} as const
+
+/**
+ * Color values for usage pill states using CSS variables
+ */
+export const USAGE_PILL_COLORS = {
+ /** Unfilled pill color (gray) */
+ UNFILLED: 'var(--surface-7)',
+ /** Normal filled pill color (blue) */
+ FILLED: 'var(--brand-secondary)',
+ /** Warning state pill color (yellow/orange) */
+ WARNING: 'var(--warning)',
+ /** Critical/limit reached pill color (red) */
+ AT_LIMIT: 'var(--text-error)',
+} as const
diff --git a/apps/sim/lib/billing/client/index.ts b/apps/sim/lib/billing/client/index.ts
index 24ef12179..b08880594 100644
--- a/apps/sim/lib/billing/client/index.ts
+++ b/apps/sim/lib/billing/client/index.ts
@@ -1,3 +1,7 @@
+export {
+ USAGE_PILL_COLORS,
+ USAGE_THRESHOLDS,
+} from './consts'
export type {
BillingStatus,
SubscriptionData,
@@ -8,6 +12,7 @@ export {
canUpgrade,
getBillingStatus,
getDaysRemainingInPeriod,
+ getFilledPillColor,
getRemainingBudget,
getSubscriptionStatus,
getUsage,
diff --git a/apps/sim/lib/billing/client/usage-visualization.ts b/apps/sim/lib/billing/client/usage-visualization.ts
deleted file mode 100644
index 8ab0a344a..000000000
--- a/apps/sim/lib/billing/client/usage-visualization.ts
+++ /dev/null
@@ -1,150 +0,0 @@
-/**
- * Shared utilities for consistent usage visualization across the application.
- *
- * This module provides a single source of truth for how usage metrics are
- * displayed visually through "pills" or progress indicators.
- */
-
-/**
- * Number of pills to display in usage indicators.
- *
- * Using 8 pills provides:
- * - 12.5% granularity per pill
- * - Good balance between precision and visual clarity
- * - Consistent representation across panel and settings
- */
-export const USAGE_PILL_COUNT = 8
-
-/**
- * Usage percentage thresholds for visual states.
- */
-export const USAGE_THRESHOLDS = {
- /** Warning threshold (yellow/orange state) */
- WARNING: 75,
- /** Critical threshold (red state) */
- CRITICAL: 90,
-} as const
-
-/**
- * Color values for usage pill states using CSS variables
- */
-export const USAGE_PILL_COLORS = {
- /** Unfilled pill color (gray) */
- UNFILLED: 'var(--surface-7)',
- /** Normal filled pill color (blue) */
- FILLED: 'var(--brand-secondary)',
- /** Warning state pill color (yellow/orange) */
- WARNING: 'var(--warning)',
- /** Critical/limit reached pill color (red) */
- AT_LIMIT: 'var(--text-error)',
-} as const
-
-/**
- * Calculate the number of filled pills based on usage percentage.
- *
- * Uses Math.ceil() to ensure even minimal usage (0.01%) shows visual feedback.
- * This provides better UX by making it clear that there is some usage, even if small.
- *
- * @param percentUsed - The usage percentage (0-100). Can be a decimal (e.g., 0.315 for 0.315%)
- * @returns Number of pills that should be filled (0 to USAGE_PILL_COUNT)
- *
- * @example
- * calculateFilledPills(0.315) // Returns 1 (shows feedback for 0.315% usage)
- * calculateFilledPills(50) // Returns 4 (50% of 8 pills)
- * calculateFilledPills(100) // Returns 8 (completely filled)
- * calculateFilledPills(150) // Returns 8 (clamped to maximum)
- */
-export function calculateFilledPills(percentUsed: number): number {
- // Clamp percentage to valid range [0, 100]
- const safePercent = Math.min(Math.max(percentUsed, 0), 100)
-
- // Calculate filled pills using ceil to show feedback for any usage
- return Math.ceil((safePercent / 100) * USAGE_PILL_COUNT)
-}
-
-/**
- * Determine if usage has reached the limit (all pills filled).
- *
- * @param percentUsed - The usage percentage (0-100)
- * @returns true if all pills should be filled (at or over limit)
- */
-export function isUsageAtLimit(percentUsed: number): boolean {
- return calculateFilledPills(percentUsed) >= USAGE_PILL_COUNT
-}
-
-/**
- * Get the appropriate color for a pill based on its state.
- *
- * @param isFilled - Whether this pill should be filled
- * @param isAtLimit - Whether usage has reached the limit
- * @returns CSS color value
- */
-export function getPillColor(isFilled: boolean, isAtLimit: boolean): string {
- if (!isFilled) return USAGE_PILL_COLORS.UNFILLED
- if (isAtLimit) return USAGE_PILL_COLORS.AT_LIMIT
- return USAGE_PILL_COLORS.FILLED
-}
-
-/**
- * Get the appropriate filled pill color based on usage thresholds.
- *
- * @param isCritical - Whether usage is at critical level (blocked or >= 90%)
- * @param isWarning - Whether usage is at warning level (>= 75% but < critical)
- * @returns CSS color value for filled pills
- */
-export function getFilledPillColor(isCritical: boolean, isWarning: boolean): string {
- if (isCritical) return USAGE_PILL_COLORS.AT_LIMIT
- if (isWarning) return USAGE_PILL_COLORS.WARNING
- return USAGE_PILL_COLORS.FILLED
-}
-
-/**
- * Determine usage state based on percentage and blocked status.
- *
- * @param percentUsed - The usage percentage (0-100)
- * @param isBlocked - Whether the account is blocked
- * @returns Object containing isCritical and isWarning flags
- */
-export function getUsageState(
- percentUsed: number,
- isBlocked = false
-): { isCritical: boolean; isWarning: boolean } {
- const isCritical = isBlocked || percentUsed >= USAGE_THRESHOLDS.CRITICAL
- const isWarning = !isCritical && percentUsed >= USAGE_THRESHOLDS.WARNING
- return { isCritical, isWarning }
-}
-
-/**
- * Generate an array of pill states for rendering.
- *
- * @param percentUsed - The usage percentage (0-100)
- * @param isBlocked - Whether the account is blocked
- * @returns Array of pill states with colors
- *
- * @example
- * const pills = generatePillStates(50)
- * pills.forEach((pill, index) => (
- *
- * ))
- */
-export function generatePillStates(
- percentUsed: number,
- isBlocked = false
-): Array<{
- filled: boolean
- color: string
- index: number
-}> {
- const filledCount = calculateFilledPills(percentUsed)
- const { isCritical, isWarning } = getUsageState(percentUsed, isBlocked)
- const filledColor = getFilledPillColor(isCritical, isWarning)
-
- return Array.from({ length: USAGE_PILL_COUNT }, (_, index) => {
- const filled = index < filledCount
- return {
- filled,
- color: filled ? filledColor : USAGE_PILL_COLORS.UNFILLED,
- index,
- }
- })
-}
diff --git a/apps/sim/lib/billing/client/utils.ts b/apps/sim/lib/billing/client/utils.ts
index 5266c46d9..5f6c7fda6 100644
--- a/apps/sim/lib/billing/client/utils.ts
+++ b/apps/sim/lib/billing/client/utils.ts
@@ -4,6 +4,7 @@
*/
import { DEFAULT_FREE_CREDITS } from '@/lib/billing/constants'
+import { USAGE_PILL_COLORS } from './consts'
import type { BillingStatus, SubscriptionData, UsageData } from './types'
const defaultUsage: UsageData = {
@@ -36,9 +37,35 @@ export function getSubscriptionStatus(subscriptionData: SubscriptionData | null
/**
* Get usage data from subscription data
+ * Validates and sanitizes all numeric values to prevent crashes from malformed data
*/
export function getUsage(subscriptionData: SubscriptionData | null | undefined): UsageData {
- return subscriptionData?.usage ?? defaultUsage
+ const usage = subscriptionData?.usage
+
+ if (!usage) {
+ return defaultUsage
+ }
+
+ return {
+ current:
+ typeof usage.current === 'number' && Number.isFinite(usage.current) ? usage.current : 0,
+ limit:
+ typeof usage.limit === 'number' && Number.isFinite(usage.limit)
+ ? usage.limit
+ : DEFAULT_FREE_CREDITS,
+ percentUsed:
+ typeof usage.percentUsed === 'number' && Number.isFinite(usage.percentUsed)
+ ? usage.percentUsed
+ : 0,
+ isWarning: Boolean(usage.isWarning),
+ isExceeded: Boolean(usage.isExceeded),
+ billingPeriodStart: usage.billingPeriodStart ?? null,
+ billingPeriodEnd: usage.billingPeriodEnd ?? null,
+ lastPeriodCost:
+ typeof usage.lastPeriodCost === 'number' && Number.isFinite(usage.lastPeriodCost)
+ ? usage.lastPeriodCost
+ : 0,
+ }
}
/**
@@ -100,3 +127,16 @@ export function canUpgrade(subscriptionData: SubscriptionData | null | undefined
const status = getSubscriptionStatus(subscriptionData)
return status.plan === 'free' || status.plan === 'pro'
}
+
+/**
+ * Get the appropriate filled pill color based on usage thresholds.
+ *
+ * @param isCritical - Whether usage is at critical level (blocked or >= 90%)
+ * @param isWarning - Whether usage is at warning level (>= 75% but < critical)
+ * @returns CSS color value for filled pills
+ */
+export function getFilledPillColor(isCritical: boolean, isWarning: boolean): string {
+ if (isCritical) return USAGE_PILL_COLORS.AT_LIMIT
+ if (isWarning) return USAGE_PILL_COLORS.WARNING
+ return USAGE_PILL_COLORS.FILLED
+}
diff --git a/apps/sim/lib/billing/core/usage.ts b/apps/sim/lib/billing/core/usage.ts
index 85977b8db..beefdc261 100644
--- a/apps/sim/lib/billing/core/usage.ts
+++ b/apps/sim/lib/billing/core/usage.ts
@@ -96,11 +96,32 @@ export async function handleNewUser(userId: string): Promise {
}
}
+/**
+ * Ensures a userStats record exists for a user.
+ * Creates one with default values if missing.
+ * This is a fallback for cases where the user.create.after hook didn't fire
+ * (e.g., OAuth account linking to existing users).
+ *
+ */
+export async function ensureUserStatsExists(userId: string): Promise {
+ await db
+ .insert(userStats)
+ .values({
+ id: crypto.randomUUID(),
+ userId: userId,
+ currentUsageLimit: getFreeTierLimit().toString(),
+ usageLimitUpdatedAt: new Date(),
+ })
+ .onConflictDoNothing({ target: userStats.userId })
+}
+
/**
* Get comprehensive usage data for a user
*/
export async function getUserUsageData(userId: string): Promise {
try {
+ await ensureUserStatsExists(userId)
+
const [userStatsData, subscription] = await Promise.all([
db.select().from(userStats).where(eq(userStats.userId, userId)).limit(1),
getHighestPrioritySubscription(userId),
diff --git a/apps/sim/lib/billing/organizations/membership.ts b/apps/sim/lib/billing/organizations/membership.ts
index ed02c5111..4bfeff5ce 100644
--- a/apps/sim/lib/billing/organizations/membership.ts
+++ b/apps/sim/lib/billing/organizations/membership.ts
@@ -16,6 +16,7 @@ import {
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, sql } from 'drizzle-orm'
+import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { validateSeatAvailability } from '@/lib/billing/validation/seat-management'
@@ -556,6 +557,8 @@ export async function removeUserFromOrganization(
const restoreResult = await restoreUserProSubscription(userId)
billingActions.proRestored = restoreResult.restored
billingActions.usageRestored = restoreResult.usageRestored
+
+ await syncUsageLimitsFromSubscription(userId)
}
} catch (postRemoveError) {
logger.error('Post-removal personal Pro restore check failed', {