Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion apps/sim/app/api/organizations/[id]/seats/route.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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,
Expand Down
16 changes: 3 additions & 13 deletions apps/sim/lib/billing/calculations/usage-monitor.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -108,19 +107,10 @@ export async function checkUsageStatus(userId: string): Promise<UsageData> {
)
}
}
// 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
Expand Down
124 changes: 76 additions & 48 deletions apps/sim/lib/billing/core/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,56 @@ import { getEmailPreferences } from '@/lib/messaging/email/unsubscribe'

const logger = createLogger('UsageManagement')

export 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
*/
export async function getOrgUsageLimit(
organizationId: string,
plan: string,
seats: number | null
): Promise<OrgUsageLimitResult> {
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 }
}

const { basePrice } = getPlanPricing(plan)
const minimum = (seats ?? 0) * basePrice

if (configured !== null) {
return { limit: Math.max(configured, minimum), minimum }
}

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
Expand Down Expand Up @@ -87,22 +137,13 @@ export async function getUserUsageData(userId: string): Promise<UsageData> {
? 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
Expand Down Expand Up @@ -159,24 +200,15 @@ export async function getUserUsageLimitInfo(userId: string): Promise<UsageLimitI
minimumLimit = getPerUserMinimumLimit(subscription)
canEdit = canEditUsageLimit(subscription)
} else {
// Team/Enterprise: Use organization limits (users cannot edit)
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)
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 {
Expand Down Expand Up @@ -323,27 +355,23 @@ export async function getUserUsageLimit(userId: string): Promise<number> {

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
}

/**
Expand Down
50 changes: 46 additions & 4 deletions apps/sim/lib/billing/organization.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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) {
Expand All @@ -158,15 +200,15 @@ 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,
})
}
}

logger.info('Synced usage limits for organization members', {
organizationId: subscription.referenceId,
organizationId,
memberCount: members.length,
subscriptionId: subscription.id,
plan: subscription.plan,
Expand Down
24 changes: 7 additions & 17 deletions apps/sim/lib/logs/execution/logger.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { db } from '@sim/db'
import {
member,
organization,
userStats,
user as userTable,
workflow,
Expand All @@ -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'
Expand Down Expand Up @@ -386,21 +389,8 @@ export class ExecutionLogger implements IExecutionLoggerService {
limit,
})
} else if (sub?.referenceId) {
let orgLimit = 0
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)
} else {
orgLimit = 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)` })
Expand Down