Skip to content

Commit 0449804

Browse files
improvement(billing): duplicate checks for bypasses, logger billing actor consistency, run from block (#3107)
* improvement(billing): improve against direct subscription creation bypasses * more usage of block/unblock helpers * address bugbot comments * fail closed * only run dup check for orgs
1 parent c286f3e commit 0449804

File tree

15 files changed

+304
-97
lines changed

15 files changed

+304
-97
lines changed

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { z } from 'zod'
2020
import { getEmailSubject, renderInvitationEmail } from '@/components/emails'
2121
import { getSession } from '@/lib/auth'
2222
import { hasAccessControlAccess } from '@/lib/billing'
23+
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
2324
import { requireStripeClient } from '@/lib/billing/stripe-client'
2425
import { getBaseUrl } from '@/lib/core/utils/urls'
2526
import { sendEmail } from '@/lib/messaging/email/mailer'
@@ -501,6 +502,18 @@ export async function PUT(
501502
}
502503
}
503504

505+
if (status === 'accepted') {
506+
try {
507+
await syncUsageLimitsFromSubscription(session.user.id)
508+
} catch (syncError) {
509+
logger.error('Failed to sync usage limits after joining org', {
510+
userId: session.user.id,
511+
organizationId,
512+
error: syncError,
513+
})
514+
}
515+
}
516+
504517
logger.info(`Organization invitation ${status}`, {
505518
organizationId,
506519
invitationId,

apps/sim/app/api/users/me/subscription/[id]/transfer/route.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { and, eq } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
66
import { z } from 'zod'
77
import { getSession } from '@/lib/auth'
8+
import { hasActiveSubscription } from '@/lib/billing'
89

910
const logger = createLogger('SubscriptionTransferAPI')
1011

@@ -88,6 +89,14 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
8889
)
8990
}
9091

92+
// Check if org already has an active subscription (prevent duplicates)
93+
if (await hasActiveSubscription(organizationId)) {
94+
return NextResponse.json(
95+
{ error: 'Organization already has an active subscription' },
96+
{ status: 409 }
97+
)
98+
}
99+
91100
await db
92101
.update(subscription)
93102
.set({ referenceId: organizationId })

apps/sim/app/api/v1/admin/users/[id]/billing/route.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,10 @@ export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) =
203203
}
204204

205205
updateData.billingBlocked = body.billingBlocked
206+
// Clear the reason when unblocking
207+
if (body.billingBlocked === false) {
208+
updateData.billingBlockedReason = null
209+
}
206210
updated.push('billingBlocked')
207211
}
208212

apps/sim/app/api/workflows/[id]/execute-from-block/route.ts

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
1-
import { db, workflow as workflowTable } from '@sim/db'
21
import { createLogger } from '@sim/logger'
3-
import { eq } from 'drizzle-orm'
42
import { type NextRequest, NextResponse } from 'next/server'
53
import { v4 as uuidv4 } from 'uuid'
64
import { z } from 'zod'
75
import { checkHybridAuth } from '@/lib/auth/hybrid'
86
import { generateRequestId } from '@/lib/core/utils/request'
97
import { SSE_HEADERS } from '@/lib/core/utils/sse'
108
import { markExecutionCancelled } from '@/lib/execution/cancellation'
9+
import { preprocessExecution } from '@/lib/execution/preprocessing'
1110
import { LoggingSession } from '@/lib/logs/execution/logging-session'
1211
import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
1312
import { createSSECallbacks } from '@/lib/workflows/executor/execution-events'
@@ -75,12 +74,31 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
7574
const { startBlockId, sourceSnapshot, input } = validation.data
7675
const executionId = uuidv4()
7776

78-
const [workflowRecord] = await db
79-
.select({ workspaceId: workflowTable.workspaceId, userId: workflowTable.userId })
80-
.from(workflowTable)
81-
.where(eq(workflowTable.id, workflowId))
82-
.limit(1)
77+
// Run preprocessing checks (billing, rate limits, usage limits)
78+
const preprocessResult = await preprocessExecution({
79+
workflowId,
80+
userId,
81+
triggerType: 'manual',
82+
executionId,
83+
requestId,
84+
checkRateLimit: false, // Manual executions don't rate limit
85+
checkDeployment: false, // Run-from-block doesn't require deployment
86+
})
87+
88+
if (!preprocessResult.success) {
89+
const { error } = preprocessResult
90+
logger.warn(`[${requestId}] Preprocessing failed for run-from-block`, {
91+
workflowId,
92+
error: error?.message,
93+
statusCode: error?.statusCode,
94+
})
95+
return NextResponse.json(
96+
{ error: error?.message || 'Execution blocked' },
97+
{ status: error?.statusCode || 500 }
98+
)
99+
}
83100

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

97116
const loggingSession = new LoggingSession(workflowId, executionId, 'manual', requestId)

apps/sim/lib/billing/authorization.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,37 @@
11
import { db } from '@sim/db'
22
import * as schema from '@sim/db/schema'
3+
import { createLogger } from '@sim/logger'
34
import { and, eq } from 'drizzle-orm'
5+
import { hasActiveSubscription } from '@/lib/billing'
6+
7+
const logger = createLogger('BillingAuthorization')
48

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

26+
// For organizations: check for existing active subscriptions to prevent duplicates
27+
if (await hasActiveSubscription(referenceId)) {
28+
logger.warn('Blocking checkout - active subscription already exists for organization', {
29+
userId,
30+
referenceId,
31+
})
32+
return false
33+
}
34+
1835
// Check if referenceId is an organizationId the user has admin rights to
1936
const members = await db
2037
.select()

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

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,11 @@ export function useSubscriptionUpgrade() {
2525
}
2626

