Skip to content
10 changes: 0 additions & 10 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 @@ -107,7 +105,6 @@ export async function GET(request: NextRequest) {
)
}

// Transform data to match component expectations
billingData = {
organizationId: rawBillingData.organizationId,
organizationName: rawBillingData.organizationName,
Expand All @@ -122,17 +119,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
8 changes: 6 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,10 @@ export async function POST(request: NextRequest) {
)
}

if (isOrgPlan(sub.plan)) {
// Any subscription whose referenceId is an organization (team,
// enterprise, or `pro_*` attached to an org) requires org admin/owner
// to change the 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,15 +14,15 @@ 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 { isPaid, sqlIsPro } from '@/lib/billing/plan-helpers'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
import { getBaseUrl } from '@/lib/core/utils/urls'
Expand Down Expand Up @@ -356,7 +356,10 @@ export async function PUT(
.limit(1)

const orgSub = orgSubs[0]
const orgIsPaid = orgSub && isOrgPlan(orgSub.plan)
// Any paid subscription attached to the org triggers the
// "snapshot my personal Pro usage and cancel it" flow — includes
// `pro_*` plans transferred to the org, not just team/enterprise.
const orgIsPaid = orgSub && isPaid(orgSub.plan)

if (orgIsPaid) {
const userId = session.user.id
Expand Down Expand Up @@ -410,6 +413,51 @@ export async function PUT(
personalProToCancel = personalPro
}
}

// Transfer the joining user's accumulated personal storage
// bytes into the organization's pool. After this point
// `isOrgScopedSubscription` returns true for the user, so
// `getUserStorageUsage`/`incrementStorageUsage`/`decrementStorageUsage`
// all route through `organization.storageUsedBytes`. Without
// this transfer, pre-join bytes would be orphaned on the
// user's row and subsequent decrements (deleting a pre-join
// file after joining) would wrongly reduce the org pool.
//
// `.for('update')` acquires a row-level write lock on the
// user's `user_stats` row so a concurrent
// `incrementStorageUsage`/`decrementStorageUsage` (from
// another tab, a scheduled run, an API-key writer, etc.)
// blocks until this transaction commits — otherwise Postgres
// READ COMMITTED would let a write land between the snapshot
// SELECT and the zero UPDATE, silently dropping those bytes.
// Mirrors the bulk version in `syncSubscriptionUsageLimits`.
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', {
Expand Down
8 changes: 6 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,10 @@ 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 actual entity. Any sub whose
// `referenceId` is an org gets credited to the org pool — including
// `pro_*` plans transferred to an org.
if (isOrgScopedSubscription(userSubscription, resolvedUserId)) {
entityType = 'organization'
entityId = userSubscription.referenceId

Expand Down
11 changes: 7 additions & 4 deletions apps/sim/app/api/v1/admin/users/[id]/billing/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { member, organization, subscription, user, userStats } from '@sim/db/sch
import { createLogger } from '@sim/logger'
import { eq, or } from 'drizzle-orm'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { isOrgPlan } from '@/lib/billing/plan-helpers'
import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils'
import { generateShortId } from '@/lib/core/utils/uuid'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
Expand Down Expand Up @@ -155,7 +155,10 @@ export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) =
.limit(1)

const userSubscription = await getHighestPrioritySubscription(userId)
const isTeamOrEnterpriseMember = userSubscription && isOrgPlan(userSubscription.plan)
// True for any user whose effective subscription is attached to an org
// (team, enterprise, or `pro_*` transferred to an org). They have no
// individual usage limit — the org cap governs.
const isOrgScopedMember = isOrgScopedSubscription(userSubscription, userId)

const [orgMembership] = await db
.select({ organizationId: member.organizationId })
Expand All @@ -168,9 +171,9 @@ export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) =
const warnings: string[] = []

if (body.currentUsageLimit !== undefined) {
if (isTeamOrEnterpriseMember && orgMembership) {
if (isOrgScopedMember && orgMembership) {
warnings.push(
'User is a team/enterprise member. Individual limits may be ignored in favor of organization limits.'
'User is on an org-scoped subscription. Individual limits are ignored in favor of organization limits.'
)
}

Expand Down
14 changes: 14 additions & 0 deletions apps/sim/app/invite/[id]/invite.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@

import { useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { useParams, useRouter, useSearchParams } from 'next/navigation'
import { client, useSession } from '@/lib/auth/auth-client'
import { InviteLayout, InviteStatusCard } from '@/app/invite/components'
import { organizationKeys } from '@/hooks/queries/organization'
import { subscriptionKeys } from '@/hooks/queries/subscription'

const logger = createLogger('InviteById')

Expand Down Expand Up @@ -166,6 +169,7 @@ export default function Invite() {
const inviteId = params.id as string
const searchParams = useSearchParams()
const { data: session, isPending } = useSession()
const queryClient = useQueryClient()
const [invitationDetails, setInvitationDetails] = useState<any>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<InviteError | null>(null)
Expand Down Expand Up @@ -345,6 +349,16 @@ export default function Invite() {
organizationId: orgId,
})

// Invalidate billing / org caches so `/workspace` doesn't flash the
// user's pre-join personal subscription while the new team-scoped
// data is being refetched. Accept-flow side effects (snapshot,
// storage transfer, plan sync, member insert) have already
// committed by the time we reach here.
await Promise.all([
queryClient.invalidateQueries({ queryKey: subscriptionKeys.all }),
queryClient.invalidateQueries({ queryKey: organizationKeys.all }),
])

setAccepted(true)

setTimeout(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ export interface SubscriptionState {
isTeam: boolean
isEnterprise: boolean
isPaid: boolean
/**
* True when the subscription's `referenceId` is an organization. Source
* of truth for scope-based decisions — `pro_*` plans that have been
* transferred to an org are org-scoped even though `isTeam` is false.
*/
isOrgScoped: boolean
plan: string
status: string
}
Expand All @@ -30,21 +36,36 @@ export function getSubscriptionPermissions(
subscription: SubscriptionState,
userRole: UserRole
): SubscriptionPermissions {
const { isFree, isPro, isTeam, isEnterprise, isPaid } = subscription
const { isFree, isPro, isTeam, isEnterprise, isPaid, isOrgScoped } = subscription
const { isTeamAdmin } = userRole

// "Org-scoped non-admin" collapses all the "team member" behaviors
// (hidden edit, hidden cancel, no upgrade plans, pooled view, etc.).
// This includes members of `pro_*` orgs that aren't admins/owners.
const orgMemberOnly = isOrgScoped && !isTeamAdmin
const orgAdminOrSolo = !isOrgScoped || isTeamAdmin

const isEnterpriseMember = isEnterprise && !isTeamAdmin
const canViewUsageInfo = !isEnterpriseMember

return {
canUpgradeToPro: isFree,
canUpgradeToTeam: isFree || (isPro && !isTeam),
canViewEnterprise: !isEnterprise && !(isTeam && !isTeamAdmin), // Don't show to enterprise users or team members
canManageTeam: isTeam && isTeamAdmin,
canEditUsageLimit: (isFree || (isPro && !isTeam) || (isTeam && isTeamAdmin)) && !isEnterprise, // Free users see upgrade badge, Pro (non-team) users and team admins see pencil
canCancelSubscription: isPaid && !isEnterprise && !(isTeam && !isTeamAdmin), // Team members can't cancel
showTeamMemberView: isTeam && !isTeamAdmin,
showUpgradePlans: isFree || (isPro && !isTeam) || (isTeam && isTeamAdmin), // Free users, Pro users, Team owners see plans
canUpgradeToTeam: isFree || (isPro && !isOrgScoped),
canViewEnterprise: !isEnterprise && !orgMemberOnly,
canManageTeam: isOrgScoped && isTeamAdmin && !isEnterprise,
Comment thread
icecrasher321 marked this conversation as resolved.
// Edit the limit when: paid plan (not free, not enterprise) AND either
// personally-scoped or acting as an org admin/owner.
canEditUsageLimit: (isFree || (isPaid && !isEnterprise)) && orgAdminOrSolo,
canCancelSubscription: isPaid && !isEnterprise && orgAdminOrSolo,
showTeamMemberView: orgMemberOnly,
// Personal Pro can upgrade to team/enterprise. Any org admin/owner on
// a non-enterprise plan can upgrade to enterprise — covers team admins
// AND admins of `pro_*` plans attached to an org (previously missed by
// the narrower `isTeam && isTeamAdmin` check, which left pro-on-org
// admins with no upgrade path even though `getVisiblePlans` listed
// enterprise for them).
showUpgradePlans:
(isFree || (isPro && !isOrgScoped) || (isOrgScoped && isTeamAdmin)) && !isEnterprise,
isEnterpriseMember,
canViewUsageInfo,
}
Expand All @@ -55,22 +76,25 @@ export function getVisiblePlans(
userRole: UserRole
): ('pro' | 'team' | 'enterprise')[] {
const plans: ('pro' | 'team' | 'enterprise')[] = []
const { isFree, isPro, isTeam } = subscription
const { isFree, isPro, isEnterprise, isOrgScoped } = subscription
const { isTeamAdmin } = userRole

// Free users see all plans
if (isFree) {
plans.push('pro', 'team', 'enterprise')
}
// Pro users see team and enterprise
else if (isPro && !isTeam) {
// Personally-scoped Pro: can upgrade to team or enterprise
else if (isPro && !isOrgScoped) {
plans.push('team', 'enterprise')
}
// Team owners see only enterprise (no team plan since they already have it)
else if (isTeam && isTeamAdmin) {
// Org admin/owner on a non-enterprise plan: enterprise is the only
// remaining upgrade. Covers team admins and `pro_*`-on-org admins.
// Explicitly excludes enterprise admins (already on the top tier) so
// this stays consistent with `showUpgradePlans`.
else if (isOrgScoped && isTeamAdmin && !isEnterprise) {
plans.push('enterprise')
}
// Team members, Enterprise users see no plans
// Org members, Enterprise users see no plans

return plans
}
Loading
Loading