diff --git a/apps/sim/app/api/organizations/[id]/route.ts b/apps/sim/app/api/organizations/[id]/route.ts index eada8c6aa1..65e9743942 100644 --- a/apps/sim/app/api/organizations/[id]/route.ts +++ b/apps/sim/app/api/organizations/[id]/route.ts @@ -7,7 +7,6 @@ import { getSession } from '@/lib/auth' import { getOrganizationSeatAnalytics, getOrganizationSeatInfo, - updateOrganizationSeats, } from '@/lib/billing/validation/seat-management' import { createLogger } from '@/lib/logs/console/logger' @@ -25,7 +24,6 @@ const updateOrganizationSchema = z.object({ ) .optional(), logo: z.string().nullable().optional(), - seats: z.number().int().min(1, 'Invalid seat count').optional(), }) /** @@ -116,7 +114,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ /** * PUT /api/organizations/[id] - * Update organization settings or seat count + * Update organization settings (name, slug, logo) + * Note: For seat updates, use PUT /api/organizations/[id]/seats instead */ export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { try { @@ -135,7 +134,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: firstError.message }, { status: 400 }) } - const { name, slug, logo, seats } = validation.data + const { name, slug, logo } = validation.data // Verify user has admin access const memberEntry = await db @@ -155,31 +154,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) } - // Handle seat count update - if (seats !== undefined) { - const result = await updateOrganizationSeats(organizationId, seats, session.user.id) - - if (!result.success) { - return NextResponse.json({ error: result.error }, { status: 400 }) - } - - logger.info('Organization seat count updated', { - organizationId, - newSeatCount: seats, - updatedBy: session.user.id, - }) - - return NextResponse.json({ - success: true, - message: 'Seat count updated successfully', - data: { - seats: seats, - updatedBy: session.user.id, - updatedAt: new Date().toISOString(), - }, - }) - } - // Handle settings update if (name !== undefined || slug !== undefined || logo !== undefined) { // Check if slug is already taken by another organization diff --git a/apps/sim/app/api/organizations/[id]/seats/route.ts b/apps/sim/app/api/organizations/[id]/seats/route.ts new file mode 100644 index 0000000000..8812b3673f --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/seats/route.ts @@ -0,0 +1,297 @@ +import { db } from '@sim/db' +import { member, subscription } from '@sim/db/schema' +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { getSession } from '@/lib/auth' +import { requireStripeClient } from '@/lib/billing/stripe-client' +import { isBillingEnabled } from '@/lib/environment' +import { createLogger } from '@/lib/logs/console/logger' + +const logger = createLogger('OrganizationSeatsAPI') + +const updateSeatsSchema = z.object({ + seats: z.number().int().min(1, 'Minimum 1 seat required').max(50, 'Maximum 50 seats allowed'), +}) + +/** + * PUT /api/organizations/[id]/seats + * Update organization seat count using Stripe's subscription.update API. + * This is the recommended approach for per-seat billing changes. + */ +export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const session = await getSession() + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + if (!isBillingEnabled) { + return NextResponse.json({ error: 'Billing is not enabled' }, { status: 400 }) + } + + const { id: organizationId } = await params + const body = await request.json() + + const validation = updateSeatsSchema.safeParse(body) + if (!validation.success) { + const firstError = validation.error.errors[0] + return NextResponse.json({ error: firstError.message }, { status: 400 }) + } + + const { seats: newSeatCount } = validation.data + + // Verify user has admin access to this organization + const memberEntry = await db + .select() + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) + .limit(1) + + if (memberEntry.length === 0) { + return NextResponse.json( + { error: 'Forbidden - Not a member of this organization' }, + { status: 403 } + ) + } + + if (!['owner', 'admin'].includes(memberEntry[0].role)) { + return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) + } + + // Get the organization's subscription + const subscriptionRecord = await db + .select() + .from(subscription) + .where(and(eq(subscription.referenceId, organizationId), eq(subscription.status, 'active'))) + .limit(1) + + if (subscriptionRecord.length === 0) { + return NextResponse.json({ error: 'No active subscription found' }, { status: 404 }) + } + + const orgSubscription = subscriptionRecord[0] + + // Only team plans support seat changes (not enterprise - those are handled manually) + if (orgSubscription.plan !== 'team') { + return NextResponse.json( + { error: 'Seat changes are only available for Team plans' }, + { status: 400 } + ) + } + + if (!orgSubscription.stripeSubscriptionId) { + return NextResponse.json( + { error: 'No Stripe subscription found for this organization' }, + { status: 400 } + ) + } + + // Validate that we're not reducing below current member count + const memberCount = await db + .select({ userId: member.userId }) + .from(member) + .where(eq(member.organizationId, organizationId)) + + if (newSeatCount < memberCount.length) { + return NextResponse.json( + { + error: `Cannot reduce seats below current member count (${memberCount.length})`, + currentMembers: memberCount.length, + }, + { status: 400 } + ) + } + + const currentSeats = orgSubscription.seats || 1 + + // If no change, return early + if (newSeatCount === currentSeats) { + return NextResponse.json({ + success: true, + message: 'No change in seat count', + data: { + seats: currentSeats, + stripeSubscriptionId: orgSubscription.stripeSubscriptionId, + }, + }) + } + + const stripe = requireStripeClient() + + // Get the Stripe subscription to find the subscription item ID + const stripeSubscription = await stripe.subscriptions.retrieve( + orgSubscription.stripeSubscriptionId + ) + + if (stripeSubscription.status !== 'active') { + return NextResponse.json({ error: 'Stripe subscription is not active' }, { status: 400 }) + } + + // Find the subscription item (there should be only one for team plans) + const subscriptionItem = stripeSubscription.items.data[0] + + if (!subscriptionItem) { + return NextResponse.json( + { error: 'No subscription item found in Stripe subscription' }, + { status: 500 } + ) + } + + logger.info('Updating Stripe subscription quantity', { + organizationId, + stripeSubscriptionId: orgSubscription.stripeSubscriptionId, + subscriptionItemId: subscriptionItem.id, + currentSeats, + newSeatCount, + userId: session.user.id, + }) + + // Update the subscription item quantity using Stripe's recommended approach + // This will automatically prorate the billing + const updatedSubscription = await stripe.subscriptions.update( + orgSubscription.stripeSubscriptionId, + { + items: [ + { + id: subscriptionItem.id, + quantity: newSeatCount, + }, + ], + proration_behavior: 'create_prorations', // Stripe's default - charge/credit immediately + } + ) + + // Update our local database to reflect the change + // Note: This will also be updated via webhook, but we update immediately for UX + await db + .update(subscription) + .set({ + seats: newSeatCount, + }) + .where(eq(subscription.id, orgSubscription.id)) + + logger.info('Successfully updated seat count', { + organizationId, + stripeSubscriptionId: orgSubscription.stripeSubscriptionId, + oldSeats: currentSeats, + newSeats: newSeatCount, + updatedBy: session.user.id, + prorationBehavior: 'create_prorations', + }) + + return NextResponse.json({ + success: true, + message: + newSeatCount > currentSeats + ? `Added ${newSeatCount - currentSeats} seat(s). Your billing has been adjusted.` + : `Removed ${currentSeats - newSeatCount} seat(s). You'll receive a prorated credit.`, + data: { + seats: newSeatCount, + previousSeats: currentSeats, + stripeSubscriptionId: updatedSubscription.id, + stripeStatus: updatedSubscription.status, + }, + }) + } catch (error) { + const { id: organizationId } = await params + + // Handle Stripe-specific errors + if (error instanceof Error && 'type' in error) { + const stripeError = error as any + logger.error('Stripe error updating seats', { + organizationId, + type: stripeError.type, + code: stripeError.code, + message: stripeError.message, + }) + + return NextResponse.json( + { + error: stripeError.message || 'Failed to update seats in Stripe', + code: stripeError.code, + }, + { status: 400 } + ) + } + + logger.error('Failed to update organization seats', { + organizationId, + error, + }) + + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} + +/** + * GET /api/organizations/[id]/seats + * Get current seat information for an organization + */ +export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const session = await getSession() + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: organizationId } = await params + + // Verify user has access to this organization + const memberEntry = await db + .select() + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) + .limit(1) + + if (memberEntry.length === 0) { + return NextResponse.json( + { error: 'Forbidden - Not a member of this organization' }, + { status: 403 } + ) + } + + // Get subscription data + const subscriptionRecord = await db + .select() + .from(subscription) + .where(and(eq(subscription.referenceId, organizationId), eq(subscription.status, 'active'))) + .limit(1) + + if (subscriptionRecord.length === 0) { + return NextResponse.json({ error: 'No active subscription found' }, { status: 404 }) + } + + // Get member count + const memberCount = await db + .select({ userId: member.userId }) + .from(member) + .where(eq(member.organizationId, organizationId)) + + const orgSubscription = subscriptionRecord[0] + const maxSeats = orgSubscription.seats || 1 + const usedSeats = memberCount.length + const availableSeats = Math.max(0, maxSeats - usedSeats) + + return NextResponse.json({ + success: true, + data: { + maxSeats, + usedSeats, + availableSeats, + plan: orgSubscription.plan, + canModifySeats: orgSubscription.plan === 'team', + }, + }) + } catch (error) { + const { id: organizationId } = await params + logger.error('Failed to get organization seats', { + organizationId, + error, + }) + + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/team-management.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/team-management.tsx index 5ca0bb837b..d0e38f80a1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/team-management.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/team-management.tsx @@ -233,7 +233,6 @@ export function TeamManagement() { await updateSeatsMutation.mutateAsync({ orgId: activeOrganization?.id, seats: currentSeats - 1, - subscriptionId: subscriptionData.id, }) } catch (error) { logger.error('Failed to reduce seats', error) @@ -258,7 +257,6 @@ export function TeamManagement() { await updateSeatsMutation.mutateAsync({ orgId: activeOrganization?.id, seats: seatsToUse, - subscriptionId: subscriptionData.id, }) setIsAddSeatDialogOpen(false) } catch (error) { diff --git a/apps/sim/hooks/queries/organization.ts b/apps/sim/hooks/queries/organization.ts index 74897c7cd3..f77273152b 100644 --- a/apps/sim/hooks/queries/organization.ts +++ b/apps/sim/hooks/queries/organization.ts @@ -1,5 +1,8 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { client } from '@/lib/auth-client' +import { createLogger } from '@/lib/logs/console/logger' + +const logger = createLogger('OrganizationQueries') /** * Query key factories for organization-related queries @@ -79,7 +82,7 @@ async function fetchOrganizationSubscription(orgId: string) { }) if (response.error) { - console.error('Error fetching organization subscription:', response.error) + logger.error('Error fetching organization subscription', { error: response.error }) return null } @@ -367,28 +370,25 @@ export function useCancelInvitation() { interface UpdateSeatsParams { orgId: string seats: number - subscriptionId: string } export function useUpdateSeats() { const queryClient = useQueryClient() return useMutation({ - mutationFn: async ({ seats, orgId, subscriptionId }: UpdateSeatsParams) => { - const response = await client.subscription.upgrade({ - plan: 'team', - referenceId: orgId, - subscriptionId, - seats, - successUrl: window.location.href, - cancelUrl: window.location.href, + mutationFn: async ({ seats, orgId }: UpdateSeatsParams) => { + const response = await fetch(`/api/organizations/${orgId}/seats`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ seats }), }) - if (response.error) { - throw new Error(response.error.message || 'Failed to update seats') + if (!response.ok) { + const error = await response.json() + throw new Error(error.error || 'Failed to update seats') } - return response.data + return response.json() }, onSuccess: (_data, variables) => { queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) }) diff --git a/apps/sim/lib/auth.ts b/apps/sim/lib/auth.ts index e3d7f544ef..48982d432a 100644 --- a/apps/sim/lib/auth.ts +++ b/apps/sim/lib/auth.ts @@ -27,6 +27,7 @@ import { authorizeSubscriptionReference } from '@/lib/billing/authorization' import { handleNewUser } from '@/lib/billing/core/usage' import { syncSubscriptionUsageLimits } from '@/lib/billing/organization' import { getPlans } from '@/lib/billing/plans' +import { syncSeatsFromStripeQuantity } from '@/lib/billing/validation/seat-management' import { handleManualEnterpriseSubscription } from '@/lib/billing/webhooks/enterprise' import { handleInvoiceFinalized, @@ -1654,6 +1655,7 @@ export const auth = betterAuth({ await sendPlanWelcomeEmail(subscription) }, onSubscriptionUpdate: async ({ + event, subscription, }: { event: Stripe.Event @@ -1674,6 +1676,35 @@ export const auth = betterAuth({ error, }) } + + // Sync seat count from Stripe subscription quantity for team plans + if (subscription.plan === 'team') { + try { + const stripeSubscription = event.data.object as Stripe.Subscription + const quantity = stripeSubscription.items?.data?.[0]?.quantity || 1 + + const result = await syncSeatsFromStripeQuantity( + subscription.id, + subscription.seats, + quantity + ) + + if (result.synced) { + logger.info('[onSubscriptionUpdate] Synced seat count from Stripe', { + subscriptionId: subscription.id, + referenceId: subscription.referenceId, + previousSeats: result.previousSeats, + newSeats: result.newSeats, + }) + } + } catch (error) { + logger.error('[onSubscriptionUpdate] Failed to sync seat count', { + subscriptionId: subscription.id, + referenceId: subscription.referenceId, + error, + }) + } + } }, onSubscriptionDeleted: async ({ subscription, diff --git a/apps/sim/lib/billing/validation/seat-management.ts b/apps/sim/lib/billing/validation/seat-management.ts index f7588af1db..bc4791f51b 100644 --- a/apps/sim/lib/billing/validation/seat-management.ts +++ b/apps/sim/lib/billing/validation/seat-management.ts @@ -405,3 +405,50 @@ export async function getOrganizationSeatAnalytics(organizationId: string) { return null } } + +/** + * Sync seat count from Stripe subscription quantity. + * Used by webhook handlers to keep local DB in sync with Stripe. + */ +export async function syncSeatsFromStripeQuantity( + subscriptionId: string, + currentSeats: number | null, + stripeQuantity: number +): Promise<{ synced: boolean; previousSeats: number | null; newSeats: number }> { + const effectiveCurrentSeats = currentSeats ?? 0 + + // Only update if quantity differs + if (stripeQuantity === effectiveCurrentSeats) { + return { + synced: false, + previousSeats: effectiveCurrentSeats, + newSeats: stripeQuantity, + } + } + + try { + await db + .update(subscription) + .set({ seats: stripeQuantity }) + .where(eq(subscription.id, subscriptionId)) + + logger.info('Synced seat count from Stripe', { + subscriptionId, + previousSeats: effectiveCurrentSeats, + newSeats: stripeQuantity, + }) + + return { + synced: true, + previousSeats: effectiveCurrentSeats, + newSeats: stripeQuantity, + } + } catch (error) { + logger.error('Failed to sync seat count from Stripe', { + subscriptionId, + stripeQuantity, + error, + }) + throw error + } +}