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:
Vikhyath Mondreti
2025-11-29 18:27:12 -08:00
committed by GitHub
parent 7bf9251db1
commit fc5f815c7a
10 changed files with 7784 additions and 139 deletions

View File

@@ -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)

View File

@@ -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) => {

View File

@@ -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
}

View File

@@ -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,
})

View File

@@ -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
*/

View File

@@ -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({

View File

@@ -0,0 +1 @@
ALTER TABLE "organization" ADD COLUMN "departed_member_usage" numeric DEFAULT '0' NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -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
}
]
}

View File

@@ -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(),
})