Skip to content

Commit fc5f815

Browse files
fix(team-plans): track departed member usage so value not lost (#2118)
* fix(team-plans): track departed member usage so value not lost * reset usage to 0 when they leave team * prep merge with stagig * regen migrations * fix org invite + ws selection' --------- Co-authored-by: Waleed <[email protected]>
1 parent 7bf9251 commit fc5f815

File tree

10 files changed

+7784
-139
lines changed

10 files changed

+7784
-139
lines changed

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

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { db } from '@sim/db'
2-
import { member, subscription as subscriptionTable, user, userStats } from '@sim/db/schema'
3-
import { and, eq } from 'drizzle-orm'
2+
import {
3+
member,
4+
organization,
5+
subscription as subscriptionTable,
6+
user,
7+
userStats,
8+
} from '@sim/db/schema'
9+
import { and, eq, sql } from 'drizzle-orm'
410
import { type NextRequest, NextResponse } from 'next/server'
511
import { z } from 'zod'
612
import { getSession } from '@/lib/auth'
@@ -297,6 +303,44 @@ export async function DELETE(
297303
return NextResponse.json({ error: 'Cannot remove organization owner' }, { status: 400 })
298304
}
299305

306+
// Capture departed member's usage and reset their cost to prevent double billing
307+
try {
308+
const departingUserStats = await db
309+
.select({ currentPeriodCost: userStats.currentPeriodCost })
310+
.from(userStats)
311+
.where(eq(userStats.userId, memberId))
312+
.limit(1)
313+
314+
if (departingUserStats.length > 0 && departingUserStats[0].currentPeriodCost) {
315+
const usage = Number.parseFloat(departingUserStats[0].currentPeriodCost)
316+
if (usage > 0) {
317+
await db
318+
.update(organization)
319+
.set({
320+
departedMemberUsage: sql`${organization.departedMemberUsage} + ${usage}`,
321+
})
322+
.where(eq(organization.id, organizationId))
323+
324+
await db
325+
.update(userStats)
326+
.set({ currentPeriodCost: '0' })
327+
.where(eq(userStats.userId, memberId))
328+
329+
logger.info('Captured departed member usage and reset user cost', {
330+
organizationId,
331+
memberId,
332+
usage,
333+
})
334+
}
335+
}
336+
} catch (usageCaptureError) {
337+
logger.error('Failed to capture departed member usage', {
338+
organizationId,
339+
memberId,
340+
error: usageCaptureError,
341+
})
342+
}
343+
300344
// Remove member
301345
const removedMember = await db
302346
.delete(member)

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/team-management.tsx

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -123,8 +123,8 @@ export function TeamManagement() {
123123
const workspaceInvitations =
124124
selectedWorkspaces.length > 0
125125
? selectedWorkspaces.map((w) => ({
126-
id: w.workspaceId,
127-
name: adminWorkspaces.find((uw) => uw.id === w.workspaceId)?.name || '',
126+
workspaceId: w.workspaceId,
127+
permission: w.permission as 'admin' | 'write' | 'read',
128128
}))
129129
: undefined
130130

@@ -145,14 +145,7 @@ export function TeamManagement() {
145145
} catch (error) {
146146
logger.error('Failed to invite member', error)
147147
}
148-
}, [
149-
session?.user?.id,
150-
activeOrganization?.id,
151-
inviteEmail,
152-
selectedWorkspaces,
153-
adminWorkspaces,
154-
inviteMutation,
155-
])
148+
}, [session?.user?.id, activeOrganization?.id, inviteEmail, selectedWorkspaces, inviteMutation])
156149

