mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-28 03:00:29 -04:00
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 <walif6@gmail.com>
This commit is contained in:
committed by
GitHub
parent
7bf9251db1
commit
fc5f815c7a
@@ -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'
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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'
|
||||
@@ -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({
|
||||
|
||||
1
packages/db/migrations/0114_wise_sunfire.sql
Normal file
1
packages/db/migrations/0114_wise_sunfire.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "organization" ADD COLUMN "departed_member_usage" numeric DEFAULT '0' NOT NULL;
|
||||
7695
packages/db/migrations/meta/0114_snapshot.json
Normal file
7695
packages/db/migrations/meta/0114_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -792,6 +792,13 @@
|
||||
"when": 1764370369484,
|
||||
"tag": "0113_calm_tiger_shark",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 114,
|
||||
"version": "7",
|
||||
"when": 1764468360258,
|
||||
"tag": "0114_wise_sunfire",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -745,7 +745,8 @@ export const organization = pgTable('organization', {
|
||||
logo: text('logo'),
|
||||
metadata: json('metadata'),
|
||||
orgUsageLimit: decimal('org_usage_limit'),
|
||||
storageUsedBytes: bigint('storage_used_bytes', { mode: 'number' }).notNull().default(0), // Storage tracking for team/enterprise
|
||||
storageUsedBytes: bigint('storage_used_bytes', { mode: 'number' }).notNull().default(0),
|
||||
departedMemberUsage: decimal('departed_member_usage').notNull().default('0'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user