Skip to content

Commit 7f4edc8

Browse files
authored
fix(billing): handle missing userStats and prevent crashes (#2956)
* fix(billing): handle missing userStats and prevent crashes * fix(billing): correct import path for getFilledPillColor * fix(billing): add Number.isFinite check to lastPeriodCost
1 parent efef91e commit 7f4edc8

File tree

10 files changed

+116
-162
lines changed

10 files changed

+116
-162
lines changed

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import { useParams } from 'next/navigation'
66
import { Combobox, Label, Switch, Tooltip } from '@/components/emcn'
77
import { Skeleton } from '@/components/ui'
88
import { useSession } from '@/lib/auth/auth-client'
9+
import { USAGE_THRESHOLDS } from '@/lib/billing/client/consts'
910
import { useSubscriptionUpgrade } from '@/lib/billing/client/upgrade'
10-
import { USAGE_THRESHOLDS } from '@/lib/billing/client/usage-visualization'
1111
import { getEffectiveSeats } from '@/lib/billing/subscriptions/utils'
1212
import { cn } from '@/lib/core/utils/cn'
1313
import { getBaseUrl } from '@/lib/core/utils/urls'

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/usage-header/usage-header.tsx

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,7 @@
22

33
import type { ReactNode } from 'react'
44
import { Badge } from '@/components/emcn'
5-
import {
6-
getFilledPillColor,
7-
USAGE_PILL_COLORS,
8-
USAGE_THRESHOLDS,
9-
} from '@/lib/billing/client/usage-visualization'
5+
import { getFilledPillColor, USAGE_PILL_COLORS, USAGE_THRESHOLDS } from '@/lib/billing/client'
106

117
const PILL_COUNT = 5
128

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/usage-indicator/usage-indicator.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@ import { createLogger } from '@sim/logger'
55
import { useQueryClient } from '@tanstack/react-query'
66
import { Badge } from '@/components/emcn'
77
import { Skeleton } from '@/components/ui'
8+
import { USAGE_PILL_COLORS, USAGE_THRESHOLDS } from '@/lib/billing/client/consts'
89
import { useSubscriptionUpgrade } from '@/lib/billing/client/upgrade'
910
import {
11+
getBillingStatus,
1012
getFilledPillColor,
11-
USAGE_PILL_COLORS,
12-
USAGE_THRESHOLDS,
13-
} from '@/lib/billing/client/usage-visualization'
14-
import { getBillingStatus, getSubscriptionStatus, getUsage } from '@/lib/billing/client/utils'
13+
getSubscriptionStatus,
14+
getUsage,
15+
} from '@/lib/billing/client/utils'
1516
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
1617
import { useSocket } from '@/app/workspace/providers/socket-provider'
1718
import { subscriptionKeys, useSubscriptionData } from '@/hooks/queries/subscription'

