Skip to content

Commit 0db5ba1

Browse files
fix(org-limits): remove fallbacks for enterprise plan (#2255)
* fix(org-limits): remove fallbacks for enterprise plan * remove comment * remove comments * make logger use new helper
1 parent e390ba0 commit 0db5ba1

File tree

5 files changed

+167
-83
lines changed

5 files changed

+167
-83
lines changed

apps/sim/app/api/organizations/[id]/seats/route.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { db } from '@sim/db'
2-
import { member, subscription } from '@sim/db/schema'
2+
import { member, organization, subscription } from '@sim/db/schema'
33
import { and, eq } from 'drizzle-orm'
44
import { type NextRequest, NextResponse } from 'next/server'
55
import { z } from 'zod'
66
import { getSession } from '@/lib/auth'
7+
import { getPlanPricing } from '@/lib/billing/core/billing'
78
import { requireStripeClient } from '@/lib/billing/stripe-client'
89
import { isBillingEnabled } from '@/lib/core/config/environment'
910
import { createLogger } from '@/lib/logs/console/logger'
@@ -172,6 +173,39 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
172173
})
173174
.where(eq(subscription.id, orgSubscription.id))
174175

176+
// Update orgUsageLimit to reflect new seat count (seats × basePrice as minimum)
177+
const { basePrice } = getPlanPricing('team')
178+
const newMinimumLimit = newSeatCount * basePrice
179+
180+
const orgData = await db
181+
.select({ orgUsageLimit: organization.orgUsageLimit })
182+
.from(organization)
183+
.where(eq(organization.id, organizationId))
184+
.limit(1)
185+
186+
const currentOrgLimit =
187+
orgData.length > 0 && orgData[0].orgUsageLimit
188+
? Number.parseFloat(orgData[0].orgUsageLimit)
189+
: 0
190+
191+
// Update if new minimum is higher than current limit
192+
if (newMinimumLimit > currentOrgLimit) {
193+
await db
194+
.update(organization)
195+
.set({
196+
orgUsageLimit: newMinimumLimit.toFixed(2),
197+
updatedAt: new Date(),
198+
})
199+
.where(eq(organization.id, organizationId))
200+
201+
logger.info('Updated organization usage limit for seat change', {
202+
organizationId,
203+
newSeatCount,
204+
newMinimumLimit,
205+
previousLimit: currentOrgLimit,
206+
})
207+
}
208+
175209
logger.info('Successfully updated seat count', {
176210
organizationId,
177211
stripeSubscriptionId: orgSubscription.stripeSubscriptionId,

apps/sim/lib/billing/calculations/usage-monitor.ts

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { db } from '@sim/db'
22
import { member, organization, userStats } from '@sim/db/schema'
33
import { and, eq, inArray } from 'drizzle-orm'
4-
import { getOrganizationSubscription, getPlanPricing } from '@/lib/billing/core/billing'
54
import { getUserUsageLimit } from '@/lib/billing/core/usage'
65
import { isBillingEnabled } from '@/lib/core/config/environment'
76
import { createLogger } from '@/lib/logs/console/logger'
@@ -108,19 +107,10 @@ export async function checkUsageStatus(userId: string): Promise<UsageData> {
108107
)
109108
}
110109
}
111-
// Determine org cap
112-
let orgCap = org.orgUsageLimit ? Number.parseFloat(String(org.orgUsageLimit)) : 0
110+
// Determine org cap from orgUsageLimit (should always be set for team/enterprise)
111+
const orgCap = org.orgUsageLimit ? Number.parseFloat(String(org.orgUsageLimit)) : 0
113112
if (!orgCap || Number.isNaN(orgCap)) {
114-
// Fall back to minimum billing amount from Stripe subscription
115-
const orgSub = await getOrganizationSubscription(org.id)
116-
if (orgSub?.seats) {
117-
const { basePrice } = getPlanPricing(orgSub.plan)
118-
orgCap = (orgSub.seats ?? 0) * basePrice
119-
} else {
120-
// If no subscription, use team default
121-
const { basePrice } = getPlanPricing('team')
122-
orgCap = basePrice // Default to 1 seat minimum
123-
}
113+
logger.warn('Organization missing usage limit', { orgId: org.id })
124114
}
125115
if (pooledUsage >= orgCap) {
126116
isExceeded = true

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

Lines changed: 76 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,56 @@ import { getEmailPreferences } from '@/lib/messaging/email/unsubscribe'
2222

2323
const logger = createLogger('UsageManagement')
2424

25+
export interface OrgUsageLimitResult {
26+
limit: number
27+
minimum: number
28+
}
29+
30+
/**
31+
* Calculates the effective usage limit for a team or enterprise organization.
32+
* - Enterprise: Uses orgUsageLimit directly (fixed pricing)
33+
* - Team: Uses orgUsageLimit but never below seats × basePrice
34+
*/
35+
export async function getOrgUsageLimit(
36+
organizationId: string,
37+
plan: string,
38+
seats: number | null
39+
): Promise<OrgUsageLimitResult> {
40+
const orgData = await db
41+
.select({ orgUsageLimit: organization.orgUsageLimit })
42+
.from(organization)
43+
.where(eq(organization.id, organizationId))
44+
.limit(1)
45+
46+
const configured =
47+
orgData.length > 0 && orgData[0].orgUsageLimit
48+
? Number.parseFloat(orgData[0].orgUsageLimit)
49+
: null
50+
51+
if (plan === 'enterprise') {
52+
// Enterprise: Use configured limit directly (no per-seat minimum)
53+
if (configured !== null) {
54+
return { limit: configured, minimum: configured }
55+
}
56+
logger.warn('Enterprise org missing usage limit', { orgId: organizationId })
57+
return { limit: 0, minimum: 0 }
58+
}
59+
60+
const { basePrice } = getPlanPricing(plan)
61+
const minimum = (seats ?? 0) * basePrice
62+
63+
if (configured !== null) {
64+
return { limit: Math.max(configured, minimum), minimum }
65+
}
66+
67+
logger.warn('Team org missing usage limit, using seats × basePrice fallback', {
68+
orgId: organizationId,
69+
seats,
70+
minimum,
71+
})
72+
return { limit: minimum, minimum }
73+
}
74+
2575
/**
2676
* Handle new user setup when they join the platform
2777
* Creates userStats record with default free credits
@@ -87,22 +137,13 @@ export async function getUserUsageData(userId: string): Promise<UsageData> {
87137
? Number.parseFloat(stats.currentUsageLimit)
88138
: getFreeTierLimit()
89139
} else {
90-
// Team/Enterprise: Use organization limit but never below minimum (seats × cost per seat)
91-
const orgData = await db
92-
.select({ orgUsageLimit: organization.orgUsageLimit })
93-
.from(organization)
94-
.where(eq(organization.id, subscription.referenceId))
95-
.limit(1)
96-
97-
const { basePrice } = getPlanPricing(subscription.plan)
98-
const minimum = (subscription.seats ?? 0) * basePrice
99-
100-
if (orgData.length > 0 && orgData[0].orgUsageLimit) {
101-
const configured = Number.parseFloat(orgData[0].orgUsageLimit)
102-
limit = Math.max(configured, minimum)
103-
} else {
104-
limit = minimum
105-
}
140+
// Team/Enterprise: Use organization limit
141+
const orgLimit = await getOrgUsageLimit(
142+
subscription.referenceId,
143+
subscription.plan,
144+
subscription.seats
145+
)
146+
limit = orgLimit.limit
106147
}
107148

108149
const percentUsed = limit > 0 ? Math.min((currentUsage / limit) * 100, 100) : 0
@@ -159,24 +200,15 @@ export async function getUserUsageLimitInfo(userId: string): Promise<UsageLimitI
159200
minimumLimit = getPerUserMinimumLimit(subscription)
160201
canEdit = canEditUsageLimit(subscription)
161202
} else {
162-
// Team/Enterprise: Use organization limits (users cannot edit)
163-
const orgData = await db
164-
.select({ orgUsageLimit: organization.orgUsageLimit })
165-
.from(organization)
166-
.where(eq(organization.id, subscription.referenceId))
167-
.limit(1)
168-
169-
const { basePrice } = getPlanPricing(subscription.plan)
170-
const minimum = (subscription.seats ?? 0) * basePrice
171-
172-
if (orgData.length > 0 && orgData[0].orgUsageLimit) {
173-
const configured = Number.parseFloat(orgData[0].orgUsageLimit)
174-
currentLimit = Math.max(configured, minimum)
175-
} else {
176-
currentLimit = minimum
177-
}
178-
minimumLimit = minimum
179-
canEdit = false // Team/enterprise members cannot edit limits
203+
// Team/Enterprise: Use organization limits
204+
const orgLimit = await getOrgUsageLimit(
205+
subscription.referenceId,
206+
subscription.plan,
207+
subscription.seats
208+
)
209+
currentLimit = orgLimit.limit
210+
minimumLimit = orgLimit.minimum
211+
canEdit = false
180212
}
181213

182214
return {
@@ -323,27 +355,23 @@ export async function getUserUsageLimit(userId: string): Promise<number> {
323355

324356
return Number.parseFloat(userStatsQuery[0].currentUsageLimit)
325357
}
326-
// Team/Enterprise: Use organization limit but never below minimum
327-
const orgData = await db
328-
.select({ orgUsageLimit: organization.orgUsageLimit })
358+
// Team/Enterprise: Verify org exists then use organization limit
359+
const orgExists = await db
360+
.select({ id: organization.id })
329361
.from(organization)
330362
.where(eq(organization.id, subscription.referenceId))
331363
.limit(1)
332364

333-
if (orgData.length === 0) {
365+
if (orgExists.length === 0) {
334366
throw new Error(`Organization not found: ${subscription.referenceId} for user: ${userId}`)
335367
}
336368

337-
if (orgData[0].orgUsageLimit) {
338-
const configured = Number.parseFloat(orgData[0].orgUsageLimit)
339-
const { basePrice } = getPlanPricing(subscription.plan)
340-
const minimum = (subscription.seats ?? 0) * basePrice
341-
return Math.max(configured, minimum)
342-
}
343-
344-
// If org hasn't set a custom limit, use minimum (seats × cost per seat)
345-
const { basePrice } = getPlanPricing(subscription.plan)
346-
return (subscription.seats ?? 0) * basePrice
369+
const orgLimit = await getOrgUsageLimit(
370+
subscription.referenceId,
371+
subscription.plan,
372+
subscription.seats
373+
)
374+
return orgLimit.limit
347375
}
348376

349377
/**

apps/sim/lib/billing/organization.ts

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { db } from '@sim/db'
22
import * as schema from '@sim/db/schema'
33
import { and, eq } from 'drizzle-orm'
4+
import { getPlanPricing } from '@/lib/billing/core/billing'
45
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
56
import { createLogger } from '@/lib/logs/console/logger'
67

@@ -145,11 +146,52 @@ export async function syncSubscriptionUsageLimits(subscription: SubscriptionData
145146
plan: subscription.plan,
146147
})
147148
} else {
148-
// Organization subscription - sync usage limits for all members
149+
// Organization subscription - set org usage limit and sync member limits
150+
const organizationId = subscription.referenceId
151+
152+
// Set orgUsageLimit for team plans (enterprise is set via webhook with custom pricing)
153+
if (subscription.plan === 'team') {
154+
const { basePrice } = getPlanPricing(subscription.plan)
155+
const seats = subscription.seats ?? 1
156+
const orgLimit = seats * basePrice
157+
158+
// Only set if not already set or if updating to a higher value based on seats
159+
const orgData = await db
160+
.select({ orgUsageLimit: schema.organization.orgUsageLimit })
161+
.from(schema.organization)
162+
.where(eq(schema.organization.id, organizationId))
163+
.limit(1)
164+
165+
const currentLimit =
166+
orgData.length > 0 && orgData[0].orgUsageLimit
167+
? Number.parseFloat(orgData[0].orgUsageLimit)
168+
: 0
169+
170+
// Update if no limit set, or if new seat-based minimum is higher
171+
if (currentLimit < orgLimit) {
172+
await db
173+
.update(schema.organization)
174+
.set({
175+
orgUsageLimit: orgLimit.toFixed(2),
176+
updatedAt: new Date(),
177+
})
178+
.where(eq(schema.organization.id, organizationId))
179+
180+
logger.info('Set organization usage limit for team plan', {
181+
organizationId,
182+
seats,
183+
basePrice,
184+
orgLimit,
185+
previousLimit: currentLimit,
186+
})
187+
}
188+
}
189+
190+
// Sync usage limits for all members
149191
const members = await db
150192
.select({ userId: schema.member.userId })
151193
.from(schema.member)
152-
.where(eq(schema.member.organizationId, subscription.referenceId))
194+
.where(eq(schema.member.organizationId, organizationId))
153195

154196
if (members.length > 0) {
155197
for (const member of members) {
@@ -158,15 +200,15 @@ export async function syncSubscriptionUsageLimits(subscription: SubscriptionData
158200
} catch (memberError) {
159201
logger.error('Failed to sync usage limits for organization member', {
160202
userId: member.userId,
161-
organizationId: subscription.referenceId,
203+
organizationId,
162204
subscriptionId: subscription.id,
163205
error: memberError,
164206
})
165207
}
166208
}
167209

168210
logger.info('Synced usage limits for organization members', {
169-
organizationId: subscription.referenceId,
211+
organizationId,
170212
memberCount: members.length,
171213
subscriptionId: subscription.id,
172214
plan: subscription.plan,

apps/sim/lib/logs/execution/logger.ts

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { db } from '@sim/db'
22
import {
33
member,
4-
organization,
54
userStats,
65
user as userTable,
76
workflow,
@@ -10,7 +9,11 @@ import {
109
import { eq, sql } from 'drizzle-orm'
1110
import { v4 as uuidv4 } from 'uuid'
1211
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
13-
import { checkUsageStatus, maybeSendUsageThresholdEmail } from '@/lib/billing/core/usage'
12+
import {
13+
checkUsageStatus,
14+
getOrgUsageLimit,
15+
maybeSendUsageThresholdEmail,
16+
} from '@/lib/billing/core/usage'
1417
import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
1518
import { isBillingEnabled } from '@/lib/core/config/environment'
1619
import { redactApiKeys } from '@/lib/core/security/redaction'
@@ -386,21 +389,8 @@ export class ExecutionLogger implements IExecutionLoggerService {
386389
limit,
387390
})
388391
} else if (sub?.referenceId) {
389-
let orgLimit = 0
390-
const orgRows = await db
391-
.select({ orgUsageLimit: organization.orgUsageLimit })
392-
.from(organization)
393-
.where(eq(organization.id, sub.referenceId))
394-
.limit(1)
395-
const { getPlanPricing } = await import('@/lib/billing/core/billing')
396-
const { basePrice } = getPlanPricing(sub.plan)
397-
const minimum = (sub.seats || 1) * basePrice
398-
if (orgRows.length > 0 && orgRows[0].orgUsageLimit) {
399-
const configured = Number.parseFloat(orgRows[0].orgUsageLimit)
400-
orgLimit = Math.max(configured, minimum)
401-
} else {
402-
orgLimit = minimum
403-
}
392+
// Get org usage limit using shared helper
393+
const { limit: orgLimit } = await getOrgUsageLimit(sub.referenceId, sub.plan, sub.seats)
404394

405395
const [{ sum: orgUsageBefore }] = await db
406396
.select({ sum: sql`COALESCE(SUM(${userStats.currentPeriodCost}), 0)` })

0 commit comments

Comments
 (0)