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

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