157150
const handleWorkspaceToggle = useCallback((workspaceId: string, permission: string) => {
158151
setSelectedWorkspaces((prev) => {

apps/sim/hooks/queries/organization.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ export function useUpdateOrganizationUsageLimit() {
257257
*/
258258
interface InviteMemberParams {
259259
email: string
260-
workspaceInvitations?: Array<{ id: string; name: string }>
260+
workspaceInvitations?: Array<{ workspaceId: string; permission: 'admin' | 'write' | 'read' }>
261261
orgId: string
262262
}
263263

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

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { db } from '@sim/db'
2-
import { member, subscription, user, userStats } from '@sim/db/schema'
2+
import { member, organization, subscription, user, userStats } from '@sim/db/schema'
33
import { and, eq } from 'drizzle-orm'
44
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
55
import { getUserUsageData } from '@/lib/billing/core/usage'
@@ -120,7 +120,6 @@ export async function calculateSubscriptionOverage(sub: {
120120
let totalOverage = 0
121121

122122
if (sub.plan === 'team') {
123-
// Team plan: sum all member usage
124123
const members = await db
125124
.select({ userId: member.userId })
126125
.from(member)
@@ -132,13 +131,27 @@ export async function calculateSubscriptionOverage(sub: {
132131
totalTeamUsage += usage.currentUsage
133132
}
134133

134+
const orgData = await db
135+
.select({ departedMemberUsage: organization.departedMemberUsage })
136+
.from(organization)
137+
.where(eq(organization.id, sub.referenceId))
138+
.limit(1)
139+
140+
const departedUsage =
141+
orgData.length > 0 && orgData[0].departedMemberUsage
142+
? Number.parseFloat(orgData[0].departedMemberUsage)
143+
: 0
144+
145+
const totalUsageWithDeparted = totalTeamUsage + departedUsage
135146
const { basePrice } = getPlanPricing(sub.plan)
136147
const baseSubscriptionAmount = (sub.seats || 1) * basePrice
137-
totalOverage = Math.max(0, totalTeamUsage - baseSubscriptionAmount)
148+
totalOverage = Math.max(0, totalUsageWithDeparted - baseSubscriptionAmount)
138149

139150
logger.info('Calculated team overage', {
140151
subscriptionId: sub.id,
141-
totalTeamUsage,
152+
currentMemberUsage: totalTeamUsage,
153+
departedMemberUsage: departedUsage,
154+
totalUsage: totalUsageWithDeparted,
142155
baseSubscriptionAmount,
143156
totalOverage,
144157
})

apps/sim/lib/billing/validation/seat-management.ts

Lines changed: 0 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -239,126 +239,6 @@ export async function validateBulkInvitations(
239239
}
240240
}
241241

242-
/**
243-
* Update organization seat count in subscription
244-
*/
245-
export async function updateOrganizationSeats(
246-
organizationId: string,
247-
newSeatCount: number,
248-
updatedBy: string
249-
): Promise<{ success: boolean; error?: string }> {
250-
try {
251-
const subscriptionRecord = await getOrganizationSubscription(organizationId)
252-
253-
if (!subscriptionRecord) {
254-
return { success: false, error: 'No active subscription found' }
255-
}
256-
257-
const memberCount = await db
258-
.select({ count: count() })
259-
.from(member)
260-
.where(eq(member.organizationId, organizationId))
261-
262-
const currentMembers = memberCount[0]?.count || 0
263-
264-
if (newSeatCount < currentMembers) {
265-
return {
266-
success: false,
267-
error: `Cannot reduce seats below current member count (${currentMembers})`,
268-
}
269-
}
270-
271-
await db
272-
.update(subscription)
273-
.set({
274-
seats: newSeatCount,
275-
})
276-
.where(eq(subscription.id, subscriptionRecord.id))
277-
278-
logger.info('Organization seat count updated', {
279-
organizationId,
280-
oldSeatCount: subscriptionRecord.seats,
281-
newSeatCount,
282-
updatedBy,
283-
})
284-
285-
return { success: true }
286-
} catch (error) {
287-
logger.error('Failed to update organization seats', {
288-
organizationId,
289-
newSeatCount,
290-
updatedBy,
291-
error,
292-
})
293-
294-
return {
295-
success: false,
296-
error: error instanceof Error ? error.message : 'Unknown error',
297-
}
298-
}
299-
}
300-
301-
/**
302-
* Check if a user can be removed from an organization
303-
*/
304-
export async function validateMemberRemoval(
305-
organizationId: string,
306-
userIdToRemove: string,
307-
removedBy: string
308-
): Promise<{ canRemove: boolean; reason?: string }> {
309-
try {
310-
const memberRecord = await db
311-
.select({ role: member.role })
312-
.from(member)
313-
.where(and(eq(member.organizationId, organizationId), eq(member.userId, userIdToRemove)))
314-
.limit(1)
315-
316-
if (memberRecord.length === 0) {
317-
return { canRemove: false, reason: 'Member not found in organization' }
318-
}
319-
320-
if (memberRecord[0].role === 'owner') {
321-
return { canRemove: false, reason: 'Cannot remove organization owner' }
322-
}
323-
324-
const removerMemberRecord = await db
325-
.select({ role: member.role })
326-
.from(member)
327-
.where(and(eq(member.organizationId, organizationId), eq(member.userId, removedBy)))
328-
.limit(1)
329-
330-
if (removerMemberRecord.length === 0) {
331-
return { canRemove: false, reason: 'You are not a member of this organization' }
332-
}
333-
334-
const removerRole = removerMemberRecord[0].role
335-
const targetRole = memberRecord[0].role
336-
337-
if (removerRole === 'owner') {
338-
return userIdToRemove === removedBy
339-
? { canRemove: false, reason: 'Cannot remove yourself as owner' }
340-
: { canRemove: true }
341-
}
342-
343-
if (removerRole === 'admin') {
344-
return targetRole === 'member'
345-
? { canRemove: true }
346-
: { canRemove: false, reason: 'Insufficient permissions to remove this member' }
347-
}
348-
349-
return { canRemove: false, reason: 'Insufficient permissions' }
350-
} catch (error) {
351-
logger.error('Failed to validate member removal', {
352-
organizationId,
353-
userIdToRemove,
354-
removedBy,
355-
error,
356-
})
357-
358-
return { canRemove: false, reason: 'Validation failed' }
359-
}
360-
}
361-
362242
/**
363243
* Get seat usage analytics for an organization
364244
*/

apps/sim/lib/billing/webhooks/invoices.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { render } from '@react-email/components'
22
import { db } from '@sim/db'
3-
import { member, subscription as subscriptionTable, user, userStats } from '@sim/db/schema'
3+
import {
4+
member,
5+
organization,
6+
subscription as subscriptionTable,
7+
user,
8+
userStats,
9+
} from '@sim/db/schema'
410
import { and, eq, inArray } from 'drizzle-orm'
511
import type Stripe from 'stripe'
612
import PaymentFailedEmail from '@/components/emails/billing/payment-failed-email'
@@ -291,6 +297,11 @@ export async function resetUsageForSubscription(sub: { plan: string | null; refe
291297
.where(eq(userStats.userId, m.userId))
292298
}
293299
}
300+
301+
await db
302+
.update(organization)
303+
.set({ departedMemberUsage: '0' })
304+
.where(eq(organization.id, sub.referenceId))
294305
} else {
295306
const currentStats = await db
296307
.select({
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ALTER TABLE "organization" ADD COLUMN "departed_member_usage" numeric DEFAULT '0' NOT NULL;

0 commit comments

Comments
 (0)