diff --git a/apps/sim/app/api/v1/admin/index.ts b/apps/sim/app/api/v1/admin/index.ts index d517c8d8ca..bba1e428cf 100644 --- a/apps/sim/app/api/v1/admin/index.ts +++ b/apps/sim/app/api/v1/admin/index.ts @@ -35,19 +35,18 @@ * GET /api/v1/admin/organizations/:id - Get organization details * PATCH /api/v1/admin/organizations/:id - Update organization * GET /api/v1/admin/organizations/:id/members - List organization members - * POST /api/v1/admin/organizations/:id/members - Add/update member in organization + * POST /api/v1/admin/organizations/:id/members - Add/update member (validates seat availability) * GET /api/v1/admin/organizations/:id/members/:mid - Get member details * PATCH /api/v1/admin/organizations/:id/members/:mid - Update member role * DELETE /api/v1/admin/organizations/:id/members/:mid - Remove member * GET /api/v1/admin/organizations/:id/billing - Get org billing summary * PATCH /api/v1/admin/organizations/:id/billing - Update org usage limit * GET /api/v1/admin/organizations/:id/seats - Get seat analytics - * PATCH /api/v1/admin/organizations/:id/seats - Update seat count * * Subscriptions: * GET /api/v1/admin/subscriptions - List all subscriptions * GET /api/v1/admin/subscriptions/:id - Get subscription details - * PATCH /api/v1/admin/subscriptions/:id - Update subscription + * DELETE /api/v1/admin/subscriptions/:id - Cancel subscription (?atPeriodEnd=true for scheduled) */ export type { AdminAuthFailure, AdminAuthResult, AdminAuthSuccess } from '@/app/api/v1/admin/auth' diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts index 6bd6115eb2..a3c07e02ed 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts @@ -12,6 +12,9 @@ * POST /api/v1/admin/organizations/[id]/members * * Add a user to an organization with full billing logic. + * Validates seat availability before adding (uses same logic as invitation flow): + * - Team plans: checks seats column + * - Enterprise plans: checks metadata.seats * Handles Pro usage snapshot and subscription cancellation like the invitation flow. * If user is already a member, updates their role if different. * @@ -29,6 +32,7 @@ import { db } from '@sim/db' import { member, organization, user, userStats } from '@sim/db/schema' import { count, eq } from 'drizzle-orm' import { addUserToOrganization } from '@/lib/billing/organizations/membership' +import { requireStripeClient } from '@/lib/billing/stripe-client' import { createLogger } from '@/lib/logs/console/logger' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { @@ -223,6 +227,29 @@ export const POST = withAdminAuthParams(async (request, context) => return badRequestResponse(result.error || 'Failed to add member') } + // Sync Pro subscription cancellation with Stripe (same as invitation flow) + if (result.billingActions.proSubscriptionToCancel?.stripeSubscriptionId) { + try { + const stripe = requireStripeClient() + await stripe.subscriptions.update( + result.billingActions.proSubscriptionToCancel.stripeSubscriptionId, + { cancel_at_period_end: true } + ) + logger.info('Admin API: Synced Pro cancellation with Stripe', { + userId: body.userId, + subscriptionId: result.billingActions.proSubscriptionToCancel.subscriptionId, + stripeSubscriptionId: result.billingActions.proSubscriptionToCancel.stripeSubscriptionId, + }) + } catch (stripeError) { + logger.error('Admin API: Failed to sync Pro cancellation with Stripe', { + userId: body.userId, + subscriptionId: result.billingActions.proSubscriptionToCancel.subscriptionId, + stripeSubscriptionId: result.billingActions.proSubscriptionToCancel.stripeSubscriptionId, + error: stripeError, + }) + } + } + const data: AdminMember = { id: result.memberId!, userId: body.userId, diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/seats/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/seats/route.ts index 388570fc99..0cfe0c8d94 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/seats/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/seats/route.ts @@ -4,26 +4,12 @@ * Get organization seat analytics including member activity. * * Response: AdminSingleResponse - * - * PATCH /api/v1/admin/organizations/[id]/seats - * - * Update organization seat count with Stripe sync (matches user flow). - * - * Body: - * - seats: number - New seat count (positive integer) - * - * Response: AdminSingleResponse<{ success: true, seats: number, plan: string, stripeUpdated?: boolean }> */ -import { db } from '@sim/db' -import { organization, subscription } from '@sim/db/schema' -import { and, eq } from 'drizzle-orm' -import { requireStripeClient } from '@/lib/billing/stripe-client' import { getOrganizationSeatAnalytics } from '@/lib/billing/validation/seat-management' import { createLogger } from '@/lib/logs/console/logger' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { - badRequestResponse, internalErrorResponse, notFoundResponse, singleResponse, @@ -75,122 +61,3 @@ export const GET = withAdminAuthParams(async (_, context) => { return internalErrorResponse('Failed to get organization seats') } }) - -export const PATCH = withAdminAuthParams(async (request, context) => { - const { id: organizationId } = await context.params - - try { - const body = await request.json() - - if (typeof body.seats !== 'number' || body.seats < 1 || !Number.isInteger(body.seats)) { - return badRequestResponse('seats must be a positive integer') - } - - const [orgData] = await db - .select({ id: organization.id }) - .from(organization) - .where(eq(organization.id, organizationId)) - .limit(1) - - if (!orgData) { - return notFoundResponse('Organization') - } - - const [subData] = await db - .select() - .from(subscription) - .where(and(eq(subscription.referenceId, organizationId), eq(subscription.status, 'active'))) - .limit(1) - - if (!subData) { - return notFoundResponse('Subscription') - } - - const newSeatCount = body.seats - let stripeUpdated = false - - if (subData.plan === 'enterprise') { - const currentMetadata = (subData.metadata as Record) || {} - const newMetadata = { - ...currentMetadata, - seats: newSeatCount, - } - - await db - .update(subscription) - .set({ metadata: newMetadata }) - .where(eq(subscription.id, subData.id)) - - logger.info(`Admin API: Updated enterprise seats for organization ${organizationId}`, { - seats: newSeatCount, - }) - } else if (subData.plan === 'team') { - if (subData.stripeSubscriptionId) { - const stripe = requireStripeClient() - - const stripeSubscription = await stripe.subscriptions.retrieve(subData.stripeSubscriptionId) - - if (stripeSubscription.status !== 'active') { - return badRequestResponse('Stripe subscription is not active') - } - - const subscriptionItem = stripeSubscription.items.data[0] - if (!subscriptionItem) { - return internalErrorResponse('No subscription item found in Stripe subscription') - } - - const currentSeats = subData.seats || 1 - - logger.info('Admin API: Updating Stripe subscription quantity', { - organizationId, - stripeSubscriptionId: subData.stripeSubscriptionId, - subscriptionItemId: subscriptionItem.id, - currentSeats, - newSeatCount, - }) - - await stripe.subscriptions.update(subData.stripeSubscriptionId, { - items: [ - { - id: subscriptionItem.id, - quantity: newSeatCount, - }, - ], - proration_behavior: 'create_prorations', - }) - - stripeUpdated = true - } - - await db - .update(subscription) - .set({ seats: newSeatCount }) - .where(eq(subscription.id, subData.id)) - - logger.info(`Admin API: Updated team seats for organization ${organizationId}`, { - seats: newSeatCount, - stripeUpdated, - }) - } else { - await db - .update(subscription) - .set({ seats: newSeatCount }) - .where(eq(subscription.id, subData.id)) - - logger.info(`Admin API: Updated seats for organization ${organizationId}`, { - seats: newSeatCount, - plan: subData.plan, - }) - } - - return singleResponse({ - success: true, - seats: newSeatCount, - plan: subData.plan, - stripeUpdated, - }) - } catch (error) { - logger.error('Admin API: Failed to update organization seats', { error, organizationId }) - return internalErrorResponse('Failed to update organization seats') - } -}) diff --git a/apps/sim/app/api/v1/admin/subscriptions/[id]/route.ts b/apps/sim/app/api/v1/admin/subscriptions/[id]/route.ts index d5e3f95222..dac1dde893 100644 --- a/apps/sim/app/api/v1/admin/subscriptions/[id]/route.ts +++ b/apps/sim/app/api/v1/admin/subscriptions/[id]/route.ts @@ -5,28 +5,28 @@ * * Response: AdminSingleResponse * - * PATCH /api/v1/admin/subscriptions/[id] + * DELETE /api/v1/admin/subscriptions/[id] * - * Update subscription details with optional side effects. + * Cancel a subscription by triggering Stripe cancellation. + * The Stripe webhook handles all cleanup (same as platform cancellation): + * - Updates subscription status to canceled + * - Bills final period overages + * - Resets usage + * - Restores member Pro subscriptions (for team/enterprise) + * - Deletes organization (for team/enterprise) + * - Syncs usage limits to free tier * - * Body: - * - plan?: string - New plan (free, pro, team, enterprise) - * - status?: string - New status (active, canceled, etc.) - * - seats?: number - Seat count (for team plans) - * - metadata?: object - Subscription metadata (for enterprise) - * - periodStart?: string - Period start (ISO date) - * - periodEnd?: string - Period end (ISO date) - * - cancelAtPeriodEnd?: boolean - Cancel at period end flag - * - syncLimits?: boolean - Sync usage limits for affected users (default: false) - * - reason?: string - Reason for the change (for audit logging) + * Query Parameters: + * - atPeriodEnd?: boolean - Schedule cancellation at period end instead of immediate (default: false) + * - reason?: string - Reason for cancellation (for audit logging) * - * Response: AdminSingleResponse + * Response: { success: true, message: string, subscriptionId: string, atPeriodEnd: boolean } */ import { db } from '@sim/db' -import { member, subscription } from '@sim/db/schema' +import { subscription } from '@sim/db/schema' import { eq } from 'drizzle-orm' -import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage' +import { requireStripeClient } from '@/lib/billing/stripe-client' import { createLogger } from '@/lib/logs/console/logger' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { @@ -43,9 +43,6 @@ interface RouteParams { id: string } -const VALID_PLANS = ['free', 'pro', 'team', 'enterprise'] -const VALID_STATUSES = ['active', 'canceled', 'past_due', 'unpaid', 'trialing', 'incomplete'] - export const GET = withAdminAuthParams(async (_, context) => { const { id: subscriptionId } = await context.params @@ -69,14 +66,13 @@ export const GET = withAdminAuthParams(async (_, context) => { } }) -export const PATCH = withAdminAuthParams(async (request, context) => { +export const DELETE = withAdminAuthParams(async (request, context) => { const { id: subscriptionId } = await context.params + const url = new URL(request.url) + const atPeriodEnd = url.searchParams.get('atPeriodEnd') === 'true' + const reason = url.searchParams.get('reason') || 'Admin cancellation (no reason provided)' try { - const body = await request.json() - const syncLimits = body.syncLimits === true - const reason = body.reason || 'Admin update (no reason provided)' - const [existing] = await db .select() .from(subscription) @@ -87,150 +83,70 @@ export const PATCH = withAdminAuthParams(async (request, context) = return notFoundResponse('Subscription') } - const updateData: Record = {} - const warnings: string[] = [] - - if (body.plan !== undefined) { - if (!VALID_PLANS.includes(body.plan)) { - return badRequestResponse(`plan must be one of: ${VALID_PLANS.join(', ')}`) - } - if (body.plan !== existing.plan) { - warnings.push( - `Plan change from ${existing.plan} to ${body.plan}. This does NOT update Stripe - manual sync required.` - ) - } - updateData.plan = body.plan + if (existing.status === 'canceled') { + return badRequestResponse('Subscription is already canceled') } - if (body.status !== undefined) { - if (!VALID_STATUSES.includes(body.status)) { - return badRequestResponse(`status must be one of: ${VALID_STATUSES.join(', ')}`) - } - if (body.status !== existing.status) { - warnings.push( - `Status change from ${existing.status} to ${body.status}. This does NOT update Stripe - manual sync required.` - ) - } - updateData.status = body.status + if (!existing.stripeSubscriptionId) { + return badRequestResponse('Subscription has no Stripe subscription ID') } - if (body.seats !== undefined) { - if (typeof body.seats !== 'number' || body.seats < 1 || !Number.isInteger(body.seats)) { - return badRequestResponse('seats must be a positive integer') - } - updateData.seats = body.seats + const stripe = requireStripeClient() + + if (atPeriodEnd) { + // Schedule cancellation at period end + await stripe.subscriptions.update(existing.stripeSubscriptionId, { + cancel_at_period_end: true, + }) + + // Update DB (webhooks don't sync cancelAtPeriodEnd) + await db + .update(subscription) + .set({ cancelAtPeriodEnd: true }) + .where(eq(subscription.id, subscriptionId)) + + logger.info('Admin API: Scheduled subscription cancellation at period end', { + subscriptionId, + stripeSubscriptionId: existing.stripeSubscriptionId, + plan: existing.plan, + referenceId: existing.referenceId, + periodEnd: existing.periodEnd, + reason, + }) + + return singleResponse({ + success: true, + message: 'Subscription scheduled to cancel at period end.', + subscriptionId, + stripeSubscriptionId: existing.stripeSubscriptionId, + atPeriodEnd: true, + periodEnd: existing.periodEnd?.toISOString() ?? null, + }) } - if (body.metadata !== undefined) { - if (typeof body.metadata !== 'object' || body.metadata === null) { - return badRequestResponse('metadata must be an object') - } - updateData.metadata = { - ...((existing.metadata as Record) || {}), - ...body.metadata, - } - } - - if (body.periodStart !== undefined) { - const date = new Date(body.periodStart) - if (Number.isNaN(date.getTime())) { - return badRequestResponse('periodStart must be a valid ISO date') - } - updateData.periodStart = date - } - - if (body.periodEnd !== undefined) { - const date = new Date(body.periodEnd) - if (Number.isNaN(date.getTime())) { - return badRequestResponse('periodEnd must be a valid ISO date') - } - updateData.periodEnd = date - } - - if (body.cancelAtPeriodEnd !== undefined) { - if (typeof body.cancelAtPeriodEnd !== 'boolean') { - return badRequestResponse('cancelAtPeriodEnd must be a boolean') - } - updateData.cancelAtPeriodEnd = body.cancelAtPeriodEnd - } - - if (Object.keys(updateData).length === 0) { - return badRequestResponse('No valid fields to update') - } - - const [updated] = await db - .update(subscription) - .set(updateData) - .where(eq(subscription.id, subscriptionId)) - .returning() - - const sideEffects: { - limitsSynced: boolean - usersAffected: string[] - errors: string[] - } = { - limitsSynced: false, - usersAffected: [], - errors: [], - } - - if (syncLimits) { - try { - const referenceId = updated.referenceId - - if (['free', 'pro'].includes(updated.plan)) { - await syncUsageLimitsFromSubscription(referenceId) - sideEffects.usersAffected.push(referenceId) - sideEffects.limitsSynced = true - } else if (['team', 'enterprise'].includes(updated.plan)) { - const members = await db - .select({ userId: member.userId }) - .from(member) - .where(eq(member.organizationId, referenceId)) - - for (const m of members) { - try { - await syncUsageLimitsFromSubscription(m.userId) - sideEffects.usersAffected.push(m.userId) - } catch (memberError) { - sideEffects.errors.push(`Failed to sync limits for user ${m.userId}`) - logger.error('Admin API: Failed to sync limits for member', { - userId: m.userId, - error: memberError, - }) - } - } - sideEffects.limitsSynced = members.length > 0 - } - - logger.info('Admin API: Synced usage limits after subscription update', { - subscriptionId, - usersAffected: sideEffects.usersAffected.length, - }) - } catch (syncError) { - sideEffects.errors.push('Failed to sync usage limits') - logger.error('Admin API: Failed to sync usage limits', { - subscriptionId, - error: syncError, - }) - } - } + // Immediate cancellation + await stripe.subscriptions.cancel(existing.stripeSubscriptionId, { + prorate: true, + invoice_now: true, + }) - logger.info(`Admin API: Updated subscription ${subscriptionId}`, { - fields: Object.keys(updateData), - previousPlan: existing.plan, - previousStatus: existing.status, - syncLimits, + logger.info('Admin API: Triggered immediate subscription cancellation on Stripe', { + subscriptionId, + stripeSubscriptionId: existing.stripeSubscriptionId, + plan: existing.plan, + referenceId: existing.referenceId, reason, }) return singleResponse({ - ...toAdminSubscription(updated), - sideEffects, - warnings, + success: true, + message: 'Subscription cancellation triggered. Webhook will complete cleanup.', + subscriptionId, + stripeSubscriptionId: existing.stripeSubscriptionId, + atPeriodEnd: false, }) } catch (error) { - logger.error('Admin API: Failed to update subscription', { error, subscriptionId }) - return internalErrorResponse('Failed to update subscription') + logger.error('Admin API: Failed to cancel subscription', { error, subscriptionId }) + return internalErrorResponse('Failed to cancel subscription') } }) diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 901a37e8b1..2760e16f1e 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -2020,14 +2020,6 @@ 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', { - subscriptionId: subscription.id, - referenceId: subscription.referenceId, - }) } catch (error) { logger.error('[onSubscriptionDeleted] Failed to handle subscription deletion', { subscriptionId: subscription.id, diff --git a/apps/sim/lib/billing/organizations/membership.ts b/apps/sim/lib/billing/organizations/membership.ts index 7599483758..ae7e86b7f6 100644 --- a/apps/sim/lib/billing/organizations/membership.ts +++ b/apps/sim/lib/billing/organizations/membership.ts @@ -21,6 +21,131 @@ import { createLogger } from '@/lib/logs/console/logger' const logger = createLogger('OrganizationMembership') +export interface RestoreProResult { + restored: boolean + usageRestored: boolean + subscriptionId?: string +} + +/** + * Restore a user's personal Pro subscription if it was paused (cancelAtPeriodEnd=true). + * Also restores any snapshotted Pro usage from when they joined a team. + * + * Called when: + * - A member leaves a team (via removeUserFromOrganization) + * - A team subscription ends (members stay but get Pro restored) + */ +export async function restoreUserProSubscription(userId: string): Promise { + const result: RestoreProResult = { + restored: false, + usageRestored: false, + } + + try { + const [personalPro] = await db + .select() + .from(subscriptionTable) + .where( + and( + eq(subscriptionTable.referenceId, userId), + eq(subscriptionTable.status, 'active'), + eq(subscriptionTable.plan, 'pro') + ) + ) + .limit(1) + + if (!personalPro?.cancelAtPeriodEnd || !personalPro.stripeSubscriptionId) { + return result + } + + result.subscriptionId = personalPro.id + + 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, + stripeSubscriptionId: personalPro.stripeSubscriptionId, + error: stripeError, + }) + } + + try { + await db + .update(subscriptionTable) + .set({ cancelAtPeriodEnd: false }) + .where(eq(subscriptionTable.id, personalPro.id)) + + result.restored = true + + logger.info('Restored personal Pro subscription', { + userId, + subscriptionId: personalPro.id, + }) + } catch (dbError) { + logger.error('DB update failed when restoring personal Pro', { + userId, + subscriptionId: personalPro.id, + error: dbError, + }) + } + + try { + const [stats] = await db + .select({ + currentPeriodCost: userStats.currentPeriodCost, + proPeriodCostSnapshot: userStats.proPeriodCostSnapshot, + }) + .from(userStats) + .where(eq(userStats.userId, userId)) + .limit(1) + + if (stats) { + const currentUsage = stats.currentPeriodCost || '0' + const snapshotUsage = stats.proPeriodCostSnapshot || '0' + const snapshotNum = Number.parseFloat(snapshotUsage) + + if (snapshotNum > 0) { + const currentNum = Number.parseFloat(currentUsage) + const restoredUsage = (currentNum + snapshotNum).toString() + + await db + .update(userStats) + .set({ + currentPeriodCost: restoredUsage, + proPeriodCostSnapshot: '0', + }) + .where(eq(userStats.userId, userId)) + + result.usageRestored = true + + logger.info('Restored Pro usage snapshot', { + userId, + previousUsage: currentUsage, + snapshotUsage, + restoredUsage, + }) + } + } + } catch (usageRestoreError) { + logger.error('Failed to restore Pro usage snapshot', { + userId, + error: usageRestoreError, + }) + } + } catch (error) { + logger.error('Failed to restore user Pro subscription', { + userId, + error, + }) + } + + return result +} + export interface AddMemberParams { userId: string organizationId: string @@ -409,7 +534,6 @@ export async function removeUserFromOrganization( // STEP 3: Restore personal Pro if user has no remaining paid team memberships if (!skipBillingLogic) { try { - // Check for remaining paid team memberships const remainingPaidTeams = await db .select({ orgId: member.organizationId }) .from(member) @@ -428,104 +552,10 @@ export async function removeUserFromOrganization( ) } - // If no remaining paid teams, try to restore personal Pro if (!hasAnyPaidTeam) { - const [personalPro] = await db - .select() - .from(subscriptionTable) - .where( - and( - eq(subscriptionTable.referenceId, userId), - eq(subscriptionTable.status, 'active'), - eq(subscriptionTable.plan, 'pro') - ) - ) - .limit(1) - - // Only restore if cancelAtPeriodEnd is true AND stripeSubscriptionId exists - if ( - personalPro && - personalPro.cancelAtPeriodEnd === true && - personalPro.stripeSubscriptionId - ) { - // Call Stripe API first (separate try/catch so failure doesn't prevent DB update) - 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, - stripeSubscriptionId: personalPro.stripeSubscriptionId, - error: stripeError, - }) - } - - // Update DB (separate try/catch) - try { - await db - .update(subscriptionTable) - .set({ cancelAtPeriodEnd: false }) - .where(eq(subscriptionTable.id, personalPro.id)) - - billingActions.proRestored = true - - logger.info('Restored personal Pro after leaving last paid team', { - userId, - personalSubscriptionId: personalPro.id, - }) - } catch (dbError) { - logger.error('DB update failed when restoring personal Pro', { - userId, - subscriptionId: personalPro.id, - error: dbError, - }) - } - - // Restore snapshotted Pro usage (separate try/catch) - try { - const [stats] = await db - .select({ - currentPeriodCost: userStats.currentPeriodCost, - proPeriodCostSnapshot: userStats.proPeriodCostSnapshot, - }) - .from(userStats) - .where(eq(userStats.userId, userId)) - .limit(1) - - if (stats) { - const currentUsage = stats.currentPeriodCost || '0' - const snapshotUsage = stats.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', - }) - .where(eq(userStats.userId, userId)) - - billingActions.usageRestored = true - - logger.info('Restored Pro usage after leaving team', { - userId, - previousUsage: currentUsage, - snapshotUsage: snapshotUsage, - restoredUsage: restoredUsage, - }) - } - } catch (usageRestoreError) { - logger.error('Failed to restore Pro usage after leaving team', { - userId, - error: usageRestoreError, - }) - } - } + const restoreResult = await restoreUserProSubscription(userId) + billingActions.proRestored = restoreResult.restored + billingActions.usageRestored = restoreResult.usageRestored } } catch (postRemoveError) { logger.error('Post-removal personal Pro restore check failed', { diff --git a/apps/sim/lib/billing/webhooks/subscription.ts b/apps/sim/lib/billing/webhooks/subscription.ts index 0e06b7519b..1c0005de49 100644 --- a/apps/sim/lib/billing/webhooks/subscription.ts +++ b/apps/sim/lib/billing/webhooks/subscription.ts @@ -1,7 +1,9 @@ import { db } from '@sim/db' -import { subscription } from '@sim/db/schema' +import { member, organization, subscription } from '@sim/db/schema' import { and, eq, ne } from 'drizzle-orm' import { calculateSubscriptionOverage } from '@/lib/billing/core/billing' +import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage' +import { restoreUserProSubscription } from '@/lib/billing/organizations/membership' import { requireStripeClient } from '@/lib/billing/stripe-client' import { getBilledOverageForSubscription, @@ -11,6 +13,43 @@ import { createLogger } from '@/lib/logs/console/logger' const logger = createLogger('StripeSubscriptionWebhooks') +/** + * Restore personal Pro subscriptions for all members of an organization + * when the team/enterprise subscription ends. + */ +async function restoreMemberProSubscriptions(organizationId: string): Promise { + let restoredCount = 0 + + try { + const members = await db + .select({ userId: member.userId }) + .from(member) + .where(eq(member.organizationId, organizationId)) + + for (const m of members) { + const result = await restoreUserProSubscription(m.userId) + if (result.restored) { + restoredCount++ + } + } + + if (restoredCount > 0) { + logger.info('Restored Pro subscriptions for team members', { + organizationId, + restoredCount, + totalMembers: members.length, + }) + } + } catch (error) { + logger.error('Failed to restore member Pro subscriptions', { + organizationId, + error, + }) + } + + return restoredCount +} + /** * Handle new subscription creation - reset usage if transitioning from free to paid */ @@ -98,12 +137,34 @@ export async function handleSubscriptionDeleted(subscription: { const totalOverage = await calculateSubscriptionOverage(subscription) const stripe = requireStripeClient() - // Enterprise plans have no overages - just reset usage + // Enterprise plans have no overages - reset usage, restore Pro, sync limits, delete org if (subscription.plan === 'enterprise') { + // Get member userIds before any changes (needed for limit syncing after org deletion) + const memberUserIds = await db + .select({ userId: member.userId }) + .from(member) + .where(eq(member.organizationId, subscription.referenceId)) + await resetUsageForSubscription({ plan: subscription.plan, referenceId: subscription.referenceId, }) + const restoredProCount = await restoreMemberProSubscriptions(subscription.referenceId) + + await db.delete(organization).where(eq(organization.id, subscription.referenceId)) + + // Sync usage limits for former members (now free or Pro tier) + for (const m of memberUserIds) { + await syncUsageLimitsFromSubscription(m.userId) + } + + logger.info('Successfully processed enterprise subscription cancellation', { + subscriptionId: subscription.id, + stripeSubscriptionId, + restoredProCount, + organizationDeleted: true, + membersSynced: memberUserIds.length, + }) return } @@ -209,13 +270,39 @@ export async function handleSubscriptionDeleted(subscription: { referenceId: subscription.referenceId, }) + // For team: restore member Pro subscriptions, sync limits, delete organization + let restoredProCount = 0 + let organizationDeleted = false + let membersSynced = 0 + if (subscription.plan === 'team') { + // Get member userIds before deletion (needed for limit syncing) + const memberUserIds = await db + .select({ userId: member.userId }) + .from(member) + .where(eq(member.organizationId, subscription.referenceId)) + + restoredProCount = await restoreMemberProSubscriptions(subscription.referenceId) + + await db.delete(organization).where(eq(organization.id, subscription.referenceId)) + organizationDeleted = true + + // Sync usage limits for former members (now free or Pro tier) + for (const m of memberUserIds) { + await syncUsageLimitsFromSubscription(m.userId) + } + membersSynced = memberUserIds.length + } + // 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 + // We handle overage billing, usage reset, Pro restoration, limit syncing, and org cleanup logger.info('Successfully processed subscription cancellation', { subscriptionId: subscription.id, stripeSubscriptionId, totalOverage, + restoredProCount, + organizationDeleted, + membersSynced, }) } catch (error) { logger.error('Failed to handle subscription deletion', {