improvement(admin-routes): cleanup code that could accidentally desync stripe and DB (#2363)

* remove non-functional admin route

* stripe updates cleanup
This commit is contained in:
Vikhyath Mondreti
2025-12-13 15:11:14 -08:00
committed by GitHub
parent f7d1b06d75
commit 8d0e50fd0d
7 changed files with 318 additions and 400 deletions

View File

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

View File

@@ -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<RouteParams>(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,

View File

@@ -4,26 +4,12 @@
* Get organization seat analytics including member activity.
*
* Response: AdminSingleResponse<AdminSeatAnalytics>
*
* 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<RouteParams>(async (_, context) => {
return internalErrorResponse('Failed to get organization seats')
}
})
export const PATCH = withAdminAuthParams<RouteParams>(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<string, unknown>) || {}
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')
}
})

View File

@@ -5,28 +5,28 @@
*
* Response: AdminSingleResponse<AdminSubscription>
*
* 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<AdminSubscription & { sideEffects }>
* 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<RouteParams>(async (_, context) => {
const { id: subscriptionId } = await context.params
@@ -69,14 +66,13 @@ export const GET = withAdminAuthParams<RouteParams>(async (_, context) => {
}
})
export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) => {
export const DELETE = withAdminAuthParams<RouteParams>(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<RouteParams>(async (request, context) =
return notFoundResponse('Subscription')
}
const updateData: Record<string, unknown> = {}
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<string, unknown>) || {}),
...body.metadata,
}
}
// Immediate cancellation
await stripe.subscriptions.cancel(existing.stripeSubscriptionId, {
prorate: true,
invoice_now: true,
})
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,
})
}
}
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')
}
})

View File

@@ -2087,14 +2087,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,

View File

@@ -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<RestoreProResult> {
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', {

View File

@@ -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<number> {
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', {