apps/sim/lib/auth/auth.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,17 @@ export const auth = betterAuth({
256256
return { data: account }
257257
},
258258
after: async (account) => {
259+
try {
260+
const { ensureUserStatsExists } = await import('@/lib/billing/core/usage')
261+
await ensureUserStatsExists(account.userId)
262+
} catch (error) {
263+
logger.error('[databaseHooks.account.create.after] Failed to ensure user stats', {
264+
userId: account.userId,
265+
accountId: account.id,
266+
error,
267+
})
268+
}
269+
259270
if (account.providerId === 'salesforce') {
260271
const updates: {
261272
accessTokenExpiresAt?: Date
@@ -462,7 +473,6 @@ export const auth = betterAuth({
462473
},
463474
emailVerification: {
464475
autoSignInAfterVerification: true,
465-
// onEmailVerification is called by the emailOTP plugin when email is verified via OTP
466476
onEmailVerification: async (user) => {
467477
if (isHosted && user.email) {
468478
try {
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* Number of pills to display in usage indicators.
3+
*/
4+
export const USAGE_PILL_COUNT = 8
5+
6+
/**
7+
* Usage percentage thresholds for visual states.
8+
*/
9+
export const USAGE_THRESHOLDS = {
10+
/** Warning threshold (yellow/orange state) */
11+
WARNING: 75,
12+
/** Critical threshold (red state) */
13+
CRITICAL: 90,
14+
} as const
15+
16+
/**
17+
* Color values for usage pill states using CSS variables
18+
*/
19+
export const USAGE_PILL_COLORS = {
20+
/** Unfilled pill color (gray) */
21+
UNFILLED: 'var(--surface-7)',
22+
/** Normal filled pill color (blue) */
23+
FILLED: 'var(--brand-secondary)',
24+
/** Warning state pill color (yellow/orange) */
25+
WARNING: 'var(--warning)',
26+
/** Critical/limit reached pill color (red) */
27+
AT_LIMIT: 'var(--text-error)',
28+
} as const

apps/sim/lib/billing/client/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
export {
2+
USAGE_PILL_COLORS,
3+
USAGE_THRESHOLDS,
4+
} from './consts'
15
export type {
26
BillingStatus,
37
SubscriptionData,
@@ -8,6 +12,7 @@ export {
812
canUpgrade,
913
getBillingStatus,
1014
getDaysRemainingInPeriod,
15+
getFilledPillColor,
1116
getRemainingBudget,
1217
getSubscriptionStatus,
1318
getUsage,

apps/sim/lib/billing/client/usage-visualization.ts

Lines changed: 0 additions & 150 deletions
This file was deleted.

apps/sim/lib/billing/client/utils.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
*/
55

66
import { DEFAULT_FREE_CREDITS } from '@/lib/billing/constants'
7+
import { USAGE_PILL_COLORS } from './consts'
78
import type { BillingStatus, SubscriptionData, UsageData } from './types'
89

910
const defaultUsage: UsageData = {
@@ -36,9 +37,35 @@ export function getSubscriptionStatus(subscriptionData: SubscriptionData | null
3637

3738
/**
3839
* Get usage data from subscription data
40+
* Validates and sanitizes all numeric values to prevent crashes from malformed data
3941
*/
4042
export function getUsage(subscriptionData: SubscriptionData | null | undefined): UsageData {
41-
return subscriptionData?.usage ?? defaultUsage
43+
const usage = subscriptionData?.usage
44+
45+
if (!usage) {
46+
return defaultUsage
47+
}
48+
49+
return {
50+
current:
51+
typeof usage.current === 'number' && Number.isFinite(usage.current) ? usage.current : 0,
52+
limit:
53+
typeof usage.limit === 'number' && Number.isFinite(usage.limit)
54+
? usage.limit
55+
: DEFAULT_FREE_CREDITS,
56+
percentUsed:
57+
typeof usage.percentUsed === 'number' && Number.isFinite(usage.percentUsed)
58+
? usage.percentUsed
59+
: 0,
60+
isWarning: Boolean(usage.isWarning),
61+
isExceeded: Boolean(usage.isExceeded),
62+
billingPeriodStart: usage.billingPeriodStart ?? null,
63+
billingPeriodEnd: usage.billingPeriodEnd ?? null,
64+
lastPeriodCost:
65+
typeof usage.lastPeriodCost === 'number' && Number.isFinite(usage.lastPeriodCost)
66+
? usage.lastPeriodCost
67+
: 0,
68+
}
4269
}
4370

4471
/**
@@ -100,3 +127,16 @@ export function canUpgrade(subscriptionData: SubscriptionData | null | undefined
100127
const status = getSubscriptionStatus(subscriptionData)
101128
return status.plan === 'free' || status.plan === 'pro'
102129
}
130+
131+
/**
132+
* Get the appropriate filled pill color based on usage thresholds.
133+
*
134+
* @param isCritical - Whether usage is at critical level (blocked or >= 90%)
135+
* @param isWarning - Whether usage is at warning level (>= 75% but < critical)
136+
* @returns CSS color value for filled pills
137+
*/
138+
export function getFilledPillColor(isCritical: boolean, isWarning: boolean): string {
139+
if (isCritical) return USAGE_PILL_COLORS.AT_LIMIT
140+
if (isWarning) return USAGE_PILL_COLORS.WARNING
141+
return USAGE_PILL_COLORS.FILLED
142+
}

apps/sim/lib/billing/core/usage.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,11 +96,32 @@ export async function handleNewUser(userId: string): Promise<void> {
9696
}
9797
}
9898

99+
/**
100+
* Ensures a userStats record exists for a user.
101+
* Creates one with default values if missing.
102+
* This is a fallback for cases where the user.create.after hook didn't fire
103+
* (e.g., OAuth account linking to existing users).
104+
*
105+
*/
106+
export async function ensureUserStatsExists(userId: string): Promise<void> {
107+
await db
108+
.insert(userStats)
109+
.values({
110+
id: crypto.randomUUID(),
111+
userId: userId,
112+
currentUsageLimit: getFreeTierLimit().toString(),
113+
usageLimitUpdatedAt: new Date(),
114+
})
115+
.onConflictDoNothing({ target: userStats.userId })
116+
}
117+
99118
/**
100119
* Get comprehensive usage data for a user
101120
*/
102121
export async function getUserUsageData(userId: string): Promise<UsageData> {
103122
try {
123+
await ensureUserStatsExists(userId)
124+
104125
const [userStatsData, subscription] = await Promise.all([
105126
db.select().from(userStats).where(eq(userStats.userId, userId)).limit(1),
106127
getHighestPrioritySubscription(userId),

apps/sim/lib/billing/organizations/membership.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
} from '@sim/db/schema'
1717
import { createLogger } from '@sim/logger'
1818
import { and, eq, sql } from 'drizzle-orm'
19+
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
1920
import { requireStripeClient } from '@/lib/billing/stripe-client'
2021
import { validateSeatAvailability } from '@/lib/billing/validation/seat-management'
2122

@@ -556,6 +557,8 @@ export async function removeUserFromOrganization(
556557
const restoreResult = await restoreUserProSubscription(userId)
557558
billingActions.proRestored = restoreResult.restored
558559
billingActions.usageRestored = restoreResult.usageRestored
560+
561+
await syncUsageLimitsFromSubscription(userId)
559562
}
560563
} catch (postRemoveError) {
561564
logger.error('Post-removal personal Pro restore check failed', {

0 commit comments

Comments
 (0)