2727
let currentSubscriptionId: string | undefined
28+
let allSubscriptions: any[] = []
2829
try {
2930
const listResult = await client.subscription.list()
30-
const activePersonalSub = listResult.data?.find(
31+
allSubscriptions = listResult.data || []
32+
const activePersonalSub = allSubscriptions.find(
3133
(sub: any) => sub.status === 'active' && sub.referenceId === userId
3234
)
3335
currentSubscriptionId = activePersonalSub?.id
@@ -50,6 +52,25 @@ export function useSubscriptionUpgrade() {
5052
)
5153

5254
if (existingOrg) {
55+
// Check if this org already has an active team subscription
56+
const existingTeamSub = allSubscriptions.find(
57+
(sub: any) =>
58+
sub.status === 'active' &&
59+
sub.referenceId === existingOrg.id &&
60+
(sub.plan === 'team' || sub.plan === 'enterprise')
61+
)
62+
63+
if (existingTeamSub) {
64+
logger.warn('Organization already has an active team subscription', {
65+
userId,
66+
organizationId: existingOrg.id,
67+
existingSubscriptionId: existingTeamSub.id,
68+
})
69+
throw new Error(
70+
'This organization already has an active team subscription. Please manage it from the billing settings.'
71+
)
72+
}
73+
5374
logger.info('Using existing organization for team plan upgrade', {
5475
userId,
5576
organizationId: existingOrg.id,

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

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
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 { createLogger } from '@sim/logger'
44
import { and, eq, inArray } from 'drizzle-orm'
55
import { checkEnterprisePlan, checkProPlan, checkTeamPlan } from '@/lib/billing/subscriptions/utils'
@@ -26,10 +26,22 @@ export async function getHighestPrioritySubscription(userId: string) {
2626

2727
let orgSubs: typeof personalSubs = []
2828
if (orgIds.length > 0) {
29-
orgSubs = await db
30-
.select()
31-
.from(subscription)
32-
.where(and(inArray(subscription.referenceId, orgIds), eq(subscription.status, 'active')))
29+
// Verify orgs exist to filter out orphaned subscriptions
30+
const existingOrgs = await db
31+
.select({ id: organization.id })
32+
.from(organization)
33+
.where(inArray(organization.id, orgIds))
34+
35+
const validOrgIds = existingOrgs.map((o) => o.id)
36+
37+
if (validOrgIds.length > 0) {
38+
orgSubs = await db
39+
.select()
40+
.from(subscription)
41+
.where(
42+
and(inArray(subscription.referenceId, validOrgIds), eq(subscription.status, 'active'))
43+
)
44+
}
3345
}
3446

3547
const allSubs = [...personalSubs, ...orgSubs]

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,28 @@ const logger = createLogger('SubscriptionCore')
2525

2626
export { getHighestPrioritySubscription }
2727

28+
/**
29+
* Check if a referenceId (user ID or org ID) has an active subscription
30+
* Used for duplicate subscription prevention
31+
*
32+
* Fails closed: returns true on error to prevent duplicate creation
33+
*/
34+
export async function hasActiveSubscription(referenceId: string): Promise<boolean> {
35+
try {
36+
const [activeSub] = await db
37+
.select({ id: subscription.id })
38+
.from(subscription)
39+
.where(and(eq(subscription.referenceId, referenceId), eq(subscription.status, 'active')))
40+
.limit(1)
41+
42+
return !!activeSub
43+
} catch (error) {
44+
logger.error('Error checking active subscription', { error, referenceId })
45+
// Fail closed: assume subscription exists to prevent duplicate creation
46+
return true
47+
}
48+
}
49+
2850
/**
2951
* Check if user is on Pro plan (direct or via organization)
3052
*/

apps/sim/lib/billing/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export {
1111
getHighestPrioritySubscription as getActiveSubscription,
1212
getUserSubscriptionState as getSubscriptionState,
1313
hasAccessControlAccess,
14+
hasActiveSubscription,
1415
hasCredentialSetsAccess,
1516
hasSSOAccess,
1617
isEnterpriseOrgAdminOrOwner,
@@ -32,6 +33,11 @@ export {
3233
} from '@/lib/billing/core/usage'
3334
export * from '@/lib/billing/credits/balance'
3435
export * from '@/lib/billing/credits/purchase'
36+
export {
37+
blockOrgMembers,
38+
getOrgMemberIds,
39+
unblockOrgMembers,
40+
} from '@/lib/billing/organizations/membership'
3541
export * from '@/lib/billing/subscriptions/utils'
3642
export { canEditUsageLimit as canEditLimit } from '@/lib/billing/subscriptions/utils'
3743
export * from '@/lib/billing/types'

apps/sim/lib/billing/organization.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
} from '@sim/db/schema'
99
import { createLogger } from '@sim/logger'
1010
import { and, eq } from 'drizzle-orm'
11+
import { hasActiveSubscription } from '@/lib/billing'
1112
import { getPlanPricing } from '@/lib/billing/core/billing'
1213
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
1314

@@ -159,6 +160,16 @@ export async function ensureOrganizationForTeamSubscription(
159160
if (existingMembership.length > 0) {
160161
const membership = existingMembership[0]
161162
if (membership.role === 'owner' || membership.role === 'admin') {
163+
// Check if org already has an active subscription (prevent duplicates)
164+
if (await hasActiveSubscription(membership.organizationId)) {
165+
logger.error('Organization already has an active subscription', {
166+
userId,
167+
organizationId: membership.organizationId,
168+
newSubscriptionId: subscription.id,
169+
})
170+
throw new Error('Organization already has an active subscription')
171+
}
172+
162173
logger.info('User already owns/admins an org, using it', {
163174
userId,
164175
organizationId: membership.organizationId,

0 commit comments

Comments
 (0)