From 75ce65cf86c59988cd0f01b6a10219d4bfb1582d Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 8 Dec 2025 15:36:49 -0800 Subject: [PATCH 1/4] fix(org-limits): remove fallbacks for enterprise plan --- .../app/api/organizations/[id]/seats/route.ts | 36 ++++- .../lib/billing/calculations/usage-monitor.ts | 16 +-- apps/sim/lib/billing/core/usage.ts | 126 +++++++++++------- apps/sim/lib/billing/organization.ts | 50 ++++++- apps/sim/lib/logs/execution/logger.ts | 24 ++-- 5 files changed, 178 insertions(+), 74 deletions(-) diff --git a/apps/sim/app/api/organizations/[id]/seats/route.ts b/apps/sim/app/api/organizations/[id]/seats/route.ts index c107b42a81..c13ebc4292 100644 --- a/apps/sim/app/api/organizations/[id]/seats/route.ts +++ b/apps/sim/app/api/organizations/[id]/seats/route.ts @@ -1,9 +1,10 @@ import { db } from '@sim/db' -import { member, subscription } from '@sim/db/schema' +import { member, organization, subscription } from '@sim/db/schema' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' +import { getPlanPricing } from '@/lib/billing/core/billing' import { requireStripeClient } from '@/lib/billing/stripe-client' import { isBillingEnabled } from '@/lib/core/config/environment' import { createLogger } from '@/lib/logs/console/logger' @@ -172,6 +173,39 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ }) .where(eq(subscription.id, orgSubscription.id)) + // Update orgUsageLimit to reflect new seat count (seats × basePrice as minimum) + const { basePrice } = getPlanPricing('team') + const newMinimumLimit = newSeatCount * basePrice + + const orgData = await db + .select({ orgUsageLimit: organization.orgUsageLimit }) + .from(organization) + .where(eq(organization.id, organizationId)) + .limit(1) + + const currentOrgLimit = + orgData.length > 0 && orgData[0].orgUsageLimit + ? Number.parseFloat(orgData[0].orgUsageLimit) + : 0 + + // Update if new minimum is higher than current limit + if (newMinimumLimit > currentOrgLimit) { + await db + .update(organization) + .set({ + orgUsageLimit: newMinimumLimit.toFixed(2), + updatedAt: new Date(), + }) + .where(eq(organization.id, organizationId)) + + logger.info('Updated organization usage limit for seat change', { + organizationId, + newSeatCount, + newMinimumLimit, + previousLimit: currentOrgLimit, + }) + } + logger.info('Successfully updated seat count', { organizationId, stripeSubscriptionId: orgSubscription.stripeSubscriptionId, diff --git a/apps/sim/lib/billing/calculations/usage-monitor.ts b/apps/sim/lib/billing/calculations/usage-monitor.ts index daddf286ac..66cf8fbe44 100644 --- a/apps/sim/lib/billing/calculations/usage-monitor.ts +++ b/apps/sim/lib/billing/calculations/usage-monitor.ts @@ -1,7 +1,6 @@ import { db } from '@sim/db' import { member, organization, userStats } from '@sim/db/schema' 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' import { createLogger } from '@/lib/logs/console/logger' @@ -108,19 +107,10 @@ export async function checkUsageStatus(userId: string): Promise { ) } } - // Determine org cap - let orgCap = org.orgUsageLimit ? Number.parseFloat(String(org.orgUsageLimit)) : 0 + // Determine org cap from orgUsageLimit (should always be set for team/enterprise) + const orgCap = org.orgUsageLimit ? Number.parseFloat(String(org.orgUsageLimit)) : 0 if (!orgCap || Number.isNaN(orgCap)) { - // Fall back to minimum billing amount from Stripe subscription - const orgSub = await getOrganizationSubscription(org.id) - if (orgSub?.seats) { - const { basePrice } = getPlanPricing(orgSub.plan) - orgCap = (orgSub.seats ?? 0) * basePrice - } else { - // If no subscription, use team default - const { basePrice } = getPlanPricing('team') - orgCap = basePrice // Default to 1 seat minimum - } + logger.warn('Organization missing usage limit', { orgId: org.id }) } if (pooledUsage >= orgCap) { isExceeded = true diff --git a/apps/sim/lib/billing/core/usage.ts b/apps/sim/lib/billing/core/usage.ts index ff52e3fb44..9b639e76c4 100644 --- a/apps/sim/lib/billing/core/usage.ts +++ b/apps/sim/lib/billing/core/usage.ts @@ -22,6 +22,58 @@ import { getEmailPreferences } from '@/lib/messaging/email/unsubscribe' const logger = createLogger('UsageManagement') +interface OrgUsageLimitResult { + limit: number + minimum: number +} + +/** + * Calculates the effective usage limit for a team or enterprise organization. + * - Enterprise: Uses orgUsageLimit directly (fixed pricing) + * - Team: Uses orgUsageLimit but never below seats × basePrice + */ +async function getOrgUsageLimit( + organizationId: string, + plan: string, + seats: number | null +): Promise { + const orgData = await db + .select({ orgUsageLimit: organization.orgUsageLimit }) + .from(organization) + .where(eq(organization.id, organizationId)) + .limit(1) + + const configured = + orgData.length > 0 && orgData[0].orgUsageLimit + ? Number.parseFloat(orgData[0].orgUsageLimit) + : null + + if (plan === 'enterprise') { + // Enterprise: Use configured limit directly (no per-seat minimum) + if (configured !== null) { + return { limit: configured, minimum: configured } + } + logger.warn('Enterprise org missing usage limit', { orgId: organizationId }) + return { limit: 0, minimum: 0 } + } + + // Team: Use orgUsageLimit (should always be set, but fallback to seats × basePrice for safety) + const { basePrice } = getPlanPricing(plan) + const minimum = (seats ?? 0) * basePrice + + if (configured !== null) { + return { limit: Math.max(configured, minimum), minimum } + } + + // Fallback for backward compatibility - orgUsageLimit should be set by syncSubscriptionUsageLimits + logger.warn('Team org missing usage limit, using seats × basePrice fallback', { + orgId: organizationId, + seats, + minimum, + }) + return { limit: minimum, minimum } +} + /** * Handle new user setup when they join the platform * Creates userStats record with default free credits @@ -87,22 +139,13 @@ export async function getUserUsageData(userId: string): Promise { ? Number.parseFloat(stats.currentUsageLimit) : getFreeTierLimit() } else { - // Team/Enterprise: Use organization limit but never below minimum (seats × cost per seat) - const orgData = await db - .select({ orgUsageLimit: organization.orgUsageLimit }) - .from(organization) - .where(eq(organization.id, subscription.referenceId)) - .limit(1) - - const { basePrice } = getPlanPricing(subscription.plan) - const minimum = (subscription.seats ?? 0) * basePrice - - if (orgData.length > 0 && orgData[0].orgUsageLimit) { - const configured = Number.parseFloat(orgData[0].orgUsageLimit) - limit = Math.max(configured, minimum) - } else { - limit = minimum - } + // Team/Enterprise: Use organization limit + const orgLimit = await getOrgUsageLimit( + subscription.referenceId, + subscription.plan, + subscription.seats + ) + limit = orgLimit.limit } const percentUsed = limit > 0 ? Math.min((currentUsage / limit) * 100, 100) : 0 @@ -159,24 +202,15 @@ export async function getUserUsageLimitInfo(userId: string): Promise 0 && orgData[0].orgUsageLimit) { - const configured = Number.parseFloat(orgData[0].orgUsageLimit) - currentLimit = Math.max(configured, minimum) - } else { - currentLimit = minimum - } - minimumLimit = minimum - canEdit = false // Team/enterprise members cannot edit limits + // Team/Enterprise: Use organization limits + const orgLimit = await getOrgUsageLimit( + subscription.referenceId, + subscription.plan, + subscription.seats + ) + currentLimit = orgLimit.limit + minimumLimit = orgLimit.minimum + canEdit = false } return { @@ -323,27 +357,23 @@ export async function getUserUsageLimit(userId: string): Promise { return Number.parseFloat(userStatsQuery[0].currentUsageLimit) } - // Team/Enterprise: Use organization limit but never below minimum - const orgData = await db - .select({ orgUsageLimit: organization.orgUsageLimit }) + // Team/Enterprise: Verify org exists then use organization limit + const orgExists = await db + .select({ id: organization.id }) .from(organization) .where(eq(organization.id, subscription.referenceId)) .limit(1) - if (orgData.length === 0) { + if (orgExists.length === 0) { throw new Error(`Organization not found: ${subscription.referenceId} for user: ${userId}`) } - if (orgData[0].orgUsageLimit) { - const configured = Number.parseFloat(orgData[0].orgUsageLimit) - 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 { basePrice } = getPlanPricing(subscription.plan) - return (subscription.seats ?? 0) * basePrice + const orgLimit = await getOrgUsageLimit( + subscription.referenceId, + subscription.plan, + subscription.seats + ) + return orgLimit.limit } /** diff --git a/apps/sim/lib/billing/organization.ts b/apps/sim/lib/billing/organization.ts index 6c2fdff72a..17511fc4ad 100644 --- a/apps/sim/lib/billing/organization.ts +++ b/apps/sim/lib/billing/organization.ts @@ -1,6 +1,7 @@ import { db } from '@sim/db' import * as schema from '@sim/db/schema' import { and, eq } from 'drizzle-orm' +import { getPlanPricing } from '@/lib/billing/core/billing' import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage' import { createLogger } from '@/lib/logs/console/logger' @@ -145,11 +146,52 @@ export async function syncSubscriptionUsageLimits(subscription: SubscriptionData plan: subscription.plan, }) } else { - // Organization subscription - sync usage limits for all members + // Organization subscription - set org usage limit and sync member limits + const organizationId = subscription.referenceId + + // Set orgUsageLimit for team plans (enterprise is set via webhook with custom pricing) + if (subscription.plan === 'team') { + const { basePrice } = getPlanPricing(subscription.plan) + const seats = subscription.seats ?? 1 + const orgLimit = seats * basePrice + + // Only set if not already set or if updating to a higher value based on seats + const orgData = await db + .select({ orgUsageLimit: schema.organization.orgUsageLimit }) + .from(schema.organization) + .where(eq(schema.organization.id, organizationId)) + .limit(1) + + const currentLimit = + orgData.length > 0 && orgData[0].orgUsageLimit + ? Number.parseFloat(orgData[0].orgUsageLimit) + : 0 + + // Update if no limit set, or if new seat-based minimum is higher + if (currentLimit < orgLimit) { + await db + .update(schema.organization) + .set({ + orgUsageLimit: orgLimit.toFixed(2), + updatedAt: new Date(), + }) + .where(eq(schema.organization.id, organizationId)) + + logger.info('Set organization usage limit for team plan', { + organizationId, + seats, + basePrice, + orgLimit, + previousLimit: currentLimit, + }) + } + } + + // Sync usage limits for all members const members = await db .select({ userId: schema.member.userId }) .from(schema.member) - .where(eq(schema.member.organizationId, subscription.referenceId)) + .where(eq(schema.member.organizationId, organizationId)) if (members.length > 0) { for (const member of members) { @@ -158,7 +200,7 @@ export async function syncSubscriptionUsageLimits(subscription: SubscriptionData } catch (memberError) { logger.error('Failed to sync usage limits for organization member', { userId: member.userId, - organizationId: subscription.referenceId, + organizationId, subscriptionId: subscription.id, error: memberError, }) @@ -166,7 +208,7 @@ export async function syncSubscriptionUsageLimits(subscription: SubscriptionData } logger.info('Synced usage limits for organization members', { - organizationId: subscription.referenceId, + organizationId, memberCount: members.length, subscriptionId: subscription.id, plan: subscription.plan, diff --git a/apps/sim/lib/logs/execution/logger.ts b/apps/sim/lib/logs/execution/logger.ts index 53d6b604b9..e3e705009c 100644 --- a/apps/sim/lib/logs/execution/logger.ts +++ b/apps/sim/lib/logs/execution/logger.ts @@ -386,20 +386,28 @@ export class ExecutionLogger implements IExecutionLoggerService { limit, }) } else if (sub?.referenceId) { - let orgLimit = 0 + // Fetch org usage limit const orgRows = await db .select({ orgUsageLimit: organization.orgUsageLimit }) .from(organization) .where(eq(organization.id, sub.referenceId)) .limit(1) - const { getPlanPricing } = await import('@/lib/billing/core/billing') - const { basePrice } = getPlanPricing(sub.plan) - const minimum = (sub.seats || 1) * basePrice - if (orgRows.length > 0 && orgRows[0].orgUsageLimit) { - const configured = Number.parseFloat(orgRows[0].orgUsageLimit) - orgLimit = Math.max(configured, minimum) + + const configured = + orgRows.length > 0 && orgRows[0].orgUsageLimit + ? Number.parseFloat(orgRows[0].orgUsageLimit) + : null + + let orgLimit: number + if (sub.plan === 'enterprise') { + // Enterprise: Use configured limit directly (no per-seat minimum) + orgLimit = configured ?? 0 } else { - orgLimit = minimum + // Team: Use configured limit but never below seats × basePrice + const { getPlanPricing } = await import('@/lib/billing/core/billing') + const { basePrice } = getPlanPricing(sub.plan) + const minimum = (sub.seats || 1) * basePrice + orgLimit = configured !== null ? Math.max(configured, minimum) : minimum } const [{ sum: orgUsageBefore }] = await db From 530a0d83052a266a7040551f23ed4029247b0f4a Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 8 Dec 2025 15:41:55 -0800 Subject: [PATCH 2/4] remove comment --- apps/sim/lib/billing/core/usage.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/sim/lib/billing/core/usage.ts b/apps/sim/lib/billing/core/usage.ts index 9b639e76c4..9be1560190 100644 --- a/apps/sim/lib/billing/core/usage.ts +++ b/apps/sim/lib/billing/core/usage.ts @@ -65,7 +65,6 @@ async function getOrgUsageLimit( return { limit: Math.max(configured, minimum), minimum } } - // Fallback for backward compatibility - orgUsageLimit should be set by syncSubscriptionUsageLimits logger.warn('Team org missing usage limit, using seats × basePrice fallback', { orgId: organizationId, seats, From 697f811e3e7e6ca8abfbfc6861ad0b01327b9d91 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 8 Dec 2025 15:42:17 -0800 Subject: [PATCH 3/4] remove comments --- apps/sim/lib/billing/core/usage.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/sim/lib/billing/core/usage.ts b/apps/sim/lib/billing/core/usage.ts index 9be1560190..5ea3e85947 100644 --- a/apps/sim/lib/billing/core/usage.ts +++ b/apps/sim/lib/billing/core/usage.ts @@ -57,7 +57,6 @@ async function getOrgUsageLimit( return { limit: 0, minimum: 0 } } - // Team: Use orgUsageLimit (should always be set, but fallback to seats × basePrice for safety) const { basePrice } = getPlanPricing(plan) const minimum = (seats ?? 0) * basePrice From 6c35464d1dfead69f65cb01bf1c56de0bb57488a Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 8 Dec 2025 16:49:42 -0800 Subject: [PATCH 4/4] make logger use new helper --- apps/sim/lib/billing/core/usage.ts | 4 ++-- apps/sim/lib/logs/execution/logger.ts | 32 ++++++--------------------- 2 files changed, 9 insertions(+), 27 deletions(-) diff --git a/apps/sim/lib/billing/core/usage.ts b/apps/sim/lib/billing/core/usage.ts index 5ea3e85947..f784151cf5 100644 --- a/apps/sim/lib/billing/core/usage.ts +++ b/apps/sim/lib/billing/core/usage.ts @@ -22,7 +22,7 @@ import { getEmailPreferences } from '@/lib/messaging/email/unsubscribe' const logger = createLogger('UsageManagement') -interface OrgUsageLimitResult { +export interface OrgUsageLimitResult { limit: number minimum: number } @@ -32,7 +32,7 @@ interface OrgUsageLimitResult { * - Enterprise: Uses orgUsageLimit directly (fixed pricing) * - Team: Uses orgUsageLimit but never below seats × basePrice */ -async function getOrgUsageLimit( +export async function getOrgUsageLimit( organizationId: string, plan: string, seats: number | null diff --git a/apps/sim/lib/logs/execution/logger.ts b/apps/sim/lib/logs/execution/logger.ts index e3e705009c..22727ef42c 100644 --- a/apps/sim/lib/logs/execution/logger.ts +++ b/apps/sim/lib/logs/execution/logger.ts @@ -1,7 +1,6 @@ import { db } from '@sim/db' import { member, - organization, userStats, user as userTable, workflow, @@ -10,7 +9,11 @@ import { import { eq, sql } from 'drizzle-orm' import { v4 as uuidv4 } from 'uuid' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' -import { checkUsageStatus, maybeSendUsageThresholdEmail } from '@/lib/billing/core/usage' +import { + checkUsageStatus, + getOrgUsageLimit, + maybeSendUsageThresholdEmail, +} from '@/lib/billing/core/usage' import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing' import { isBillingEnabled } from '@/lib/core/config/environment' import { redactApiKeys } from '@/lib/core/security/redaction' @@ -386,29 +389,8 @@ export class ExecutionLogger implements IExecutionLoggerService { limit, }) } else if (sub?.referenceId) { - // Fetch org usage limit - const orgRows = await db - .select({ orgUsageLimit: organization.orgUsageLimit }) - .from(organization) - .where(eq(organization.id, sub.referenceId)) - .limit(1) - - const configured = - orgRows.length > 0 && orgRows[0].orgUsageLimit - ? Number.parseFloat(orgRows[0].orgUsageLimit) - : null - - let orgLimit: number - if (sub.plan === 'enterprise') { - // Enterprise: Use configured limit directly (no per-seat minimum) - orgLimit = configured ?? 0 - } else { - // Team: Use configured limit but never below seats × basePrice - const { getPlanPricing } = await import('@/lib/billing/core/billing') - const { basePrice } = getPlanPricing(sub.plan) - const minimum = (sub.seats || 1) * basePrice - orgLimit = configured !== null ? Math.max(configured, minimum) : minimum - } + // Get org usage limit using shared helper + const { limit: orgLimit } = await getOrgUsageLimit(sub.referenceId, sub.plan, sub.seats) const [{ sum: orgUsageBefore }] = await db .select({ sum: sql`COALESCE(SUM(${userStats.currentPeriodCost}), 0)` })