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
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { z } from 'zod'
import { getEmailSubject, renderInvitationEmail } from '@/components/emails'
import { getSession } from '@/lib/auth'
import { hasAccessControlAccess } from '@/lib/billing'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer'
Expand Down Expand Up @@ -501,6 +502,18 @@ export async function PUT(
}
}

if (status === 'accepted') {
try {
await syncUsageLimitsFromSubscription(session.user.id)
} catch (syncError) {
logger.error('Failed to sync usage limits after joining org', {
userId: session.user.id,
organizationId,
error: syncError,
})
}
}

logger.info(`Organization invitation ${status}`, {
organizationId,
invitationId,
Expand Down
9 changes: 9 additions & 0 deletions apps/sim/app/api/users/me/subscription/[id]/transfer/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { hasActiveSubscription } from '@/lib/billing'

const logger = createLogger('SubscriptionTransferAPI')

Expand Down Expand Up @@ -88,6 +89,14 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
)
}

// Check if org already has an active subscription (prevent duplicates)
if (await hasActiveSubscription(organizationId)) {
return NextResponse.json(
{ error: 'Organization already has an active subscription' },
{ status: 409 }
)
}

await db
.update(subscription)
.set({ referenceId: organizationId })
Expand Down
4 changes: 4 additions & 0 deletions apps/sim/app/api/v1/admin/users/[id]/billing/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,10 @@ export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) =
}

updateData.billingBlocked = body.billingBlocked
// Clear the reason when unblocking
if (body.billingBlocked === false) {
updateData.billingBlockedReason = null
}
updated.push('billingBlocked')
}

Expand Down
33 changes: 26 additions & 7 deletions apps/sim/app/api/workflows/[id]/execute-from-block/route.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import { db, workflow as workflowTable } from '@sim/db'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { SSE_HEADERS } from '@/lib/core/utils/sse'
import { markExecutionCancelled } from '@/lib/execution/cancellation'
import { preprocessExecution } from '@/lib/execution/preprocessing'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
import { createSSECallbacks } from '@/lib/workflows/executor/execution-events'
Expand Down Expand Up @@ -75,12 +74,31 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
const { startBlockId, sourceSnapshot, input } = validation.data
const executionId = uuidv4()

const [workflowRecord] = await db
.select({ workspaceId: workflowTable.workspaceId, userId: workflowTable.userId })
.from(workflowTable)
.where(eq(workflowTable.id, workflowId))
.limit(1)
// Run preprocessing checks (billing, rate limits, usage limits)
const preprocessResult = await preprocessExecution({
workflowId,
userId,
triggerType: 'manual',
executionId,
requestId,
checkRateLimit: false, // Manual executions don't rate limit
checkDeployment: false, // Run-from-block doesn't require deployment
})

if (!preprocessResult.success) {
const { error } = preprocessResult
logger.warn(`[${requestId}] Preprocessing failed for run-from-block`, {
workflowId,
error: error?.message,
statusCode: error?.statusCode,
})
return NextResponse.json(
{ error: error?.message || 'Execution blocked' },
{ status: error?.statusCode || 500 }
)
}

const workflowRecord = preprocessResult.workflowRecord
if (!workflowRecord?.workspaceId) {
return NextResponse.json({ error: 'Workflow not found or has no workspace' }, { status: 404 })
}
Expand All @@ -92,6 +110,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
workflowId,
startBlockId,
executedBlocksCount: sourceSnapshot.executedBlocks.length,
billingActorUserId: preprocessResult.actorUserId,
})

const loggingSession = new LoggingSession(workflowId, executionId, 'manual', requestId)
Expand Down
19 changes: 18 additions & 1 deletion apps/sim/lib/billing/authorization.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,37 @@
import { db } from '@sim/db'
import * as schema from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { hasActiveSubscription } from '@/lib/billing'

const logger = createLogger('BillingAuthorization')

