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
48 changes: 46 additions & 2 deletions apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { db } from '@sim/db'
import { member, subscription as subscriptionTable, user, userStats } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import {
member,
organization,
subscription as subscriptionTable,
user,
userStats,
} from '@sim/db/schema'
import { and, eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
Expand Down Expand Up @@ -297,6 +303,44 @@ export async function DELETE(
return NextResponse.json({ error: 'Cannot remove organization owner' }, { status: 400 })
}

// Capture departed member's usage and reset their cost to prevent double billing
try {
const departingUserStats = await db
.select({ currentPeriodCost: userStats.currentPeriodCost })
.from(userStats)
.where(eq(userStats.userId, memberId))
.limit(1)

if (departingUserStats.length > 0 && departingUserStats[0].currentPeriodCost) {
const usage = Number.parseFloat(departingUserStats[0].currentPeriodCost)
if (usage > 0) {
await db
.update(organization)
.set({
departedMemberUsage: sql`${organization.departedMemberUsage} + ${usage}`,
})
.where(eq(organization.id, organizationId))

await db
.update(userStats)
.set({ currentPeriodCost: '0' })
.where(eq(userStats.userId, memberId))

logger.info('Captured departed member usage and reset user cost', {
organizationId,
memberId,
usage,
})
}
}
} catch (usageCaptureError) {
logger.error('Failed to capture departed member usage', {
organizationId,
memberId,
error: usageCaptureError,
})
}

// Remove member
const removedMember = await db
.delete(member)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,8 @@ export function TeamManagement() {
const workspaceInvitations =
selectedWorkspaces.length > 0
? selectedWorkspaces.map((w) => ({
id: w.workspaceId,
name: adminWorkspaces.find((uw) => uw.id === w.workspaceId)?.name || '',
workspaceId: w.workspaceId,
permission: w.permission as 'admin' | 'write' | 'read',
}))
: undefined

Expand All @@ -145,14 +145,7 @@ export function TeamManagement() {
} catch (error) {
logger.error('Failed to invite member', error)
}
}, [
session?.user?.id,
activeOrganization?.id,
inviteEmail,
selectedWorkspaces,
adminWorkspaces,
inviteMutation,
])
}, [session?.user?.id, activeOrganization?.id, inviteEmail, selectedWorkspaces, inviteMutation])

