mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-08 22:48:14 -05:00
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:
committed by
GitHub
parent
f7d1b06d75
commit
8d0e50fd0d
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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', {
|
||||
|
||||
Reference in New Issue
Block a user