/**
* Check if a user is authorized to manage billing for a given reference ID
* Reference ID can be either a user ID (individual subscription) or organization ID (team subscription)
*
* This function also performs duplicate subscription validation for organizations:
* - Rejects if an organization already has an active subscription (prevents duplicates)
* - Personal subscriptions (referenceId === userId) skip this check to allow upgrades
*/
export async function authorizeSubscriptionReference(
userId: string,
referenceId: string
): Promise<boolean> {
// User can always manage their own subscriptions
// User can always manage their own subscriptions (Pro upgrades, etc.)
if (referenceId === userId) {
return true
}

// For organizations: check for existing active subscriptions to prevent duplicates
if (await hasActiveSubscription(referenceId)) {
logger.warn('Blocking checkout - active subscription already exists for organization', {
userId,
referenceId,
})
return false
}

// Check if referenceId is an organizationId the user has admin rights to
const members = await db
.select()
Expand Down
23 changes: 22 additions & 1 deletion apps/sim/lib/billing/client/upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,11 @@ export function useSubscriptionUpgrade() {
}

let currentSubscriptionId: string | undefined
let allSubscriptions: any[] = []
try {
const listResult = await client.subscription.list()
const activePersonalSub = listResult.data?.find(
allSubscriptions = listResult.data || []
const activePersonalSub = allSubscriptions.find(
(sub: any) => sub.status === 'active' && sub.referenceId === userId
)
currentSubscriptionId = activePersonalSub?.id
Expand All @@ -50,6 +52,25 @@ export function useSubscriptionUpgrade() {
)

if (existingOrg) {
// Check if this org already has an active team subscription
const existingTeamSub = allSubscriptions.find(
(sub: any) =>
sub.status === 'active' &&
sub.referenceId === existingOrg.id &&
(sub.plan === 'team' || sub.plan === 'enterprise')
)

if (existingTeamSub) {
logger.warn('Organization already has an active team subscription', {
userId,
organizationId: existingOrg.id,
existingSubscriptionId: existingTeamSub.id,
})
throw new Error(
'This organization already has an active team subscription. Please manage it from the billing settings.'
)
}

logger.info('Using existing organization for team plan upgrade', {
userId,
organizationId: existingOrg.id,
Expand Down
22 changes: 17 additions & 5 deletions apps/sim/lib/billing/core/plan.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { db } from '@sim/db'
import { member, subscription } from '@sim/db/schema'
import { member, organization, subscription } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray } from 'drizzle-orm'
import { checkEnterprisePlan, checkProPlan, checkTeamPlan } from '@/lib/billing/subscriptions/utils'
Expand All @@ -26,10 +26,22 @@ export async function getHighestPrioritySubscription(userId: string) {

let orgSubs: typeof personalSubs = []
if (orgIds.length > 0) {
orgSubs = await db
.select()
.from(subscription)
.where(and(inArray(subscription.referenceId, orgIds), eq(subscription.status, 'active')))
// Verify orgs exist to filter out orphaned subscriptions
const existingOrgs = await db
.select({ id: organization.id })
.from(organization)
.where(inArray(organization.id, orgIds))

const validOrgIds = existingOrgs.map((o) => o.id)

if (validOrgIds.length > 0) {
orgSubs = await db
.select()
.from(subscription)
.where(
and(inArray(subscription.referenceId, validOrgIds), eq(subscription.status, 'active'))
)
}
}

const allSubs = [...personalSubs, ...orgSubs]
Expand Down
22 changes: 22 additions & 0 deletions apps/sim/lib/billing/core/subscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,28 @@ const logger = createLogger('SubscriptionCore')

export { getHighestPrioritySubscription }

/**
* Check if a referenceId (user ID or org ID) has an active subscription
* Used for duplicate subscription prevention
*
* Fails closed: returns true on error to prevent duplicate creation
*/
export async function hasActiveSubscription(referenceId: string): Promise<boolean> {
try {
const [activeSub] = await db
.select({ id: subscription.id })
.from(subscription)
.where(and(eq(subscription.referenceId, referenceId), eq(subscription.status, 'active')))
.limit(1)

return !!activeSub
} catch (error) {
logger.error('Error checking active subscription', { error, referenceId })
// Fail closed: assume subscription exists to prevent duplicate creation
return true
}
}

/**
* Check if user is on Pro plan (direct or via organization)
*/
Expand Down
6 changes: 6 additions & 0 deletions apps/sim/lib/billing/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export {
getHighestPrioritySubscription as getActiveSubscription,
getUserSubscriptionState as getSubscriptionState,
hasAccessControlAccess,
hasActiveSubscription,
hasCredentialSetsAccess,
hasSSOAccess,
isEnterpriseOrgAdminOrOwner,
Expand All @@ -32,6 +33,11 @@ export {
} from '@/lib/billing/core/usage'
export * from '@/lib/billing/credits/balance'
export * from '@/lib/billing/credits/purchase'
export {
blockOrgMembers,
getOrgMemberIds,
unblockOrgMembers,
} from '@/lib/billing/organizations/membership'
export * from '@/lib/billing/subscriptions/utils'
export { canEditUsageLimit as canEditLimit } from '@/lib/billing/subscriptions/utils'
export * from '@/lib/billing/types'
Expand Down
11 changes: 11 additions & 0 deletions apps/sim/lib/billing/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { hasActiveSubscription } from '@/lib/billing'
import { getPlanPricing } from '@/lib/billing/core/billing'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'

Expand Down Expand Up @@ -159,6 +160,16 @@ export async function ensureOrganizationForTeamSubscription(
if (existingMembership.length > 0) {
const membership = existingMembership[0]
if (membership.role === 'owner' || membership.role === 'admin') {
// Check if org already has an active subscription (prevent duplicates)
if (await hasActiveSubscription(membership.organizationId)) {
logger.error('Organization already has an active subscription', {
userId,
organizationId: membership.organizationId,
newSubscriptionId: subscription.id,
})
throw new Error('Organization already has an active subscription')
}

logger.info('User already owns/admins an org, using it', {
userId,
organizationId: membership.organizationId,
Expand Down
75 changes: 74 additions & 1 deletion apps/sim/lib/billing/organizations/membership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,86 @@ import {
userStats,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, sql } from 'drizzle-orm'
import { and, eq, inArray, isNull, ne, or, 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'

const logger = createLogger('OrganizationMembership')

export type BillingBlockReason = 'payment_failed' | 'dispute'

/**
* Get all member user IDs for an organization
*/
export async function getOrgMemberIds(organizationId: string): Promise<string[]> {
const members = await db
.select({ userId: member.userId })
.from(member)
.where(eq(member.organizationId, organizationId))

return members.map((m) => m.userId)
}

/**
* Block all members of an organization for billing reasons
* Returns the number of members actually blocked
*
* Reason priority: dispute > payment_failed
* A payment_failed block won't overwrite an existing dispute block
*/
export async function blockOrgMembers(
organizationId: string,
reason: BillingBlockReason
): Promise<number> {
const memberIds = await getOrgMemberIds(organizationId)

if (memberIds.length === 0) {
return 0
}

// Don't overwrite dispute blocks with payment_failed (dispute is higher priority)
const whereClause =
reason === 'payment_failed'
? and(
inArray(userStats.userId, memberIds),
or(ne(userStats.billingBlockedReason, 'dispute'), isNull(userStats.billingBlockedReason))
)
: inArray(userStats.userId, memberIds)

const result = await db
.update(userStats)
.set({ billingBlocked: true, billingBlockedReason: reason })
.where(whereClause)
.returning({ userId: userStats.userId })

return result.length
}

/**
* Unblock all members of an organization blocked for a specific reason
* Only unblocks members blocked for the specified reason (not other reasons)
* Returns the number of members actually unblocked
*/
export async function unblockOrgMembers(
organizationId: string,
reason: BillingBlockReason
): Promise<number> {
const memberIds = await getOrgMemberIds(organizationId)

if (memberIds.length === 0) {
return 0
}

const result = await db
.update(userStats)
.set({ billingBlocked: false, billingBlockedReason: null })
.where(and(inArray(userStats.userId, memberIds), eq(userStats.billingBlockedReason, reason)))
.returning({ userId: userStats.userId })

return result.length
}

export interface RestoreProResult {
restored: boolean
usageRestored: boolean
Expand Down
Loading