const handleWorkspaceToggle = useCallback((workspaceId: string, permission: string) => {
setSelectedWorkspaces((prev) => {
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/hooks/queries/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ export function useUpdateOrganizationUsageLimit() {
*/
interface InviteMemberParams {
email: string
workspaceInvitations?: Array<{ id: string; name: string }>
workspaceInvitations?: Array<{ workspaceId: string; permission: 'admin' | 'write' | 'read' }>
orgId: string
}

Expand Down
21 changes: 17 additions & 4 deletions apps/sim/lib/billing/core/billing.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { db } from '@sim/db'
import { member, subscription, user, userStats } from '@sim/db/schema'
import { member, organization, subscription, user, userStats } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { getUserUsageData } from '@/lib/billing/core/usage'
Expand Down Expand Up @@ -120,7 +120,6 @@ export async function calculateSubscriptionOverage(sub: {
let totalOverage = 0

if (sub.plan === 'team') {
// Team plan: sum all member usage
const members = await db
.select({ userId: member.userId })
.from(member)
Expand All @@ -132,13 +131,27 @@ export async function calculateSubscriptionOverage(sub: {
totalTeamUsage += usage.currentUsage
}

const orgData = await db
.select({ departedMemberUsage: organization.departedMemberUsage })
.from(organization)
.where(eq(organization.id, sub.referenceId))
.limit(1)

const departedUsage =
orgData.length > 0 && orgData[0].departedMemberUsage
? Number.parseFloat(orgData[0].departedMemberUsage)
: 0

const totalUsageWithDeparted = totalTeamUsage + departedUsage
const { basePrice } = getPlanPricing(sub.plan)
const baseSubscriptionAmount = (sub.seats || 1) * basePrice
totalOverage = Math.max(0, totalTeamUsage - baseSubscriptionAmount)
totalOverage = Math.max(0, totalUsageWithDeparted - baseSubscriptionAmount)

logger.info('Calculated team overage', {
subscriptionId: sub.id,
totalTeamUsage,
currentMemberUsage: totalTeamUsage,
departedMemberUsage: departedUsage,
totalUsage: totalUsageWithDeparted,
baseSubscriptionAmount,
totalOverage,
})
Expand Down
120 changes: 0 additions & 120 deletions apps/sim/lib/billing/validation/seat-management.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,126 +239,6 @@ export async function validateBulkInvitations(
}
}

/**
* Update organization seat count in subscription
*/
export async function updateOrganizationSeats(
organizationId: string,
newSeatCount: number,
updatedBy: string
): Promise<{ success: boolean; error?: string }> {
try {
const subscriptionRecord = await getOrganizationSubscription(organizationId)

if (!subscriptionRecord) {
return { success: false, error: 'No active subscription found' }
}

const memberCount = await db
.select({ count: count() })
.from(member)
.where(eq(member.organizationId, organizationId))

const currentMembers = memberCount[0]?.count || 0

if (newSeatCount < currentMembers) {
return {
success: false,
error: `Cannot reduce seats below current member count (${currentMembers})`,
}
}

await db
.update(subscription)
.set({
seats: newSeatCount,
})
.where(eq(subscription.id, subscriptionRecord.id))

logger.info('Organization seat count updated', {
organizationId,
oldSeatCount: subscriptionRecord.seats,
newSeatCount,
updatedBy,
})

return { success: true }
} catch (error) {
logger.error('Failed to update organization seats', {
organizationId,
newSeatCount,
updatedBy,
error,
})

return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
}
}
}

/**
* Check if a user can be removed from an organization
*/
export async function validateMemberRemoval(
organizationId: string,
userIdToRemove: string,
removedBy: string
): Promise<{ canRemove: boolean; reason?: string }> {
try {
const memberRecord = await db
.select({ role: member.role })
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.userId, userIdToRemove)))
.limit(1)

if (memberRecord.length === 0) {
return { canRemove: false, reason: 'Member not found in organization' }
}

if (memberRecord[0].role === 'owner') {
return { canRemove: false, reason: 'Cannot remove organization owner' }
}

const removerMemberRecord = await db
.select({ role: member.role })
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.userId, removedBy)))
.limit(1)

if (removerMemberRecord.length === 0) {
return { canRemove: false, reason: 'You are not a member of this organization' }
}

const removerRole = removerMemberRecord[0].role
const targetRole = memberRecord[0].role

if (removerRole === 'owner') {
return userIdToRemove === removedBy
? { canRemove: false, reason: 'Cannot remove yourself as owner' }
: { canRemove: true }
}

if (removerRole === 'admin') {
return targetRole === 'member'
? { canRemove: true }
: { canRemove: false, reason: 'Insufficient permissions to remove this member' }
}

return { canRemove: false, reason: 'Insufficient permissions' }
} catch (error) {
logger.error('Failed to validate member removal', {
organizationId,
userIdToRemove,
removedBy,
error,
})

return { canRemove: false, reason: 'Validation failed' }
}
}

/**
* Get seat usage analytics for an organization
*/
Expand Down
13 changes: 12 additions & 1 deletion apps/sim/lib/billing/webhooks/invoices.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { render } from '@react-email/components'
import { db } from '@sim/db'
import { member, subscription as subscriptionTable, user, userStats } from '@sim/db/schema'
import {
member,
organization,
subscription as subscriptionTable,
user,
userStats,
} from '@sim/db/schema'
import { and, eq, inArray } from 'drizzle-orm'
import type Stripe from 'stripe'
import PaymentFailedEmail from '@/components/emails/billing/payment-failed-email'
Expand Down Expand Up @@ -291,6 +297,11 @@ export async function resetUsageForSubscription(sub: { plan: string | null; refe
.where(eq(userStats.userId, m.userId))
}
}

await db
.update(organization)
.set({ departedMemberUsage: '0' })
.where(eq(organization.id, sub.referenceId))
} else {
const currentStats = await db
.select({
Expand Down
1 change: 1 addition & 0 deletions packages/db/migrations/0114_wise_sunfire.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE "organization" ADD COLUMN "departed_member_usage" numeric DEFAULT '0' NOT NULL;
Loading