mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
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 <vikhyath@simstudio.ai>
This commit is contained in:
@@ -5,13 +5,16 @@ import {
|
||||
member,
|
||||
organization,
|
||||
permissions,
|
||||
subscription as subscriptionTable,
|
||||
user,
|
||||
userStats,
|
||||
type WorkspaceInvitationStatus,
|
||||
workspaceInvitation,
|
||||
} from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('OrganizationInvitation')
|
||||
@@ -64,6 +67,16 @@ export async function PUT(
|
||||
{ params }: { params: Promise<{ id: string; invitationId: string }> }
|
||||
) {
|
||||
const { id: organizationId, invitationId } = await params
|
||||
|
||||
logger.info(
|
||||
'[PUT /api/organizations/[id]/invitations/[invitationId]] Invitation acceptance request',
|
||||
{
|
||||
organizationId,
|
||||
invitationId,
|
||||
path: req.url,
|
||||
}
|
||||
)
|
||||
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
@@ -130,6 +143,48 @@ export async function PUT(
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce: user can only be part of a single organization
|
||||
if (status === 'accepted') {
|
||||
// Check if user is already a member of ANY organization
|
||||
const existingOrgMemberships = await db
|
||||
.select({ organizationId: member.organizationId })
|
||||
.from(member)
|
||||
.where(eq(member.userId, session.user.id))
|
||||
|
||||
if (existingOrgMemberships.length > 0) {
|
||||
// Check if already a member of THIS specific organization
|
||||
const alreadyMemberOfThisOrg = existingOrgMemberships.some(
|
||||
(m) => m.organizationId === organizationId
|
||||
)
|
||||
|
||||
if (alreadyMemberOfThisOrg) {
|
||||
return NextResponse.json(
|
||||
{ error: 'You are already a member of this organization' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Member of a different organization
|
||||
// Mark the invitation as rejected since they can't accept it
|
||||
await db
|
||||
.update(invitation)
|
||||
.set({
|
||||
status: 'rejected',
|
||||
})
|
||||
.where(eq(invitation.id, invitationId))
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'You are already a member of an organization. Leave your current organization before accepting a new invitation.',
|
||||
},
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let personalProToCancel: any = null
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.update(invitation).set({ status }).where(eq(invitation.id, invitationId))
|
||||
|
||||
@@ -142,6 +197,83 @@ export async function PUT(
|
||||
createdAt: new Date(),
|
||||
})
|
||||
|
||||
// Snapshot Pro usage and cancel Pro subscription when joining a paid team
|
||||
try {
|
||||
const orgSubs = await tx
|
||||
.select()
|
||||
.from(subscriptionTable)
|
||||
.where(
|
||||
and(
|
||||
eq(subscriptionTable.referenceId, organizationId),
|
||||
eq(subscriptionTable.status, 'active')
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
const orgSub = orgSubs[0]
|
||||
const orgIsPaid = orgSub && (orgSub.plan === 'team' || orgSub.plan === 'enterprise')
|
||||
|
||||
if (orgIsPaid) {
|
||||
const userId = session.user.id
|
||||
|
||||
// Find user's active personal Pro subscription
|
||||
const personalSubs = await tx
|
||||
.select()
|
||||
.from(subscriptionTable)
|
||||
.where(
|
||||
and(
|
||||
eq(subscriptionTable.referenceId, userId),
|
||||
eq(subscriptionTable.status, 'active'),
|
||||
eq(subscriptionTable.plan, 'pro')
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
const personalPro = personalSubs[0]
|
||||
if (personalPro) {
|
||||
// Snapshot the current Pro usage before resetting
|
||||
const userStatsRows = await tx
|
||||
.select({
|
||||
currentPeriodCost: userStats.currentPeriodCost,
|
||||
})
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
if (userStatsRows.length > 0) {
|
||||
const currentProUsage = userStatsRows[0].currentPeriodCost || '0'
|
||||
|
||||
// Snapshot Pro usage and reset currentPeriodCost so new usage goes to team
|
||||
await tx
|
||||
.update(userStats)
|
||||
.set({
|
||||
proPeriodCostSnapshot: currentProUsage,
|
||||
currentPeriodCost: '0', // Reset so new usage is attributed to team
|
||||
})
|
||||
.where(eq(userStats.userId, userId))
|
||||
|
||||
logger.info('Snapshotted Pro usage when joining team', {
|
||||
userId,
|
||||
proUsageSnapshot: currentProUsage,
|
||||
organizationId,
|
||||
})
|
||||
}
|
||||
|
||||
// Mark for cancellation after transaction
|
||||
if (personalPro.cancelAtPeriodEnd !== true) {
|
||||
personalProToCancel = personalPro
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to handle Pro user joining team', {
|
||||
userId: session.user.id,
|
||||
organizationId,
|
||||
error,
|
||||
})
|
||||
// Don't fail the whole invitation acceptance due to this
|
||||
}
|
||||
|
||||
const linkedWorkspaceInvitations = await tx
|
||||
.select()
|
||||
.from(workspaceInvitation)
|
||||
@@ -179,6 +311,44 @@ export async function PUT(
|
||||
}
|
||||
})
|
||||
|
||||
// Handle Pro subscription cancellation after transaction commits
|
||||
if (personalProToCancel) {
|
||||
try {
|
||||
const stripe = requireStripeClient()
|
||||
if (personalProToCancel.stripeSubscriptionId) {
|
||||
try {
|
||||
await stripe.subscriptions.update(personalProToCancel.stripeSubscriptionId, {
|
||||
cancel_at_period_end: true,
|
||||
})
|
||||
} catch (stripeError) {
|
||||
logger.error('Failed to set cancel_at_period_end on Stripe for personal Pro', {
|
||||
userId: session.user.id,
|
||||
subscriptionId: personalProToCancel.id,
|
||||
stripeSubscriptionId: personalProToCancel.stripeSubscriptionId,
|
||||
error: stripeError,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await db
|
||||
.update(subscriptionTable)
|
||||
.set({ cancelAtPeriodEnd: true })
|
||||
.where(eq(subscriptionTable.id, personalProToCancel.id))
|
||||
|
||||
logger.info('Auto-cancelled personal Pro at period end after joining paid team', {
|
||||
userId: session.user.id,
|
||||
personalSubscriptionId: personalProToCancel.id,
|
||||
organizationId,
|
||||
})
|
||||
} catch (dbError) {
|
||||
logger.error('Failed to update DB cancelAtPeriodEnd for personal Pro', {
|
||||
userId: session.user.id,
|
||||
subscriptionId: personalProToCancel.id,
|
||||
error: dbError,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Organization invitation ${status}`, {
|
||||
organizationId,
|
||||
invitationId,
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
workspace,
|
||||
workspaceInvitation,
|
||||
} from '@sim/db/schema'
|
||||
import { and, eq, inArray, isNull } from 'drizzle-orm'
|
||||
import { and, eq, inArray, isNull, or } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
getEmailSubject,
|
||||
@@ -463,7 +463,10 @@ export async function DELETE(
|
||||
and(
|
||||
eq(invitation.id, invitationId),
|
||||
eq(invitation.organizationId, organizationId),
|
||||
eq(invitation.status, 'pending')
|
||||
or(
|
||||
eq(invitation.status, 'pending'),
|
||||
eq(invitation.status, 'rejected') // Allow cancelling rejected invitations too
|
||||
)
|
||||
)
|
||||
)
|
||||
.returning()
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { db } from '@sim/db'
|
||||
import { member, user, userStats } from '@sim/db/schema'
|
||||
import { member, subscription as subscriptionTable, user, userStats } from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getUserUsageData } from '@/lib/billing/core/usage'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('OrganizationMemberAPI')
|
||||
@@ -304,6 +305,124 @@ export async function DELETE(
|
||||
wasSelfRemoval: session.user.id === memberId,
|
||||
})
|
||||
|
||||
// If the removed user left their last paid team and has a personal Pro set to cancel_at_period_end, restore it
|
||||
try {
|
||||
const remainingPaidTeams = await db
|
||||
.select({ orgId: member.organizationId })
|
||||
.from(member)
|
||||
.where(eq(member.userId, memberId))
|
||||
|
||||
let hasAnyPaidTeam = false
|
||||
if (remainingPaidTeams.length > 0) {
|
||||
const orgIds = remainingPaidTeams.map((m) => m.orgId)
|
||||
const orgPaidSubs = await db
|
||||
.select()
|
||||
.from(subscriptionTable)
|
||||
.where(and(eq(subscriptionTable.status, 'active'), eq(subscriptionTable.plan, 'team')))
|
||||
|
||||
hasAnyPaidTeam = orgPaidSubs.some((s) => orgIds.includes(s.referenceId))
|
||||
}
|
||||
|
||||
if (!hasAnyPaidTeam) {
|
||||
const personalProRows = await db
|
||||
.select()
|
||||
.from(subscriptionTable)
|
||||
.where(
|
||||
and(
|
||||
eq(subscriptionTable.referenceId, memberId),
|
||||
eq(subscriptionTable.status, 'active'),
|
||||
eq(subscriptionTable.plan, 'pro')
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
const personalPro = personalProRows[0]
|
||||
if (
|
||||
personalPro &&
|
||||
personalPro.cancelAtPeriodEnd === true &&
|
||||
personalPro.stripeSubscriptionId
|
||||
) {
|
||||
try {
|
||||
const stripe = requireStripeClient()
|
||||
await stripe.subscriptions.update(personalPro.stripeSubscriptionId, {
|
||||
cancel_at_period_end: false,
|
||||
})
|
||||
} catch (stripeError) {
|
||||
logger.error('Stripe restore cancel_at_period_end failed for personal Pro', {
|
||||
userId: memberId,
|
||||
stripeSubscriptionId: personalPro.stripeSubscriptionId,
|
||||
error: stripeError,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await db
|
||||
.update(subscriptionTable)
|
||||
.set({ cancelAtPeriodEnd: false })
|
||||
.where(eq(subscriptionTable.id, personalPro.id))
|
||||
|
||||
logger.info('Restored personal Pro after leaving last paid team', {
|
||||
userId: memberId,
|
||||
personalSubscriptionId: personalPro.id,
|
||||
})
|
||||
} catch (dbError) {
|
||||
logger.error('DB update failed when restoring personal Pro', {
|
||||
userId: memberId,
|
||||
subscriptionId: personalPro.id,
|
||||
error: dbError,
|
||||
})
|
||||
}
|
||||
|
||||
// Also restore the snapshotted Pro usage back to currentPeriodCost
|
||||
try {
|
||||
const userStatsRows = await db
|
||||
.select({
|
||||
currentPeriodCost: userStats.currentPeriodCost,
|
||||
proPeriodCostSnapshot: userStats.proPeriodCostSnapshot,
|
||||
})
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, memberId))
|
||||
.limit(1)
|
||||
|
||||
if (userStatsRows.length > 0) {
|
||||
const currentUsage = userStatsRows[0].currentPeriodCost || '0'
|
||||
const snapshotUsage = userStatsRows[0].proPeriodCostSnapshot || '0'
|
||||
|
||||
const currentNum = Number.parseFloat(currentUsage)
|
||||
const snapshotNum = Number.parseFloat(snapshotUsage)
|
||||
const restoredUsage = (currentNum + snapshotNum).toString()
|
||||
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({
|
||||
currentPeriodCost: restoredUsage,
|
||||
proPeriodCostSnapshot: '0', // Clear the snapshot
|
||||
})
|
||||
.where(eq(userStats.userId, memberId))
|
||||
|
||||
logger.info('Restored Pro usage after leaving team', {
|
||||
userId: memberId,
|
||||
previousUsage: currentUsage,
|
||||
snapshotUsage: snapshotUsage,
|
||||
restoredUsage: restoredUsage,
|
||||
})
|
||||
}
|
||||
} catch (usageRestoreError) {
|
||||
logger.error('Failed to restore Pro usage after leaving team', {
|
||||
userId: memberId,
|
||||
error: usageRestoreError,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (postRemoveError) {
|
||||
logger.error('Post-removal personal Pro restore check failed', {
|
||||
organizationId,
|
||||
memberId,
|
||||
error: postRemoveError,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message:
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { db } from '@sim/db'
|
||||
import { member } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createOrganizationForTeamPlan } from '@/lib/billing/organization'
|
||||
@@ -39,6 +42,23 @@ export async function POST(request: Request) {
|
||||
organizationSlug,
|
||||
})
|
||||
|
||||
// Enforce: a user can only belong to one organization at a time
|
||||
const existingOrgMembership = await db
|
||||
.select({ id: member.id })
|
||||
.from(member)
|
||||
.where(eq(member.userId, user.id))
|
||||
.limit(1)
|
||||
|
||||
if (existingOrgMembership.length > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'You are already a member of an organization. Leave your current organization before creating a new one.',
|
||||
},
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
// Create organization and make user the owner/admin
|
||||
const organizationId = await createOrganizationForTeamPlan(
|
||||
user.id,
|
||||
|
||||
@@ -23,6 +23,7 @@ export default function Invite() {
|
||||
const [isNewUser, setIsNewUser] = useState(false)
|
||||
const [token, setToken] = useState<string | null>(null)
|
||||
const [invitationType, setInvitationType] = useState<'organization' | 'workspace'>('workspace')
|
||||
const [currentOrgName, setCurrentOrgName] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const errorReason = searchParams.get('error')
|
||||
@@ -75,6 +76,20 @@ export default function Invite() {
|
||||
|
||||
if (data) {
|
||||
setInvitationType('organization')
|
||||
|
||||
// Check if user is already in an organization BEFORE showing the invitation
|
||||
const activeOrgResponse = await client.organization
|
||||
.getFullOrganization()
|
||||
.catch(() => ({ data: null }))
|
||||
|
||||
if (activeOrgResponse?.data) {
|
||||
// User is already in an organization
|
||||
setCurrentOrgName(activeOrgResponse.data.name)
|
||||
setError('already-in-organization')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setInvitationDetails({
|
||||
type: 'organization',
|
||||
data,
|
||||
@@ -119,19 +134,33 @@ export default function Invite() {
|
||||
window.location.href = `/api/workspaces/invitations/${encodeURIComponent(inviteId)}?token=${encodeURIComponent(token || '')}`
|
||||
} else {
|
||||
try {
|
||||
const response = await client.organization.acceptInvitation({
|
||||
invitationId: inviteId,
|
||||
// Get the organizationId from invitation details
|
||||
const orgId = invitationDetails?.data?.organizationId
|
||||
|
||||
if (!orgId) {
|
||||
throw new Error('Organization ID not found')
|
||||
}
|
||||
|
||||
// Use our custom API endpoint that handles Pro usage snapshot
|
||||
const response = await fetch(`/api/organizations/${orgId}/invitations/${inviteId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ status: 'accepted' }),
|
||||
})
|
||||
|
||||
const orgId =
|
||||
response.data?.invitation.organizationId || invitationDetails?.data?.organizationId
|
||||
|
||||
if (orgId) {
|
||||
await client.organization.setActive({
|
||||
organizationId: orgId,
|
||||
})
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({ error: 'Failed to accept invitation' }))
|
||||
throw new Error(data.error || 'Failed to accept invitation')
|
||||
}
|
||||
|
||||
// Set the organization as active
|
||||
await client.organization.setActive({
|
||||
organizationId: orgId,
|
||||
})
|
||||
|
||||
setAccepted(true)
|
||||
|
||||
setTimeout(() => {
|
||||
@@ -139,8 +168,17 @@ export default function Invite() {
|
||||
}, 2000)
|
||||
} catch (err: any) {
|
||||
logger.error('Error accepting invitation:', err)
|
||||
setError(err.message || 'Failed to accept invitation')
|
||||
} finally {
|
||||
|
||||
// Reset accepted state on error
|
||||
setAccepted(false)
|
||||
|
||||
// Check if it's a 409 conflict (already in an organization)
|
||||
if (err.status === 409 || err.message?.includes('already a member of an organization')) {
|
||||
setError('already-in-organization')
|
||||
} else {
|
||||
setError(err.message || 'Failed to accept invitation')
|
||||
}
|
||||
|
||||
setIsAccepting(false)
|
||||
}
|
||||
}
|
||||
@@ -213,19 +251,54 @@ export default function Invite() {
|
||||
if (error) {
|
||||
const errorReason = searchParams.get('error')
|
||||
const isExpiredError = errorReason === 'expired'
|
||||
const isAlreadyInOrg = error === 'already-in-organization'
|
||||
|
||||
// Special handling for already in organization
|
||||
if (isAlreadyInOrg) {
|
||||
return (
|
||||
<InviteLayout>
|
||||
<InviteStatusCard
|
||||
type='warning'
|
||||
title='Already Part of a Team'
|
||||
description={
|
||||
currentOrgName
|
||||
? `You are currently a member of "${currentOrgName}". You must leave your current organization before accepting a new invitation.`
|
||||
: 'You are already a member of an organization. Leave your current organization before accepting a new invitation.'
|
||||
}
|
||||
icon='users'
|
||||
actions={[
|
||||
{
|
||||
label: 'Manage Team Settings',
|
||||
onClick: () => router.push('/workspace'),
|
||||
variant: 'default' as const,
|
||||
},
|
||||
{
|
||||
label: 'Return to Home',
|
||||
onClick: () => router.push('/'),
|
||||
variant: 'ghost' as const,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</InviteLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// Use getErrorMessage for consistent error messages
|
||||
const errorMessage = error.startsWith('You are already') ? error : getErrorMessage(error)
|
||||
|
||||
return (
|
||||
<InviteLayout>
|
||||
<InviteStatusCard
|
||||
type='error'
|
||||
title='Invitation Error'
|
||||
description={error}
|
||||
description={errorMessage}
|
||||
icon='error'
|
||||
isExpiredError={isExpiredError}
|
||||
actions={[
|
||||
{
|
||||
label: 'Return to Home',
|
||||
onClick: () => router.push('/'),
|
||||
variant: 'default' as const,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -233,7 +306,8 @@ export default function Invite() {
|
||||
)
|
||||
}
|
||||
|
||||
if (accepted) {
|
||||
// Show success only if accepted AND no error
|
||||
if (accepted && !error) {
|
||||
return (
|
||||
<InviteLayout>
|
||||
<InviteStatusCard
|
||||
|
||||
@@ -16,6 +16,8 @@ export function getErrorMessage(reason: string): string {
|
||||
return 'Your user account could not be found. Please try logging out and logging back in.'
|
||||
case 'already-member':
|
||||
return 'You are already a member of this organization or workspace.'
|
||||
case 'already-in-organization':
|
||||
return 'You are already a member of an organization. Leave your current organization before accepting a new invitation.'
|
||||
case 'invalid-invitation':
|
||||
return 'This invitation is invalid or no longer exists.'
|
||||
case 'missing-invitation-id':
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { CheckCircle2, Mail, RotateCcw, ShieldX, UserPlus, Users2 } from 'lucide-react'
|
||||
import { AlertCircle, CheckCircle2, Mail, RotateCcw, ShieldX, UserPlus, Users2 } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { LoadingAgent } from '@/components/ui/loading-agent'
|
||||
@@ -10,10 +10,10 @@ import { inter } from '@/app/fonts/inter'
|
||||
import { soehne } from '@/app/fonts/soehne/soehne'
|
||||
|
||||
interface InviteStatusCardProps {
|
||||
type: 'login' | 'loading' | 'error' | 'success' | 'invitation'
|
||||
type: 'login' | 'loading' | 'error' | 'success' | 'invitation' | 'warning'
|
||||
title: string
|
||||
description: string | React.ReactNode
|
||||
icon?: 'userPlus' | 'mail' | 'users' | 'error' | 'success'
|
||||
icon?: 'userPlus' | 'mail' | 'users' | 'error' | 'success' | 'warning'
|
||||
actions?: Array<{
|
||||
label: string
|
||||
onClick: () => void
|
||||
@@ -30,6 +30,7 @@ const iconMap = {
|
||||
users: Users2,
|
||||
error: ShieldX,
|
||||
success: CheckCircle2,
|
||||
warning: AlertCircle,
|
||||
}
|
||||
|
||||
const iconColorMap = {
|
||||
@@ -38,6 +39,7 @@ const iconColorMap = {
|
||||
users: 'text-[var(--brand-primary-hex)]',
|
||||
error: 'text-red-500 dark:text-red-400',
|
||||
success: 'text-green-500 dark:text-green-400',
|
||||
warning: 'text-yellow-600 dark:text-yellow-500',
|
||||
}
|
||||
|
||||
const iconBgMap = {
|
||||
@@ -46,6 +48,7 @@ const iconBgMap = {
|
||||
users: 'bg-[var(--brand-primary-hex)]/10',
|
||||
error: 'bg-red-50 dark:bg-red-950/20',
|
||||
success: 'bg-green-50 dark:bg-green-950/20',
|
||||
warning: 'bg-yellow-50 dark:bg-yellow-950/20',
|
||||
}
|
||||
|
||||
export function InviteStatusCard({
|
||||
|
||||
@@ -12,6 +12,7 @@ interface RemoveMemberDialogProps {
|
||||
open: boolean
|
||||
memberName: string
|
||||
shouldReduceSeats: boolean
|
||||
isSelfRemoval?: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onShouldReduceSeatsChange: (shouldReduce: boolean) => void
|
||||
onConfirmRemove: (shouldReduceSeats: boolean) => Promise<void>
|
||||
@@ -26,34 +27,39 @@ export function RemoveMemberDialog({
|
||||
onShouldReduceSeatsChange,
|
||||
onConfirmRemove,
|
||||
onCancel,
|
||||
isSelfRemoval = false,
|
||||
}: RemoveMemberDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove Team Member</DialogTitle>
|
||||
<DialogTitle>{isSelfRemoval ? 'Leave Organization' : 'Remove Team Member'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to remove {memberName} from the team?
|
||||
{isSelfRemoval
|
||||
? 'Are you sure you want to leave this organization? You will lose access to all team resources.'
|
||||
: `Are you sure you want to remove ${memberName} from the team?`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='py-4'>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<input
|
||||
type='checkbox'
|
||||
id='reduce-seats'
|
||||
className='rounded-[4px]'
|
||||
checked={shouldReduceSeats}
|
||||
onChange={(e) => onShouldReduceSeatsChange(e.target.checked)}
|
||||
/>
|
||||
<label htmlFor='reduce-seats' className='text-xs'>
|
||||
Also reduce seat count in my subscription
|
||||
</label>
|
||||
{!isSelfRemoval && (
|
||||
<div className='py-4'>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<input
|
||||
type='checkbox'
|
||||
id='reduce-seats'
|
||||
className='rounded-[4px]'
|
||||
checked={shouldReduceSeats}
|
||||
onChange={(e) => onShouldReduceSeatsChange(e.target.checked)}
|
||||
/>
|
||||
<label htmlFor='reduce-seats' className='text-xs'>
|
||||
Also reduce seat count in my subscription
|
||||
</label>
|
||||
</div>
|
||||
<p className='mt-1 text-muted-foreground text-xs'>
|
||||
If selected, your team seat count will be reduced by 1, lowering your monthly billing.
|
||||
</p>
|
||||
</div>
|
||||
<p className='mt-1 text-muted-foreground text-xs'>
|
||||
If selected, your team seat count will be reduced by 1, lowering your monthly billing.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant='outline' onClick={onCancel} className='h-9 rounded-[8px]'>
|
||||
@@ -64,7 +70,7 @@ export function RemoveMemberDialog({
|
||||
onClick={() => onConfirmRemove(shouldReduceSeats)}
|
||||
className='h-9 rounded-[8px]'
|
||||
>
|
||||
Remove
|
||||
{isSelfRemoval ? 'Leave Organization' : 'Remove'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { UserX, X } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { LogOut, UserX, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { Invitation, Member, Organization } from '@/stores/organization'
|
||||
|
||||
interface ConsolidatedTeamMembersProps {
|
||||
const logger = createLogger('TeamMembers')
|
||||
|
||||
interface TeamMembersProps {
|
||||
organization: Organization
|
||||
currentUserEmail: string
|
||||
isAdminOrOwner: boolean
|
||||
@@ -10,41 +15,92 @@ interface ConsolidatedTeamMembersProps {
|
||||
onCancelInvitation: (invitationId: string) => void
|
||||
}
|
||||
|
||||
interface TeamMemberItem {
|
||||
type: 'member' | 'invitation'
|
||||
interface BaseItem {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
role: string
|
||||
usage?: string
|
||||
lastActive?: string
|
||||
member?: Member
|
||||
invitation?: Invitation
|
||||
avatarInitial: string
|
||||
usage: string
|
||||
}
|
||||
|
||||
interface MemberItem extends BaseItem {
|
||||
type: 'member'
|
||||
role: string
|
||||
member: Member
|
||||
}
|
||||
|
||||
interface InvitationItem extends BaseItem {
|
||||
type: 'invitation'
|
||||
invitation: Invitation
|
||||
}
|
||||
|
||||
type TeamMemberItem = MemberItem | InvitationItem
|
||||
|
||||
export function TeamMembers({
|
||||
organization,
|
||||
currentUserEmail,
|
||||
isAdminOrOwner,
|
||||
onRemoveMember,
|
||||
onCancelInvitation,
|
||||
}: ConsolidatedTeamMembersProps) {
|
||||
}: TeamMembersProps) {
|
||||
const [memberUsageData, setMemberUsageData] = useState<Record<string, number>>({})
|
||||
const [isLoadingUsage, setIsLoadingUsage] = useState(false)
|
||||
const [cancellingInvitations, setCancellingInvitations] = useState<Set<string>>(new Set())
|
||||
|
||||
// Fetch member usage data when organization changes and user is admin
|
||||
useEffect(() => {
|
||||
const fetchMemberUsage = async () => {
|
||||
if (!organization?.id || !isAdminOrOwner) return
|
||||
|
||||
setIsLoadingUsage(true)
|
||||
try {
|
||||
const response = await fetch(`/api/organizations/${organization.id}/members?include=usage`)
|
||||
if (response.ok) {
|
||||
const result = await response.json()
|
||||
const usageMap: Record<string, number> = {}
|
||||
|
||||
if (result.data) {
|
||||
result.data.forEach((member: any) => {
|
||||
if (member.currentPeriodCost !== null && member.currentPeriodCost !== undefined) {
|
||||
usageMap[member.userId] = Number.parseFloat(member.currentPeriodCost.toString())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
setMemberUsageData(usageMap)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch member usage data', { error })
|
||||
} finally {
|
||||
setIsLoadingUsage(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchMemberUsage()
|
||||
}, [organization?.id, isAdminOrOwner])
|
||||
|
||||
// Combine members and pending invitations into a single list
|
||||
const teamItems: TeamMemberItem[] = []
|
||||
|
||||
// Add existing members
|
||||
if (organization.members) {
|
||||
organization.members.forEach((member: Member) => {
|
||||
teamItems.push({
|
||||
const userId = member.user?.id
|
||||
const usageAmount = userId ? (memberUsageData[userId] ?? 0) : 0
|
||||
const name = member.user?.name || 'Unknown'
|
||||
|
||||
const memberItem: MemberItem = {
|
||||
type: 'member',
|
||||
id: member.id,
|
||||
name: member.user?.name || 'Unknown',
|
||||
name,
|
||||
email: member.user?.email || '',
|
||||
avatarInitial: name.charAt(0).toUpperCase(),
|
||||
usage: `$${usageAmount.toFixed(2)}`,
|
||||
role: member.role,
|
||||
usage: '$0.00', // TODO: Get real usage data
|
||||
lastActive: '8/26/2025', // TODO: Get real last active date
|
||||
member,
|
||||
})
|
||||
}
|
||||
|
||||
teamItems.push(memberItem)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -54,16 +110,19 @@ export function TeamMembers({
|
||||
)
|
||||
if (pendingInvitations) {
|
||||
pendingInvitations.forEach((invitation: Invitation) => {
|
||||
teamItems.push({
|
||||
const emailPrefix = invitation.email.split('@')[0]
|
||||
|
||||
const invitationItem: InvitationItem = {
|
||||
type: 'invitation',
|
||||
id: invitation.id,
|
||||
name: invitation.email.split('@')[0], // Use email prefix as name
|
||||
name: emailPrefix,
|
||||
email: invitation.email,
|
||||
role: 'pending',
|
||||
avatarInitial: emailPrefix.charAt(0).toUpperCase(),
|
||||
usage: '-',
|
||||
lastActive: '-',
|
||||
invitation,
|
||||
})
|
||||
}
|
||||
|
||||
teamItems.push(invitationItem)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -71,6 +130,25 @@ export function TeamMembers({
|
||||
return <div className='text-center text-muted-foreground text-sm'>No team members yet.</div>
|
||||
}
|
||||
|
||||
// Check if current user can leave (is a member but not owner)
|
||||
const currentUserMember = organization.members?.find((m) => m.user?.email === currentUserEmail)
|
||||
const canLeaveOrganization =
|
||||
currentUserMember && currentUserMember.role !== 'owner' && currentUserMember.user?.id
|
||||
|
||||
// Wrap onCancelInvitation to manage loading state
|
||||
const handleCancelInvitation = async (invitationId: string) => {
|
||||
setCancellingInvitations((prev) => new Set([...prev, invitationId]))
|
||||
try {
|
||||
await onCancelInvitation(invitationId)
|
||||
} finally {
|
||||
setCancellingInvitations((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(invitationId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-4'>
|
||||
{/* Header - simple like account page */}
|
||||
@@ -92,7 +170,7 @@ export function TeamMembers({
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{item.name.charAt(0).toUpperCase()}
|
||||
{item.avatarInitial}
|
||||
</div>
|
||||
|
||||
{/* Name and email */}
|
||||
@@ -119,50 +197,95 @@ export function TeamMembers({
|
||||
<div className='truncate text-muted-foreground text-xs'>{item.email}</div>
|
||||
</div>
|
||||
|
||||
{/* Usage and stats - matching subscription layout */}
|
||||
<div className='hidden items-center gap-4 text-xs tabular-nums sm:flex'>
|
||||
<div className='text-center'>
|
||||
<div className='text-muted-foreground'>Usage</div>
|
||||
<div className='font-medium'>{item.usage}</div>
|
||||
{/* Usage stats - matching subscription layout */}
|
||||
{isAdminOrOwner && (
|
||||
<div className='hidden items-center text-xs tabular-nums sm:flex'>
|
||||
<div className='text-center'>
|
||||
<div className='text-muted-foreground'>Usage</div>
|
||||
<div className='font-medium'>
|
||||
{isLoadingUsage && item.type === 'member' ? (
|
||||
<span className='inline-block h-3 w-12 animate-pulse rounded bg-muted' />
|
||||
) : (
|
||||
item.usage
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='text-center'>
|
||||
<div className='text-muted-foreground'>Active</div>
|
||||
<div className='font-medium'>{item.lastActive}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{isAdminOrOwner && (
|
||||
<div className='ml-4'>
|
||||
{item.type === 'member' &&
|
||||
item.member?.role !== 'owner' &&
|
||||
item.email !== currentUserEmail && (
|
||||
<div className='ml-4 flex gap-1'>
|
||||
{/* Admin/Owner can remove other members */}
|
||||
{isAdminOrOwner &&
|
||||
item.type === 'member' &&
|
||||
item.role !== 'owner' &&
|
||||
item.email !== currentUserEmail && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => onRemoveMember(item.member)}
|
||||
className='h-8 w-8 rounded-[8px] p-0'
|
||||
>
|
||||
<UserX className='h-4 w-4' />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='left'>Remove Member</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Admin can cancel invitations */}
|
||||
{isAdminOrOwner && item.type === 'invitation' && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => onRemoveMember(item.member!)}
|
||||
onClick={() => handleCancelInvitation(item.invitation.id)}
|
||||
disabled={cancellingInvitations.has(item.invitation.id)}
|
||||
className='h-8 w-8 rounded-[8px] p-0'
|
||||
>
|
||||
<UserX className='h-4 w-4' />
|
||||
{cancellingInvitations.has(item.invitation.id) ? (
|
||||
<span className='h-4 w-4 animate-spin rounded-full border-2 border-current border-r-transparent' />
|
||||
) : (
|
||||
<X className='h-4 w-4' />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{item.type === 'invitation' && (
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => onCancelInvitation(item.invitation!.id)}
|
||||
className='h-8 w-8 rounded-[8px] p-0'
|
||||
>
|
||||
<X className='h-4 w-4' />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='left'>
|
||||
{cancellingInvitations.has(item.invitation.id)
|
||||
? 'Cancelling...'
|
||||
: 'Cancel Invitation'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Leave Organization button */}
|
||||
{canLeaveOrganization && (
|
||||
<div className='border-t pt-4'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='default'
|
||||
onClick={() => {
|
||||
if (!currentUserMember?.user?.id) {
|
||||
logger.error('Cannot leave organization: missing user ID', { currentUserMember })
|
||||
return
|
||||
}
|
||||
onRemoveMember(currentUserMember)
|
||||
}}
|
||||
className='w-full text-red-600 hover:bg-red-50 hover:text-red-700 dark:hover:bg-red-950/20'
|
||||
>
|
||||
<LogOut className='mr-2 h-4 w-4' />
|
||||
Leave Organization
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ export function TeamManagement() {
|
||||
memberId: string
|
||||
memberName: string
|
||||
shouldReduceSeats: boolean
|
||||
isSelfRemoval?: boolean
|
||||
}>({ open: false, memberId: '', memberName: '', shouldReduceSeats: false })
|
||||
const [orgName, setOrgName] = useState('')
|
||||
const [orgSlug, setOrgSlug] = useState('')
|
||||
@@ -163,14 +164,26 @@ export function TeamManagement() {
|
||||
async (member: any) => {
|
||||
if (!session?.user || !activeOrgId) return
|
||||
|
||||
// The member object should have user.id - that's the actual user ID
|
||||
if (!member.user?.id) {
|
||||
logger.error('Member object missing user ID', { member })
|
||||
return
|
||||
}
|
||||
|
||||
const isLeavingSelf = member.user?.email === session.user.email
|
||||
const displayName = isLeavingSelf
|
||||
? 'yourself'
|
||||
: member.user?.name || member.user?.email || 'this member'
|
||||
|
||||
setRemoveMemberDialog({
|
||||
open: true,
|
||||
memberId: member.id,
|
||||
memberName: member.user?.name || member.user?.email || 'this member',
|
||||
memberId: member.user.id,
|
||||
memberName: displayName,
|
||||
shouldReduceSeats: false,
|
||||
isSelfRemoval: isLeavingSelf,
|
||||
})
|
||||
},
|
||||
[session?.user?.id, activeOrgId]
|
||||
[session?.user, activeOrgId]
|
||||
)
|
||||
|
||||
const confirmRemoveMember = useCallback(
|
||||
@@ -342,6 +355,16 @@ export function TeamManagement() {
|
||||
onCancelInvitation={cancelInvitation}
|
||||
/>
|
||||
|
||||
{/* Single Organization Notice */}
|
||||
{adminOrOwner && (
|
||||
<div className='mt-4 rounded-lg bg-muted/50 p-3'>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
<span className='font-medium'>Note:</span> Users can only be part of one organization
|
||||
at a time. They must leave their current organization before joining another.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Team Information Section - at bottom of modal */}
|
||||
<div className='mt-12 border-t pt-6'>
|
||||
<div className='space-y-3 text-xs'>
|
||||
@@ -365,6 +388,7 @@ export function TeamManagement() {
|
||||
open={removeMemberDialog.open}
|
||||
memberName={removeMemberDialog.memberName}
|
||||
shouldReduceSeats={removeMemberDialog.shouldReduceSeats}
|
||||
isSelfRemoval={removeMemberDialog.isSelfRemoval}
|
||||
onOpenChange={(open: boolean) => {
|
||||
if (!open) setRemoveMemberDialog({ ...removeMemberDialog, open: false })
|
||||
}}
|
||||
@@ -381,6 +405,7 @@ export function TeamManagement() {
|
||||
memberId: '',
|
||||
memberName: '',
|
||||
shouldReduceSeats: false,
|
||||
isSelfRemoval: false,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
renderPasswordResetEmail,
|
||||
} from '@/components/emails/render-email'
|
||||
import { getBaseURL } from '@/lib/auth-client'
|
||||
import { sendPlanWelcomeEmail } from '@/lib/billing'
|
||||
import { authorizeSubscriptionReference } from '@/lib/billing/authorization'
|
||||
import { handleNewUser } from '@/lib/billing/core/usage'
|
||||
import { syncSubscriptionUsageLimits } from '@/lib/billing/organization'
|
||||
@@ -32,6 +33,10 @@ import {
|
||||
handleInvoicePaymentFailed,
|
||||
handleInvoicePaymentSucceeded,
|
||||
} from '@/lib/billing/webhooks/invoices'
|
||||
import {
|
||||
handleSubscriptionCreated,
|
||||
handleSubscriptionDeleted,
|
||||
} from '@/lib/billing/webhooks/subscription'
|
||||
import { sendEmail } from '@/lib/email/mailer'
|
||||
import { getFromEmailAddress } from '@/lib/email/utils'
|
||||
import { quickValidateEmail } from '@/lib/email/validation'
|
||||
@@ -1217,25 +1222,11 @@ export const auth = betterAuth({
|
||||
status: subscription.status,
|
||||
})
|
||||
|
||||
try {
|
||||
await syncSubscriptionUsageLimits(subscription)
|
||||
} catch (error) {
|
||||
logger.error('[onSubscriptionComplete] Failed to sync usage limits', {
|
||||
subscriptionId: subscription.id,
|
||||
referenceId: subscription.referenceId,
|
||||
error,
|
||||
})
|
||||
}
|
||||
await handleSubscriptionCreated(subscription)
|
||||
|
||||
try {
|
||||
const { sendPlanWelcomeEmail } = await import('@/lib/billing')
|
||||
await sendPlanWelcomeEmail(subscription)
|
||||
} catch (error) {
|
||||
logger.error('[onSubscriptionComplete] Failed to send plan welcome email', {
|
||||
error,
|
||||
subscriptionId: subscription.id,
|
||||
})
|
||||
}
|
||||
await syncSubscriptionUsageLimits(subscription)
|
||||
|
||||
await sendPlanWelcomeEmail(subscription)
|
||||
},
|
||||
onSubscriptionUpdate: async ({
|
||||
subscription,
|
||||
@@ -1272,6 +1263,9 @@ export const auth = betterAuth({
|
||||
})
|
||||
|
||||
try {
|
||||
await handleSubscriptionDeleted(subscription)
|
||||
|
||||
// Reset usage limits to free tier
|
||||
await syncSubscriptionUsageLimits(subscription)
|
||||
|
||||
logger.info('[onSubscriptionDeleted] Reset usage limits to free tier', {
|
||||
@@ -1279,7 +1273,7 @@ export const auth = betterAuth({
|
||||
referenceId: subscription.referenceId,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('[onSubscriptionDeleted] Failed to reset usage limits', {
|
||||
logger.error('[onSubscriptionDeleted] Failed to handle subscription deletion', {
|
||||
subscriptionId: subscription.id,
|
||||
referenceId: subscription.referenceId,
|
||||
error,
|
||||
@@ -1311,6 +1305,7 @@ export const auth = betterAuth({
|
||||
await handleManualEnterpriseSubscription(event)
|
||||
break
|
||||
}
|
||||
// Note: customer.subscription.deleted is handled by better-auth's onSubscriptionDeleted callback above
|
||||
default:
|
||||
logger.info('[onEvent] Ignoring unsupported webhook event', {
|
||||
eventId: event.id,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { db } from '@sim/db'
|
||||
import { member, subscription, user } from '@sim/db/schema'
|
||||
import { member, 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'
|
||||
@@ -98,6 +98,100 @@ export async function calculateUserOverage(userId: string): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate overage amount for a subscription
|
||||
* Shared logic between invoice.finalized and customer.subscription.deleted handlers
|
||||
*/
|
||||
export async function calculateSubscriptionOverage(sub: {
|
||||
id: string
|
||||
plan: string | null
|
||||
referenceId: string
|
||||
seats?: number | null
|
||||
}): Promise<number> {
|
||||
// Enterprise plans have no overages
|
||||
if (sub.plan === 'enterprise') {
|
||||
logger.info('Enterprise plan has no overages', {
|
||||
subscriptionId: sub.id,
|
||||
plan: sub.plan,
|
||||
})
|
||||
return 0
|
||||
}
|
||||
|
||||
let totalOverage = 0
|
||||
|
||||
if (sub.plan === 'team') {
|
||||
// Team plan: sum all member usage
|
||||
const members = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(eq(member.organizationId, sub.referenceId))
|
||||
|
||||
let totalTeamUsage = 0
|
||||
for (const m of members) {
|
||||
const usage = await getUserUsageData(m.userId)
|
||||
totalTeamUsage += usage.currentUsage
|
||||
}
|
||||
|
||||
const { basePrice } = getPlanPricing(sub.plan)
|
||||
const baseSubscriptionAmount = (sub.seats || 1) * basePrice
|
||||
totalOverage = Math.max(0, totalTeamUsage - baseSubscriptionAmount)
|
||||
|
||||
logger.info('Calculated team overage', {
|
||||
subscriptionId: sub.id,
|
||||
totalTeamUsage,
|
||||
baseSubscriptionAmount,
|
||||
totalOverage,
|
||||
})
|
||||
} else if (sub.plan === 'pro') {
|
||||
// Pro plan: include snapshot if user joined a team
|
||||
const usage = await getUserUsageData(sub.referenceId)
|
||||
let totalProUsage = usage.currentUsage
|
||||
|
||||
// Add any snapshotted Pro usage (from when they joined a team)
|
||||
const userStatsRows = await db
|
||||
.select({ proPeriodCostSnapshot: userStats.proPeriodCostSnapshot })
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, sub.referenceId))
|
||||
.limit(1)
|
||||
|
||||
if (userStatsRows.length > 0 && userStatsRows[0].proPeriodCostSnapshot) {
|
||||
const snapshotUsage = Number.parseFloat(userStatsRows[0].proPeriodCostSnapshot.toString())
|
||||
totalProUsage += snapshotUsage
|
||||
logger.info('Including snapshotted Pro usage in overage calculation', {
|
||||
userId: sub.referenceId,
|
||||
currentUsage: usage.currentUsage,
|
||||
snapshotUsage,
|
||||
totalProUsage,
|
||||
})
|
||||
}
|
||||
|
||||
const { basePrice } = getPlanPricing(sub.plan)
|
||||
totalOverage = Math.max(0, totalProUsage - basePrice)
|
||||
|
||||
logger.info('Calculated pro overage', {
|
||||
subscriptionId: sub.id,
|
||||
totalProUsage,
|
||||
basePrice,
|
||||
totalOverage,
|
||||
})
|
||||
} else {
|
||||
// Free plan or unknown plan type
|
||||
const usage = await getUserUsageData(sub.referenceId)
|
||||
const { basePrice } = getPlanPricing(sub.plan || 'free')
|
||||
totalOverage = Math.max(0, usage.currentUsage - basePrice)
|
||||
|
||||
logger.info('Calculated overage for plan', {
|
||||
subscriptionId: sub.id,
|
||||
plan: sub.plan || 'free',
|
||||
usage: usage.currentUsage,
|
||||
basePrice,
|
||||
totalOverage,
|
||||
})
|
||||
}
|
||||
|
||||
return totalOverage
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comprehensive billing and subscription summary
|
||||
*/
|
||||
|
||||
@@ -55,7 +55,22 @@ export async function getUserUsageData(userId: string): Promise<UsageData> {
|
||||
}
|
||||
|
||||
const stats = userStatsData[0]
|
||||
const currentUsage = Number.parseFloat(stats.currentPeriodCost?.toString() ?? '0')
|
||||
let currentUsage = Number.parseFloat(stats.currentPeriodCost?.toString() ?? '0')
|
||||
|
||||
// For Pro users, include any snapshotted usage (from when they joined a team)
|
||||
// This ensures they see their total Pro usage in the UI
|
||||
if (subscription && subscription.plan === 'pro' && subscription.referenceId === userId) {
|
||||
const snapshotUsage = Number.parseFloat(stats.proPeriodCostSnapshot?.toString() ?? '0')
|
||||
if (snapshotUsage > 0) {
|
||||
currentUsage += snapshotUsage
|
||||
logger.info('Including Pro snapshot in usage display', {
|
||||
userId,
|
||||
currentPeriodCost: stats.currentPeriodCost,
|
||||
proPeriodCostSnapshot: snapshotUsage,
|
||||
totalUsage: currentUsage,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Determine usage limit based on plan type
|
||||
let limit: number
|
||||
|
||||
@@ -2,13 +2,13 @@ import { db } from '@sim/db'
|
||||
import { member, subscription as subscriptionTable, userStats } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type Stripe from 'stripe'
|
||||
import { getUserUsageData } from '@/lib/billing/core/usage'
|
||||
import { calculateSubscriptionOverage } from '@/lib/billing/core/billing'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('StripeInvoiceWebhooks')
|
||||
|
||||
async function resetUsageForSubscription(sub: { plan: string | null; referenceId: string }) {
|
||||
export async function resetUsageForSubscription(sub: { plan: string | null; referenceId: string }) {
|
||||
if (sub.plan === 'team' || sub.plan === 'enterprise') {
|
||||
const membersRows = await db
|
||||
.select({ userId: member.userId })
|
||||
@@ -31,15 +31,26 @@ async function resetUsageForSubscription(sub: { plan: string | null; referenceId
|
||||
}
|
||||
} else {
|
||||
const currentStats = await db
|
||||
.select({ current: userStats.currentPeriodCost })
|
||||
.select({
|
||||
current: userStats.currentPeriodCost,
|
||||
snapshot: userStats.proPeriodCostSnapshot,
|
||||
})
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, sub.referenceId))
|
||||
.limit(1)
|
||||
if (currentStats.length > 0) {
|
||||
const current = currentStats[0].current || '0'
|
||||
// For Pro plans, combine current + snapshot for lastPeriodCost, then clear both
|
||||
const current = Number.parseFloat(currentStats[0].current?.toString() || '0')
|
||||
const snapshot = Number.parseFloat(currentStats[0].snapshot?.toString() || '0')
|
||||
const totalLastPeriod = (current + snapshot).toString()
|
||||
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({ lastPeriodCost: current, currentPeriodCost: '0' })
|
||||
.set({
|
||||
lastPeriodCost: totalLastPeriod,
|
||||
currentPeriodCost: '0',
|
||||
proPeriodCostSnapshot: '0', // Clear snapshot at period end
|
||||
})
|
||||
.where(eq(userStats.userId, sub.referenceId))
|
||||
}
|
||||
}
|
||||
@@ -242,29 +253,7 @@ export async function handleInvoiceFinalized(event: Stripe.Event) {
|
||||
const billingPeriod = new Date(periodEnd * 1000).toISOString().slice(0, 7)
|
||||
|
||||
// Compute overage (only for team and pro plans), before resetting usage
|
||||
let totalOverage = 0
|
||||
if (sub.plan === 'team') {
|
||||
const members = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(eq(member.organizationId, sub.referenceId))
|
||||
|
||||
let totalTeamUsage = 0
|
||||
for (const m of members) {
|
||||
const usage = await getUserUsageData(m.userId)
|
||||
totalTeamUsage += usage.currentUsage
|
||||
}
|
||||
|
||||
const { getPlanPricing } = await import('@/lib/billing/core/billing')
|
||||
const { basePrice } = getPlanPricing(sub.plan)
|
||||
const baseSubscriptionAmount = (sub.seats || 1) * basePrice
|
||||
totalOverage = Math.max(0, totalTeamUsage - baseSubscriptionAmount)
|
||||
} else {
|
||||
const usage = await getUserUsageData(sub.referenceId)
|
||||
const { getPlanPricing } = await import('@/lib/billing/core/billing')
|
||||
const { basePrice } = getPlanPricing(sub.plan)
|
||||
totalOverage = Math.max(0, usage.currentUsage - basePrice)
|
||||
}
|
||||
const totalOverage = await calculateSubscriptionOverage(sub)
|
||||
|
||||
if (totalOverage > 0) {
|
||||
const customerId = String(invoice.customer)
|
||||
|
||||
206
apps/sim/lib/billing/webhooks/subscription.ts
Normal file
206
apps/sim/lib/billing/webhooks/subscription.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { db } from '@sim/db'
|
||||
import { subscription } from '@sim/db/schema'
|
||||
import { and, eq, ne } from 'drizzle-orm'
|
||||
import { calculateSubscriptionOverage } from '@/lib/billing/core/billing'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { resetUsageForSubscription } from './invoices'
|
||||
|
||||
const logger = createLogger('StripeSubscriptionWebhooks')
|
||||
|
||||
/**
|
||||
* Handle new subscription creation - reset usage if transitioning from free to paid
|
||||
*/
|
||||
export async function handleSubscriptionCreated(subscriptionData: {
|
||||
id: string
|
||||
referenceId: string
|
||||
plan: string | null
|
||||
status: string
|
||||
}) {
|
||||
try {
|
||||
const otherActiveSubscriptions = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(
|
||||
and(
|
||||
eq(subscription.referenceId, subscriptionData.referenceId),
|
||||
eq(subscription.status, 'active'),
|
||||
ne(subscription.id, subscriptionData.id) // Exclude current subscription
|
||||
)
|
||||
)
|
||||
|
||||
const wasFreePreviously = otherActiveSubscriptions.length === 0
|
||||
const isPaidPlan =
|
||||
subscriptionData.plan === 'pro' ||
|
||||
subscriptionData.plan === 'team' ||
|
||||
subscriptionData.plan === 'enterprise'
|
||||
|
||||
if (wasFreePreviously && isPaidPlan) {
|
||||
logger.info('Detected free -> paid transition, resetting usage', {
|
||||
subscriptionId: subscriptionData.id,
|
||||
referenceId: subscriptionData.referenceId,
|
||||
plan: subscriptionData.plan,
|
||||
})
|
||||
|
||||
await resetUsageForSubscription({
|
||||
plan: subscriptionData.plan,
|
||||
referenceId: subscriptionData.referenceId,
|
||||
})
|
||||
|
||||
logger.info('Successfully reset usage for free -> paid transition', {
|
||||
subscriptionId: subscriptionData.id,
|
||||
referenceId: subscriptionData.referenceId,
|
||||
plan: subscriptionData.plan,
|
||||
})
|
||||
} else {
|
||||
logger.info('No usage reset needed', {
|
||||
subscriptionId: subscriptionData.id,
|
||||
referenceId: subscriptionData.referenceId,
|
||||
plan: subscriptionData.plan,
|
||||
wasFreePreviously,
|
||||
isPaidPlan,
|
||||
otherActiveSubscriptionsCount: otherActiveSubscriptions.length,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to handle subscription creation usage reset', {
|
||||
subscriptionId: subscriptionData.id,
|
||||
referenceId: subscriptionData.referenceId,
|
||||
error,
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle subscription deletion/cancellation - bill for final period overages
|
||||
* This fires when a subscription reaches its cancel_at_period_end date or is cancelled immediately
|
||||
*/
|
||||
export async function handleSubscriptionDeleted(subscription: {
|
||||
id: string
|
||||
plan: string | null
|
||||
referenceId: string
|
||||
stripeSubscriptionId: string | null
|
||||
seats?: number | null
|
||||
}) {
|
||||
try {
|
||||
const stripeSubscriptionId = subscription.stripeSubscriptionId || ''
|
||||
|
||||
logger.info('Processing subscription deletion', {
|
||||
stripeSubscriptionId,
|
||||
subscriptionId: subscription.id,
|
||||
})
|
||||
|
||||
// Calculate overage for the final billing period
|
||||
const totalOverage = await calculateSubscriptionOverage(subscription)
|
||||
const stripe = requireStripeClient()
|
||||
|
||||
// Enterprise plans have no overages - just reset usage
|
||||
if (subscription.plan === 'enterprise') {
|
||||
await resetUsageForSubscription({
|
||||
plan: subscription.plan,
|
||||
referenceId: subscription.referenceId,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Create final overage invoice if needed
|
||||
if (totalOverage > 0 && stripeSubscriptionId) {
|
||||
const stripeSubscription = await stripe.subscriptions.retrieve(stripeSubscriptionId)
|
||||
const customerId = stripeSubscription.customer as string
|
||||
const cents = Math.round(totalOverage * 100)
|
||||
|
||||
// Use the subscription end date for the billing period
|
||||
const endedAt = stripeSubscription.ended_at || Math.floor(Date.now() / 1000)
|
||||
const billingPeriod = new Date(endedAt * 1000).toISOString().slice(0, 7)
|
||||
|
||||
const itemIdemKey = `final-overage-item:${customerId}:${stripeSubscriptionId}:${billingPeriod}`
|
||||
const invoiceIdemKey = `final-overage-invoice:${customerId}:${stripeSubscriptionId}:${billingPeriod}`
|
||||
|
||||
try {
|
||||
// Create a one-time invoice for the final overage
|
||||
const overageInvoice = await stripe.invoices.create(
|
||||
{
|
||||
customer: customerId,
|
||||
collection_method: 'charge_automatically',
|
||||
auto_advance: true, // Auto-finalize and attempt payment
|
||||
description: `Final overage charges for ${subscription.plan} subscription (${billingPeriod})`,
|
||||
metadata: {
|
||||
type: 'final_overage_billing',
|
||||
billingPeriod,
|
||||
subscriptionId: stripeSubscriptionId,
|
||||
cancelledAt: stripeSubscription.canceled_at?.toString() || '',
|
||||
},
|
||||
},
|
||||
{ idempotencyKey: invoiceIdemKey }
|
||||
)
|
||||
|
||||
// Add the overage line item
|
||||
await stripe.invoiceItems.create(
|
||||
{
|
||||
customer: customerId,
|
||||
invoice: overageInvoice.id,
|
||||
amount: cents,
|
||||
currency: 'usd',
|
||||
description: `Usage overage for ${subscription.plan} plan (Final billing period)`,
|
||||
metadata: {
|
||||
type: 'final_usage_overage',
|
||||
usage: totalOverage.toFixed(2),
|
||||
billingPeriod,
|
||||
},
|
||||
},
|
||||
{ idempotencyKey: itemIdemKey }
|
||||
)
|
||||
|
||||
// Finalize the invoice (this will trigger payment collection)
|
||||
if (overageInvoice.id) {
|
||||
await stripe.invoices.finalizeInvoice(overageInvoice.id)
|
||||
}
|
||||
|
||||
logger.info('Created final overage invoice for cancelled subscription', {
|
||||
subscriptionId: subscription.id,
|
||||
stripeSubscriptionId,
|
||||
invoiceId: overageInvoice.id,
|
||||
overageAmount: totalOverage,
|
||||
cents,
|
||||
billingPeriod,
|
||||
})
|
||||
} catch (invoiceError) {
|
||||
logger.error('Failed to create final overage invoice', {
|
||||
subscriptionId: subscription.id,
|
||||
stripeSubscriptionId,
|
||||
overageAmount: totalOverage,
|
||||
error: invoiceError,
|
||||
})
|
||||
// Don't throw - we don't want to fail the webhook
|
||||
}
|
||||
} else {
|
||||
logger.info('No overage to bill for cancelled subscription', {
|
||||
subscriptionId: subscription.id,
|
||||
plan: subscription.plan,
|
||||
})
|
||||
}
|
||||
|
||||
// Reset usage after billing
|
||||
await resetUsageForSubscription({
|
||||
plan: subscription.plan,
|
||||
referenceId: subscription.referenceId,
|
||||
})
|
||||
|
||||
// Note: better-auth's Stripe plugin already updates status to 'canceled' before calling this handler
|
||||
// We only need to handle overage billing and usage reset
|
||||
|
||||
logger.info('Successfully processed subscription cancellation', {
|
||||
subscriptionId: subscription.id,
|
||||
stripeSubscriptionId,
|
||||
totalOverage,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to handle subscription deletion', {
|
||||
subscriptionId: subscription.id,
|
||||
stripeSubscriptionId: subscription.stripeSubscriptionId || '',
|
||||
error,
|
||||
})
|
||||
throw error // Re-throw to signal webhook failure for retry
|
||||
}
|
||||
}
|
||||
@@ -546,22 +546,36 @@ export const useOrganizationStore = create<OrganizationStore>()(
|
||||
const { activeOrganization, subscriptionData } = get()
|
||||
if (!activeOrganization) return
|
||||
|
||||
logger.info('Removing member', {
|
||||
memberId,
|
||||
organizationId: activeOrganization.id,
|
||||
shouldReduceSeats,
|
||||
})
|
||||
|
||||
set({ isLoading: true })
|
||||
|
||||
try {
|
||||
await client.organization.removeMember({
|
||||
memberIdOrEmail: memberId,
|
||||
organizationId: activeOrganization.id,
|
||||
})
|
||||
|
||||
// If the user opted to reduce seats as well
|
||||
if (shouldReduceSeats && subscriptionData) {
|
||||
const currentSeats = subscriptionData.seats || 0
|
||||
if (currentSeats > 1) {
|
||||
await get().reduceSeats(currentSeats - 1)
|
||||
// Use our custom API endpoint for member removal instead of better-auth client
|
||||
const response = await fetch(
|
||||
`/api/organizations/${activeOrganization.id}/members/${memberId}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include', // Ensure cookies are sent
|
||||
body: JSON.stringify({ shouldReduceSeats }),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}) as any)
|
||||
throw new Error((data as any).error || 'Failed to remove member')
|
||||
}
|
||||
|
||||
// If the user opted to reduce seats as well (handled by the API endpoint)
|
||||
// No need to call reduceSeats separately as it's handled in the endpoint
|
||||
|
||||
await get().refreshOrganization()
|
||||
} catch (error) {
|
||||
logger.error('Failed to remove member', { error })
|
||||
@@ -584,10 +598,24 @@ export const useOrganizationStore = create<OrganizationStore>()(
|
||||
)}`,
|
||||
{ method: 'DELETE' }
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}) as any)
|
||||
|
||||
// If the invitation is not found (404), it might have already been processed
|
||||
// Just refresh the organization data to get the latest state
|
||||
if (response.status === 404) {
|
||||
logger.info(
|
||||
'Invitation not found or already processed, refreshing organization data',
|
||||
{ invitationId }
|
||||
)
|
||||
await get().refreshOrganization()
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error((data as any).error || 'Failed to cancel invitation')
|
||||
}
|
||||
|
||||
await get().refreshOrganization()
|
||||
} catch (error) {
|
||||
logger.error('Failed to cancel invitation', { error })
|
||||
|
||||
1
packages/db/migrations/0091_amusing_iron_lad.sql
Normal file
1
packages/db/migrations/0091_amusing_iron_lad.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "user_stats" ADD COLUMN "pro_period_cost_snapshot" numeric DEFAULT '0';
|
||||
6852
packages/db/migrations/meta/0091_snapshot.json
Normal file
6852
packages/db/migrations/meta/0091_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -631,6 +631,13 @@
|
||||
"when": 1757805452908,
|
||||
"tag": "0090_fearless_zaladane",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 91,
|
||||
"version": "7",
|
||||
"when": 1758567567287,
|
||||
"tag": "0091_amusing_iron_lad",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -565,6 +565,8 @@ export const userStats = pgTable('user_stats', {
|
||||
// Billing period tracking
|
||||
currentPeriodCost: decimal('current_period_cost').notNull().default('0'), // Usage in current billing period
|
||||
lastPeriodCost: decimal('last_period_cost').default('0'), // Usage from previous billing period
|
||||
// Pro usage snapshot when joining a team (to prevent double-billing)
|
||||
proPeriodCostSnapshot: decimal('pro_period_cost_snapshot').default('0'), // Snapshot of Pro usage when joining team
|
||||
// Copilot usage tracking
|
||||
totalCopilotCost: decimal('total_copilot_cost').notNull().default('0'),
|
||||
totalCopilotTokens: integer('total_copilot_tokens').notNull().default(0),
|
||||
|
||||
Reference in New Issue
Block a user