Skip to content

Commit 8eaa83f

Browse files
fix(billing): reset usage on transition from free -> paid plan (#1397)
* fix(billing): reset usage on transition from free -> paid plan * fixes: pro->team upgrade logic, single org server side check on invite routes * ui improvements * cleanup team-members code * minor renaming * progress * fix pro->team upgrade to prevent double billing * add subscription delete case handler --------- Co-authored-by: Vikhyath Mondreti <[email protected]>
1 parent aa01e7e commit 8eaa83f

File tree

20 files changed

+7885
-151
lines changed

20 files changed

+7885
-151
lines changed

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

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,16 @@ import {
55
member,
66
organization,
77
permissions,
8+
subscription as subscriptionTable,
89
user,
10+
userStats,
911
type WorkspaceInvitationStatus,
1012
workspaceInvitation,
1113
} from '@sim/db/schema'
1214
import { and, eq } from 'drizzle-orm'
1315
import { type NextRequest, NextResponse } from 'next/server'
1416
import { getSession } from '@/lib/auth'
17+
import { requireStripeClient } from '@/lib/billing/stripe-client'
1518
import { createLogger } from '@/lib/logs/console/logger'
1619

1720
const logger = createLogger('OrganizationInvitation')
@@ -64,6 +67,16 @@ export async function PUT(
6467
{ params }: { params: Promise<{ id: string; invitationId: string }> }
6568
) {
6669
const { id: organizationId, invitationId } = await params
70+
71+
logger.info(
72+
'[PUT /api/organizations/[id]/invitations/[invitationId]] Invitation acceptance request',
73+
{
74+
organizationId,
75+
invitationId,
76+
path: req.url,
77+
}
78+
)
79+
6780
const session = await getSession()
6881

6982
if (!session?.user?.id) {
@@ -130,6 +143,48 @@ export async function PUT(
130143
}
131144
}
132145

146+
// Enforce: user can only be part of a single organization
147+
if (status === 'accepted') {
148+
// Check if user is already a member of ANY organization
149+
const existingOrgMemberships = await db
150+
.select({ organizationId: member.organizationId })
151+
.from(member)
152+
.where(eq(member.userId, session.user.id))
153+
154+
if (existingOrgMemberships.length > 0) {
155+
// Check if already a member of THIS specific organization
156+
const alreadyMemberOfThisOrg = existingOrgMemberships.some(
157+
(m) => m.organizationId === organizationId
158+
)
159+
160+
if (alreadyMemberOfThisOrg) {
161+
return NextResponse.json(
162+
{ error: 'You are already a member of this organization' },
163+
{ status: 400 }
164+
)
165+
}
166+
167+
// Member of a different organization
168+
// Mark the invitation as rejected since they can't accept it
169+
await db
170+
.update(invitation)
171+
.set({
172+
status: 'rejected',
173+
})
174+
.where(eq(invitation.id, invitationId))
175+
176+
return NextResponse.json(
177+
{
178+
error:
179+
'You are already a member of an organization. Leave your current organization before accepting a new invitation.',
180+
},
181+
{ status: 409 }
182+
)
183+
}
184+
}
185+
186+
let personalProToCancel: any = null
187+
133188
await db.transaction(async (tx) => {
134189
await tx.update(invitation).set({ status }).where(eq(invitation.id, invitationId))
135190

@@ -142,6 +197,83 @@ export async function PUT(
142197
createdAt: new Date(),
143198
})
144199

200+
// Snapshot Pro usage and cancel Pro subscription when joining a paid team
201+
try {
202+
const orgSubs = await tx
203+
.select()
204+
.from(subscriptionTable)
205+
.where(
206+
and(
207+
eq(subscriptionTable.referenceId, organizationId),
208+
eq(subscriptionTable.status, 'active')
209+
)
210+
)
211+
.limit(1)
212+
213+
const orgSub = orgSubs[0]
214+
const orgIsPaid = orgSub && (orgSub.plan === 'team' || orgSub.plan === 'enterprise')
215+
216+
if (orgIsPaid) {
217+
const userId = session.user.id
218+
219+
// Find user's active personal Pro subscription
220+
const personalSubs = await tx
221+
.select()
222+
.from(subscriptionTable)
223+
.where(
224+
and(
225+
eq(subscriptionTable.referenceId, userId),
226+
eq(subscriptionTable.status, 'active'),
227+
eq(subscriptionTable.plan, 'pro')
228+
)
229+
)
230+
.limit(1)
231+
232+
const personalPro = personalSubs[0]
233+
if (personalPro) {
234+
// Snapshot the current Pro usage before resetting
235+
const userStatsRows = await tx
236+
.select({
237+
currentPeriodCost: userStats.currentPeriodCost,
238+
})
239+
.from(userStats)
240+
.where(eq(userStats.userId, userId))
241+
.limit(1)
242+
243+
if (userStatsRows.length > 0) {
244+
const currentProUsage = userStatsRows[0].currentPeriodCost || '0'
245+
246+
// Snapshot Pro usage and reset currentPeriodCost so new usage goes to team
247+
await tx
248+
.update(userStats)
249+
.set({
250+
proPeriodCostSnapshot: currentProUsage,
251+
currentPeriodCost: '0', // Reset so new usage is attributed to team
252+
})
253+
.where(eq(userStats.userId, userId))
254+
255+
logger.info('Snapshotted Pro usage when joining team', {
256+
userId,
257+
proUsageSnapshot: currentProUsage,
258+
organizationId,
259+
})
260+
}
261+
262+
// Mark for cancellation after transaction
263+
if (personalPro.cancelAtPeriodEnd !== true) {
264+
personalProToCancel = personalPro
265+
}
266+
}
267+
}
268+
} catch (error) {
269+
logger.error('Failed to handle Pro user joining team', {
270+
userId: session.user.id,
271+
organizationId,
272+
error,
273+
})
274+
// Don't fail the whole invitation acceptance due to this
275+
}
276+
145277
const linkedWorkspaceInvitations = await tx
146278
.select()
147279
.from(workspaceInvitation)
@@ -179,6 +311,44 @@ export async function PUT(
179311
}
180312
})
181313

314+
// Handle Pro subscription cancellation after transaction commits
315+
if (personalProToCancel) {
316+
try {
317+
const stripe = requireStripeClient()
318+
if (personalProToCancel.stripeSubscriptionId) {
319+
try {
320+
await stripe.subscriptions.update(personalProToCancel.stripeSubscriptionId, {
321+
cancel_at_period_end: true,
322+
})
323+
} catch (stripeError) {
324+
logger.error('Failed to set cancel_at_period_end on Stripe for personal Pro', {
325+
userId: session.user.id,
326+
subscriptionId: personalProToCancel.id,
327+
stripeSubscriptionId: personalProToCancel.stripeSubscriptionId,
328+
error: stripeError,
329+
})
330+
}
331+
}
332+
333+
await db
334+
.update(subscriptionTable)
335+
.set({ cancelAtPeriodEnd: true })
336+
.where(eq(subscriptionTable.id, personalProToCancel.id))
337+
338+
logger.info('Auto-cancelled personal Pro at period end after joining paid team', {
339+
userId: session.user.id,
340+
personalSubscriptionId: personalProToCancel.id,
341+
organizationId,
342+
})
343+
} catch (dbError) {
344+
logger.error('Failed to update DB cancelAtPeriodEnd for personal Pro', {
345+
userId: session.user.id,
346+
subscriptionId: personalProToCancel.id,
347+
error: dbError,
348+
})
349+
}
350+
}
351+
182352
logger.info(`Organization invitation ${status}`, {
183353
organizationId,
184354
invitationId,

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
workspace,
1010
workspaceInvitation,
1111
} from '@sim/db/schema'
12-
import { and, eq, inArray, isNull } from 'drizzle-orm'
12+
import { and, eq, inArray, isNull, or } from 'drizzle-orm'
1313
import { type NextRequest, NextResponse } from 'next/server'
1414
import {
1515
getEmailSubject,
@@ -463,7 +463,10 @@ export async function DELETE(
463463
and(
464464
eq(invitation.id, invitationId),
465465
eq(invitation.organizationId, organizationId),
466-
eq(invitation.status, 'pending')
466+
or(
467+
eq(invitation.status, 'pending'),
468+
eq(invitation.status, 'rejected') // Allow cancelling rejected invitations too
469+
)
467470
)
468471
)
469472
.returning()

apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { db } from '@sim/db'
2-
import { member, user, userStats } from '@sim/db/schema'
2+
import { member, subscription as subscriptionTable, user, userStats } from '@sim/db/schema'
33
import { and, eq } from 'drizzle-orm'
44
import { type NextRequest, NextResponse } from 'next/server'
55
import { getSession } from '@/lib/auth'
66
import { getUserUsageData } from '@/lib/billing/core/usage'
7+
import { requireStripeClient } from '@/lib/billing/stripe-client'
78
import { createLogger } from '@/lib/logs/console/logger'
89

910
const logger = createLogger('OrganizationMemberAPI')
@@ -304,6 +305,124 @@ export async function DELETE(
304305
wasSelfRemoval: session.user.id === memberId,
305306
})
306307

308+
// If the removed user left their last paid team and has a personal Pro set to cancel_at_period_end, restore it
309+
try {
310+
const remainingPaidTeams = await db
311+
.select({ orgId: member.organizationId })
312+
.from(member)
313+
.where(eq(member.userId, memberId))
314+
315+
let hasAnyPaidTeam = false
316+
if (remainingPaidTeams.length > 0) {
317+
const orgIds = remainingPaidTeams.map((m) => m.orgId)
318+
const orgPaidSubs = await db
319+
.select()
320+
.from(subscriptionTable)
321+
.where(and(eq(subscriptionTable.status, 'active'), eq(subscriptionTable.plan, 'team')))
322+
323+
hasAnyPaidTeam = orgPaidSubs.some((s) => orgIds.includes(s.referenceId))
324+
}
325+
326+
if (!hasAnyPaidTeam) {
327+
const personalProRows = await db
328+
.select()
329+
.from(subscriptionTable)
330+
.where(
331+
and(
332+
eq(subscriptionTable.referenceId, memberId),
333+
eq(subscriptionTable.status, 'active'),
334+
eq(subscriptionTable.plan, 'pro')
335+
)
336+
)
337+
.limit(1)
338+
339+
const personalPro = personalProRows[0]
340+
if (
341+
personalPro &&
342+
personalPro.cancelAtPeriodEnd === true &&
343+
personalPro.stripeSubscriptionId
344+
) {
345+
try {
346+
const stripe = requireStripeClient()
347+
await stripe.subscriptions.update(personalPro.stripeSubscriptionId, {
348+
cancel_at_period_end: false,
349+
})
350+
} catch (stripeError) {
351+
logger.error('Stripe restore cancel_at_period_end failed for personal Pro', {
352+
userId: memberId,
353+
stripeSubscriptionId: personalPro.stripeSubscriptionId,
354+
error: stripeError,
355+
})
356+
}
357+
358+
try {
359+
await db
360+
.update(subscriptionTable)
361+
.set({ cancelAtPeriodEnd: false })
362+
.where(eq(subscriptionTable.id, personalPro.id))
363+
364+
logger.info('Restored personal Pro after leaving last paid team', {
365+
userId: memberId,
366+
personalSubscriptionId: personalPro.id,
367+
})
368+
} catch (dbError) {
369+
logger.error('DB update failed when restoring personal Pro', {
370+
userId: memberId,
371+
subscriptionId: personalPro.id,
372+
error: dbError,
373+
})
374+
}
375+
376+
// Also restore the snapshotted Pro usage back to currentPeriodCost
377+
try {
378+
const userStatsRows = await db
379+
.select({
380+
currentPeriodCost: userStats.currentPeriodCost,
381+
proPeriodCostSnapshot: userStats.proPeriodCostSnapshot,
382+
})
383+
.from(userStats)
384+
.where(eq(userStats.userId, memberId))
385+
.limit(1)
386+
387+
if (userStatsRows.length > 0) {
388+
const currentUsage = userStatsRows[0].currentPeriodCost || '0'
389+
const snapshotUsage = userStatsRows[0].proPeriodCostSnapshot || '0'
390+
391+
const currentNum = Number.parseFloat(currentUsage)
392+
const snapshotNum = Number.parseFloat(snapshotUsage)
393+
const restoredUsage = (currentNum + snapshotNum).toString()
394+
395+
await db
396+
.update(userStats)
397+
.set({
398+
currentPeriodCost: restoredUsage,
399+
proPeriodCostSnapshot: '0', // Clear the snapshot
400+
})
401+
.where(eq(userStats.userId, memberId))
402+
403+
logger.info('Restored Pro usage after leaving team', {
404+
userId: memberId,
405+
previousUsage: currentUsage,
406+
snapshotUsage: snapshotUsage,
407+
restoredUsage: restoredUsage,
408+
})
409+
}
410+
} catch (usageRestoreError) {
411+
logger.error('Failed to restore Pro usage after leaving team', {
412+
userId: memberId,
413+
error: usageRestoreError,
414+
})
415+
}
416+
}
417+
}
418+
} catch (postRemoveError) {
419+
logger.error('Post-removal personal Pro restore check failed', {
420+
organizationId,
421+
memberId,
422+
error: postRemoveError,
423+
})
424+
}
425+
307426
return NextResponse.json({
308427
success: true,
309428
message:

0 commit comments

Comments
 (0)