Skip to content

Commit e43afc8

Browse files
fix(subscription): incomplete team subscription race condition (#2381)
1 parent 6009a73 commit e43afc8

File tree

4 files changed

+136
-104
lines changed

4 files changed

+136
-104
lines changed

apps/sim/app/api/organizations/route.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ export async function GET() {
1616
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
1717
}
1818

19-
// Get organizations where user is owner or admin
2019
const userOrganizations = await db
2120
.select({
2221
id: organization.id,
@@ -32,8 +31,15 @@ export async function GET() {
3231
)
3332
)
3433

34+
const anyMembership = await db
35+
.select({ id: member.id })
36+
.from(member)
37+
.where(eq(member.userId, session.user.id))
38+
.limit(1)
39+
3540
return NextResponse.json({
3641
organizations: userOrganizations,
42+
isMemberOfAnyOrg: anyMembership.length > 0,
3743
})
3844
} catch (error) {
3945
logger.error('Failed to fetch organizations', {

apps/sim/lib/auth/auth.ts

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ import {
2424
import { sendPlanWelcomeEmail } from '@/lib/billing'
2525
import { authorizeSubscriptionReference } from '@/lib/billing/authorization'
2626
import { handleNewUser } from '@/lib/billing/core/usage'
27-
import { syncSubscriptionUsageLimits } from '@/lib/billing/organization'
27+
import {
28+
ensureOrganizationForTeamSubscription,
29+
syncSubscriptionUsageLimits,
30+
} from '@/lib/billing/organization'
2831
import { getPlans } from '@/lib/billing/plans'
2932
import { syncSeatsFromStripeQuantity } from '@/lib/billing/validation/seat-management'
3033
import { handleChargeDispute, handleDisputeClosed } from '@/lib/billing/webhooks/disputes'
@@ -2021,11 +2024,14 @@ export const auth = betterAuth({
20212024
status: subscription.status,
20222025
})
20232026

2024-
await handleSubscriptionCreated(subscription)
2027+
const resolvedSubscription =
2028+
await ensureOrganizationForTeamSubscription(subscription)
2029+
2030+
await handleSubscriptionCreated(resolvedSubscription)
20252031

2026-
await syncSubscriptionUsageLimits(subscription)
2032+
await syncSubscriptionUsageLimits(resolvedSubscription)
20272033

2028-
await sendPlanWelcomeEmail(subscription)
2034+
await sendPlanWelcomeEmail(resolvedSubscription)
20292035
},
20302036
onSubscriptionUpdate: async ({
20312037
event,
@@ -2040,40 +2046,42 @@ export const auth = betterAuth({
20402046
plan: subscription.plan,
20412047
})
20422048

2049+
const resolvedSubscription =
2050+
await ensureOrganizationForTeamSubscription(subscription)
2051+
20432052
try {
2044-
await syncSubscriptionUsageLimits(subscription)
2053+
await syncSubscriptionUsageLimits(resolvedSubscription)
20452054
} catch (error) {
20462055
logger.error('[onSubscriptionUpdate] Failed to sync usage limits', {
2047-
subscriptionId: subscription.id,
2048-
referenceId: subscription.referenceId,
2056+
subscriptionId: resolvedSubscription.id,
2057+
referenceId: resolvedSubscription.referenceId,
20492058
error,
20502059
})
20512060
}
20522061

2053-
// Sync seat count from Stripe subscription quantity for team plans
2054-
if (subscription.plan === 'team') {
2062+
if (resolvedSubscription.plan === 'team') {
20552063
try {
20562064
const stripeSubscription = event.data.object as Stripe.Subscription
20572065
const quantity = stripeSubscription.items?.data?.[0]?.quantity || 1
20582066

20592067
const result = await syncSeatsFromStripeQuantity(
2060-
subscription.id,
2061-
subscription.seats,
2068+
resolvedSubscription.id,
2069+
resolvedSubscription.seats ?? null,
20622070
quantity
20632071
)
20642072

20652073
if (result.synced) {
20662074
logger.info('[onSubscriptionUpdate] Synced seat count from Stripe', {
2067-
subscriptionId: subscription.id,
2068-
referenceId: subscription.referenceId,
2075+
subscriptionId: resolvedSubscription.id,
2076+
referenceId: resolvedSubscription.referenceId,
20692077
previousSeats: result.previousSeats,
20702078
newSeats: result.newSeats,
20712079
})
20722080
}
20732081
} catch (error) {
20742082
logger.error('[onSubscriptionUpdate] Failed to sync seat count', {
2075-
subscriptionId: subscription.id,
2076-
referenceId: subscription.referenceId,
2083+
subscriptionId: resolvedSubscription.id,
2084+
referenceId: resolvedSubscription.referenceId,
20772085
error,
20782086
})
20792087
}

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

Lines changed: 28 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,6 @@ const CONSTANTS = {
1212
INITIAL_TEAM_SEATS: 1,
1313
} as const
1414

15-
/**
16-
* Handles organization creation for team plans and proper referenceId management
17-
*/
1815
export function useSubscriptionUpgrade() {
1916
const { data: session } = useSession()
2017
const betterAuthSubscription = useSubscription()
@@ -40,83 +37,43 @@ export function useSubscriptionUpgrade() {
4037

4138
let referenceId = userId
4239

43-
// For team plans, create organization first and use its ID as referenceId
4440
if (targetPlan === 'team') {
4541
try {
46-
// Check if user already has an organization where they are owner/admin
4742
const orgsResponse = await fetch('/api/organizations')
48-
if (orgsResponse.ok) {
49-
const orgsData = await orgsResponse.json()
50-
const existingOrg = orgsData.organizations?.find(
51-
(org: any) => org.role === 'owner' || org.role === 'admin'
52-
)
53-
54-
if (existingOrg) {
55-
logger.info('Using existing organization for team plan upgrade', {
56-
userId,
57-
organizationId: existingOrg.id,
58-
})
59-
referenceId = existingOrg.id
60-
}
43+
if (!orgsResponse.ok) {
44+
throw new Error('Failed to check organization status')
6145
}
6246

63-
// Only create new organization if no suitable one exists
64-
if (referenceId === userId) {
65-
logger.info('Creating organization for team plan upgrade', {
66-
userId,
67-
})
68-
69-
const response = await fetch('/api/organizations', {
70-
method: 'POST',
71-
headers: {
72-
'Content-Type': 'application/json',
73-
},
74-
})
75-
76-
if (!response.ok) {
77-
const errorData = await response.json().catch(() => ({}))
78-
if (response.status === 409) {
79-
throw new Error(
80-
'You are already a member of an organization. Please leave it or ask an admin to upgrade.'
81-
)
82-
}
83-
throw new Error(
84-
errorData.message || `Failed to create organization: ${response.statusText}`
85-
)
86-
}
87-
const result = await response.json()
47+
const orgsData = await orgsResponse.json()
48+
const existingOrg = orgsData.organizations?.find(
49+
(org: any) => org.role === 'owner' || org.role === 'admin'
50+
)
8851

89-
logger.info('Organization API response', {
90-
result,
91-
success: result.success,
92-
organizationId: result.organizationId,
52+
if (existingOrg) {
53+
logger.info('Using existing organization for team plan upgrade', {
54+
userId,
55+
organizationId: existingOrg.id,
9356
})
57+
referenceId = existingOrg.id
9458

95-
if (!result.success || !result.organizationId) {
96-
throw new Error('Failed to create organization for team plan')
59+
try {
60+
await client.organization.setActive({ organizationId: referenceId })
61+
logger.info('Set organization as active', { organizationId: referenceId })
62+
} catch (error) {
63+
logger.warn('Failed to set organization as active, proceeding with upgrade', {
64+
organizationId: referenceId,
65+
error: error instanceof Error ? error.message : 'Unknown error',
66+
})
9767
}
98-
99-
referenceId = result.organizationId
100-
}
101-
102-
// Set the organization as active so Better Auth recognizes it
103-
try {
104-
await client.organization.setActive({ organizationId: referenceId })
105-
106-
logger.info('Set organization as active', {
107-
organizationId: referenceId,
108-
oldReferenceId: userId,
109-
newReferenceId: referenceId,
110-
})
111-
} catch (error) {
112-
logger.warn('Failed to set organization as active, but proceeding with upgrade', {
113-
organizationId: referenceId,
114-
error: error instanceof Error ? error.message : 'Unknown error',
115-
})
116-
// Continue with upgrade even if setting active fails
68+
} else if (orgsData.isMemberOfAnyOrg) {
69+
throw new Error(
70+
'You are already a member of an organization. Please leave it or ask an admin to upgrade.'
71+
)
72+
} else {
73+
logger.info('Will create organization after payment succeeds', { userId })
11774
}
11875
} catch (error) {
119-
logger.error('Failed to prepare organization for team plan', error)
76+
logger.error('Failed to prepare for team plan upgrade', error)
12077
throw error instanceof Error
12178
? error
12279
: new Error('Failed to prepare team workspace. Please try again or contact support.')
@@ -134,23 +91,17 @@ export function useSubscriptionUpgrade() {
13491
...(targetPlan === 'team' && { seats: CONSTANTS.INITIAL_TEAM_SEATS }),
13592
} as const
13693

137-
// Add subscriptionId for existing subscriptions to ensure proper plan switching
13894
const finalParams = currentSubscriptionId
13995
? { ...upgradeParams, subscriptionId: currentSubscriptionId }
14096
: upgradeParams
14197

14298
logger.info(
14399
currentSubscriptionId ? 'Upgrading existing subscription' : 'Creating new subscription',
144-
{
145-
targetPlan,
146-
currentSubscriptionId,
147-
referenceId,
148-
}
100+
{ targetPlan, currentSubscriptionId, referenceId }
149101
)
150102

151103
await betterAuthSubscription.upgrade(finalParams)
152104

153-
// If upgrading to team plan, ensure the subscription is transferred to the organization
154105
if (targetPlan === 'team' && currentSubscriptionId && referenceId !== userId) {
155106
try {
156107
logger.info('Transferring subscription to organization after upgrade', {
@@ -174,7 +125,6 @@ export function useSubscriptionUpgrade() {
174125
organizationId: referenceId,
175126
error: text,
176127
})
177-
// We don't throw here because the upgrade itself succeeded
178128
} else {
179129
logger.info('Successfully transferred subscription to organization', {
180130
subscriptionId: currentSubscriptionId,
@@ -186,21 +136,16 @@ export function useSubscriptionUpgrade() {
186136
}
187137
}
188138

189-
// For team plans, refresh organization data to ensure UI updates
190139
if (targetPlan === 'team') {
191140
try {
192141
await queryClient.invalidateQueries({ queryKey: organizationKeys.lists() })
193142
logger.info('Refreshed organization data after team upgrade')
194143
} catch (error) {
195144
logger.warn('Failed to refresh organization data after upgrade', error)
196-
// Don't fail the entire upgrade if data refresh fails
197145
}
198146
}
199147

200-
logger.info('Subscription upgrade completed successfully', {
201-
targetPlan,
202-
referenceId,
203-
})
148+
logger.info('Subscription upgrade completed successfully', { targetPlan, referenceId })
204149
} catch (error) {
205150
logger.error('Failed to initiate subscription upgrade:', error)
206151

apps/sim/lib/billing/organization.ts

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,23 +76,18 @@ async function createOrganizationWithOwner(
7676
return newOrg.id
7777
}
7878

79-
/**
80-
* Create organization for team/enterprise plan upgrade
81-
*/
8279
export async function createOrganizationForTeamPlan(
8380
userId: string,
8481
userName?: string,
8582
userEmail?: string,
8683
organizationSlug?: string
8784
): Promise<string> {
8885
try {
89-
// Check if user already owns an organization
9086
const existingOrgId = await getUserOwnedOrganization(userId)
9187
if (existingOrgId) {
9288
return existingOrgId
9389
}
9490

95-
// Create new organization (same naming for both team and enterprise)
9691
const organizationName = userName || `${userEmail || 'User'}'s Team`
9792
const slug = organizationSlug || `${userId}-team-${Date.now()}`
9893

@@ -117,6 +112,84 @@ export async function createOrganizationForTeamPlan(
117112
}
118113
}
119114

115+
export async function ensureOrganizationForTeamSubscription(
116+
subscription: SubscriptionData
117+
): Promise<SubscriptionData> {
118+
if (subscription.plan !== 'team') {
119+
return subscription
120+
}
121+
122+
if (subscription.referenceId.startsWith('org_')) {
123+
return subscription
124+
}
125+
126+
const userId = subscription.referenceId
127+
128+
logger.info('Creating organization for team subscription', {
129+
subscriptionId: subscription.id,
130+
userId,
131+
})
132+
133+
const existingMembership = await db
134+
.select({
135+
id: schema.member.id,
136+
organizationId: schema.member.organizationId,
137+
role: schema.member.role,
138+
})
139+
.from(schema.member)
140+
.where(eq(schema.member.userId, userId))
141+
.limit(1)
142+
143+
if (existingMembership.length > 0) {
144+
const membership = existingMembership[0]
145+
if (membership.role === 'owner' || membership.role === 'admin') {
146+
logger.info('User already owns/admins an org, using it', {
147+
userId,
148+
organizationId: membership.organizationId,
149+
})
150+
151+
await db
152+
.update(schema.subscription)
153+
.set({ referenceId: membership.organizationId })
154+
.where(eq(schema.subscription.id, subscription.id))
155+
156+
return { ...subscription, referenceId: membership.organizationId }
157+
}
158+
159+
logger.error('User is member of org but not owner/admin - cannot create team subscription', {
160+
userId,
161+
existingOrgId: membership.organizationId,
162+
subscriptionId: subscription.id,
163+
})
164+
throw new Error('User is already member of another organization')
165+
}
166+
167+
const [userData] = await db
168+
.select({ name: schema.user.name, email: schema.user.email })
169+
.from(schema.user)
170+
.where(eq(schema.user.id, userId))
171+
.limit(1)
172+
173+
const orgId = await createOrganizationForTeamPlan(
174+
userId,
175+
userData?.name || undefined,
176+
userData?.email || undefined
177+
)
178+
179+
await db
180+
.update(schema.subscription)
181+
.set({ referenceId: orgId })
182+
.where(eq(schema.subscription.id, subscription.id))
183+
184+
logger.info('Created organization and updated subscription referenceId', {
185+
subscriptionId: subscription.id,
186+
userId,
187+
organizationId: orgId,
188+
})
189+
190+
return { ...subscription, referenceId: orgId }
191+
}
192+
120193
/**
121194
* Sync usage limits for subscription members
122195
* Updates usage limits for all users associated with the subscription

0 commit comments

Comments
 (0)