Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 14 additions & 11 deletions apps/sim/app/api/billing/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ import { getSession } from '@/lib/auth'
import { getEffectiveBillingStatus } from '@/lib/billing/core/access'
import { getSimplifiedBillingSummary } from '@/lib/billing/core/billing'
import { getOrganizationBillingData } from '@/lib/billing/core/organization'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
import { getPlanTierCredits } from '@/lib/billing/plan-helpers'

const logger = createLogger('UnifiedBillingAPI')

Expand Down Expand Up @@ -47,7 +45,20 @@ export async function GET(request: NextRequest) {
let billingData

if (context === 'user') {
// Get user billing and billing blocked status in parallel
if (contextId) {
const membership = await db
.select({ role: member.role })
.from(member)
.where(and(eq(member.organizationId, contextId), eq(member.userId, session.user.id)))
.limit(1)
if (membership.length === 0) {
return NextResponse.json(
{ error: 'Access denied - not a member of this organization' },
{ status: 403 }
)
}
}

const [billingResult, billingStatus] = await Promise.all([
getSimplifiedBillingSummary(session.user.id, contextId || undefined),
getEffectiveBillingStatus(session.user.id),
Expand Down Expand Up @@ -107,7 +118,6 @@ export async function GET(request: NextRequest) {
)
}

// Transform data to match component expectations
billingData = {
organizationId: rawBillingData.organizationId,
organizationName: rawBillingData.organizationName,
Expand All @@ -122,17 +132,10 @@ export async function GET(request: NextRequest) {
averageUsagePerMember: rawBillingData.averageUsagePerMember,
billingPeriodStart: rawBillingData.billingPeriodStart?.toISOString() || null,
billingPeriodEnd: rawBillingData.billingPeriodEnd?.toISOString() || null,
tierCredits: getPlanTierCredits(rawBillingData.subscriptionPlan),
totalCurrentUsageCredits: dollarsToCredits(rawBillingData.totalCurrentUsage),
totalUsageLimitCredits: dollarsToCredits(rawBillingData.totalUsageLimit),
minimumBillingAmountCredits: dollarsToCredits(rawBillingData.minimumBillingAmount),
averageUsagePerMemberCredits: dollarsToCredits(rawBillingData.averageUsagePerMember),
members: rawBillingData.members.map((m) => ({
...m,
joinedAt: m.joinedAt.toISOString(),
lastActive: m.lastActive?.toISOString() || null,
currentUsageCredits: dollarsToCredits(m.currentUsage),
usageLimitCredits: dollarsToCredits(m.usageLimit),
})),
}

Expand Down
5 changes: 3 additions & 2 deletions apps/sim/app/api/billing/switch-plan/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,13 @@ import { getEffectiveBillingStatus } from '@/lib/billing/core/access'
import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
import { writeBillingInterval } from '@/lib/billing/core/subscription'
import { getPlanType, isEnterprise, isOrgPlan } from '@/lib/billing/plan-helpers'
import { getPlanType, isEnterprise } from '@/lib/billing/plan-helpers'
import { getPlanByName } from '@/lib/billing/plans'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import {
hasUsableSubscriptionAccess,
hasUsableSubscriptionStatus,
isOrgScopedSubscription,
} from '@/lib/billing/subscriptions/utils'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
import { toError } from '@/lib/core/utils/helpers'
Expand Down Expand Up @@ -93,7 +94,7 @@ export async function POST(request: NextRequest) {
)
}

if (isOrgPlan(sub.plan)) {
if (isOrgScopedSubscription(sub, userId)) {
const hasPermission = await isOrganizationOwnerOrAdmin(userId, sub.referenceId)
if (!hasPermission) {
return NextResponse.json({ error: 'Only team admins can change the plan' }, { status: 403 })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,18 @@ import {
workspaceInvitation,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray } from 'drizzle-orm'
import { and, eq, inArray, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getEmailSubject, renderInvitationEmail } from '@/components/emails'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { hasAccessControlAccess } from '@/lib/billing'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { isOrgPlan, sqlIsPro } from '@/lib/billing/plan-helpers'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { isPaid, sqlIsPro } from '@/lib/billing/plan-helpers'
import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers'
import { enqueueOutboxEvent } from '@/lib/core/outbox/service'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { generateId } from '@/lib/core/utils/uuid'
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
Expand Down Expand Up @@ -328,8 +329,6 @@ export async function PUT(
}
}

let personalProToCancel: any = null

await db.transaction(async (tx) => {
await tx.update(invitation).set({ status }).where(eq(invitation.id, invitationId))

Expand All @@ -342,8 +341,7 @@ export async function PUT(
createdAt: new Date(),
})

// Snapshot Pro usage and cancel Pro subscription when joining a paid team
try {
{
const orgSubs = await tx
.select()
.from(subscriptionTable)
Expand All @@ -356,7 +354,7 @@ export async function PUT(
.limit(1)

const orgSub = orgSubs[0]
const orgIsPaid = orgSub && isOrgPlan(orgSub.plan)
const orgIsPaid = orgSub && isPaid(orgSub.plan)

if (orgIsPaid) {
const userId = session.user.id
Expand Down Expand Up @@ -393,8 +391,9 @@ export async function PUT(
.update(userStats)
.set({
proPeriodCostSnapshot: currentProUsage,
currentPeriodCost: '0', // Reset so new usage is attributed to team
currentPeriodCopilotCost: '0', // Reset copilot cost for new period
proPeriodCostSnapshotAt: new Date(),
currentPeriodCost: '0',
currentPeriodCopilotCost: '0',
})
.where(eq(userStats.userId, userId))

Expand All @@ -405,19 +404,48 @@ export async function PUT(
})
}

// Mark for cancellation after transaction
if (personalPro.cancelAtPeriodEnd !== true) {
personalProToCancel = personalPro
if (personalPro.cancelAtPeriodEnd !== true && personalPro.stripeSubscriptionId) {
await tx
.update(subscriptionTable)
.set({ cancelAtPeriodEnd: true })
.where(eq(subscriptionTable.id, personalPro.id))

await enqueueOutboxEvent(tx, OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END, {
stripeSubscriptionId: personalPro.stripeSubscriptionId,
subscriptionId: personalPro.id,
reason: 'member-joined-paid-org',
})
}
}

const storageRows = await tx
.select({ storageUsedBytes: userStats.storageUsedBytes })
.from(userStats)
.where(eq(userStats.userId, userId))
.for('update')
.limit(1)

const bytesToTransfer = storageRows[0]?.storageUsedBytes ?? 0
if (bytesToTransfer > 0) {
await tx
.update(organization)
.set({
storageUsedBytes: sql`${organization.storageUsedBytes} + ${bytesToTransfer}`,
})
.where(eq(organization.id, organizationId))

await tx
.update(userStats)
.set({ storageUsedBytes: 0 })
.where(eq(userStats.userId, userId))
Comment thread
icecrasher321 marked this conversation as resolved.

logger.info('Transferred personal storage bytes to org pool on join', {
userId,
organizationId,
bytes: bytesToTransfer,
})
}
Comment thread
icecrasher321 marked this conversation as resolved.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Storage transfer runs even without personal Pro subscription

Low Severity

The storage transfer block (lines 421–447) is inside the if (orgIsPaid) block but outside the if (personalPro) check. This means storage is transferred from the user to the org even when the user has no personal Pro subscription — e.g. a free-tier user accepting an org invite. The addUserToOrganization helper in membership.ts has the same structure, so this appears intentional per the PR author's note, but it's worth noting this is a behavioral change from the old code where the entire billing block was wrapped in a try-catch that only ran for Pro users.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit fca19e8. Configure here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this didn't exist before, no regression

}
} catch (error) {
logger.error('Failed to handle Pro user joining team', {
userId: session.user.id,
organizationId,
error,
})
// Don't fail the whole invitation acceptance due to this
}

// Auto-assign to permission group if one has autoAddNewMembers enabled
Expand Down Expand Up @@ -557,44 +585,6 @@ export async function PUT(
}
}

// Handle Pro subscription cancellation after transaction commits
if (personalProToCancel) {
try {
const stripe = requireStripeClient()
if (personalProToCancel.stripeSubscriptionId) {
try {
await stripe.subscriptions.update(personalProToCancel.stripeSubscriptionId, {
cancel_at_period_end: true,
})
} catch (stripeError) {
logger.error('Failed to set cancel_at_period_end on Stripe for personal Pro', {
userId: session.user.id,
subscriptionId: personalProToCancel.id,
stripeSubscriptionId: personalProToCancel.stripeSubscriptionId,
error: stripeError,
})
}
}

await db
.update(subscriptionTable)
.set({ cancelAtPeriodEnd: true })
.where(eq(subscriptionTable.id, personalProToCancel.id))

logger.info('Auto-cancelled personal Pro at period end after joining paid team', {
userId: session.user.id,
personalSubscriptionId: personalProToCancel.id,
organizationId,
})
} catch (dbError) {
logger.error('Failed to update DB cancelAtPeriodEnd for personal Pro', {
userId: session.user.id,
subscriptionId: personalProToCancel.id,
error: dbError,
})
}
}

if (status === 'accepted') {
try {
await syncUsageLimitsFromSubscription(session.user.id)
Expand Down
47 changes: 35 additions & 12 deletions apps/sim/app/api/organizations/[id]/members/route.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import { db } from '@sim/db'
import { invitation, member, organization, user, userStats } from '@sim/db/schema'
import {
invitation,
member,
organization,
subscription as subscriptionTable,
user,
userStats,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { and, eq, inArray } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getEmailSubject, renderInvitationEmail } from '@/components/emails'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { getUserUsageData } from '@/lib/billing/core/usage'
import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
import { validateSeatAvailability } from '@/lib/billing/validation/seat-management'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { generateId } from '@/lib/core/utils/uuid'
Expand Down Expand Up @@ -83,16 +90,32 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
.leftJoin(userStats, eq(user.id, userStats.userId))
.where(eq(member.organizationId, organizationId))

const membersWithUsage = await Promise.all(
base.map(async (row) => {
const usage = await getUserUsageData(row.userId)
return {
...row,
billingPeriodStart: usage.billingPeriodStart,
billingPeriodEnd: usage.billingPeriodEnd,
}
// The billing period is the same for every member — it comes from
// whichever subscription covers them. Fetch once and attach to
// every row instead of calling `getUserUsageData` per-member,
// which would run an O(N) pooled query for each of N rows.
const [orgSub] = await db
.select({
periodStart: subscriptionTable.periodStart,
periodEnd: subscriptionTable.periodEnd,
})
)
.from(subscriptionTable)
.where(
and(
eq(subscriptionTable.referenceId, organizationId),
inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES)
)
)
.limit(1)

const billingPeriodStart = orgSub?.periodStart ?? null
const billingPeriodEnd = orgSub?.periodEnd ?? null

const membersWithUsage = base.map((row) => ({
...row,
billingPeriodStart,
billingPeriodEnd,
}))

return NextResponse.json({
success: true,
Expand Down
6 changes: 4 additions & 2 deletions apps/sim/app/api/v1/admin/credits/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,11 @@ import { and, eq, inArray } from 'drizzle-orm'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { addCredits } from '@/lib/billing/credits/balance'
import { setUsageLimitForCredits } from '@/lib/billing/credits/purchase'
import { isOrgPlan, isPaid } from '@/lib/billing/plan-helpers'
import { isPaid } from '@/lib/billing/plan-helpers'
import {
ENTITLED_SUBSCRIPTION_STATUSES,
getEffectiveSeats,
isOrgScopedSubscription,
} from '@/lib/billing/subscriptions/utils'
import { generateShortId } from '@/lib/core/utils/uuid'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
Expand Down Expand Up @@ -110,7 +111,8 @@ export const POST = withAdminAuth(async (request) => {
const plan = userSubscription.plan
let seats: number | null = null

if (isOrgPlan(plan)) {
// Route admin credits to the subscription's entity (org if org-scoped).
if (isOrgScopedSubscription(userSubscription, resolvedUserId)) {
entityType = 'organization'
entityId = userSubscription.referenceId

Expand Down
23 changes: 0 additions & 23 deletions apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ import { member, organization, user, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { count, eq } from 'drizzle-orm'
import { addUserToOrganization } from '@/lib/billing/organizations/membership'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
Expand Down Expand Up @@ -229,28 +228,6 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
return badRequestResponse(result.error || 'Failed to add member')
}

if (isBillingEnabled && 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,
Expand Down
Loading
Loading