Skip to content

Commit 3f6142e

Browse files
committed
fix(billing): route scope by subscription referenceId, sync plan from Stripe, transfer storage on org join
Route every billing decision (usage limits, credits, storage, rate limit, threshold billing, webhooks, UI permissions) through the subscription's `referenceId` instead of plan-name heuristics. Fixes the production state where a `pro_6000` subscription attached to an organization was treated as personal Pro by display/edit code while execution correctly enforced the org cap. Scope - Add `isOrgScopedSubscription(sub, userId)` (pure) and `isSubscriptionOrgScoped(sub)` (async DB-backed) helpers. One is used wherever a user perspective is available; the other in webhook handlers that only have a subscription row. - Replace plan-name scope checks in ~20 files: usage/limit readers, credits balance + purchase, threshold billing, storage limits + tracking, rate limiter, invoice + subscription webhooks, seat management, membership join/leave, `switch-plan` admin gate, admin credits/billing routes, copilot 402 handler, UI subscription settings + permissions + sidebar indicator, React Query types. Plan sync - Add `syncSubscriptionPlan(subscriptionId, currentPlan, planFromStripe)` called from `onSubscriptionComplete` and `onSubscriptionUpdate` so the DB `plan` column heals on every Stripe event. Pro->Team upgrades previously updated price, seats, and referenceId but left `plan` stale — this is what produced the `pro_6000`-on-org row. Priority + grace period - `getHighestPrioritySubscription` now prefers org over personal within each tier (Enterprise > Team > Pro, org > personal at each). A user with a `cancelAtPeriodEnd` personal Pro who joins a paid org routes pooled resources to the org through the grace window. - `calculateSubscriptionOverage` personal-Pro branch reads user_stats directly (bypassing priority) and bills only `proPeriodCostSnapshot` when the user joined a paid org mid-cycle, so post-join org usage isn't double-charged on the personal Pro's final invoice. `resetUsageForSubscription` mirrors this: preserves `currentPeriodCost` / `currentPeriodCopilotCost` when `proPeriodCostSnapshot > 0` so the org's next cycle-close captures post-join usage correctly. Uniform base-price formula - `basePrice × (seats ?? 1)` everywhere: `getOrgUsageLimit`, `updateOrganizationUsageLimit`, `setUsageLimitForCredits`, `calculateSubscriptionOverage`, threshold billing, `syncSubscriptionUsageLimits`, `getOrganizationBillingData`. Admin dashboard math now agrees with enforcement math. Storage transfer on join - Invitation-accept flow moves `user_stats.storageUsedBytes` into `organization.storageUsedBytes` inside the same transaction when the org is paid. - `syncSubscriptionUsageLimits` runs a bulk-backfill version so members who joined before this fix, or orgs that upgraded from free to paid after members joined, get pulled into the org pool on the next subscription event. Idempotent. UX polish - Copilot 402 handler differentiates personal-scoped ("increase your usage limit") from org-scoped ("ask an owner or admin to raise the limit") while keeping the `increase_limit` action code the parser already understands. - Duplicate-subscription error on team upgrade names the existing plan via `getDisplayPlanName`. - Invitation-accept invalidates subscription + organization React Query caches before redirect so settings doesn't flash the user's pre-join personal view. Dead code removal - Remove unused `calculateUserOverage`, and the following fields on `SubscriptionBillingData` / `getSimplifiedBillingSummary` that no consumer in the monorepo read: `basePrice`, `overageAmount`, `totalProjected`, `tierCredits`, `basePriceCredits`, `currentUsageCredits`, `overageAmountCredits`, `totalProjectedCredits`, `usageLimitCredits`, `currentCredits`, `limitCredits`, `lastPeriodCostCredits`, `lastPeriodCopilotCostCredits`, `copilotCostCredits`, and the `organizationData` subobject. Add `metadata: unknown` to match what the server returns. Notes for the triggering customer - The `pro_6000`-on-org row self-heals on the next Stripe event via `syncSubscriptionPlan`. For the one known customer, a direct UPDATE is sufficient: `UPDATE subscription SET plan='team_6000' WHERE id='aq2...' AND plan='pro_6000'`. Made-with: Cursor
1 parent 948cdbc commit 3f6142e

File tree

36 files changed

+1152
-740
lines changed

36 files changed

+1152
-740
lines changed

apps/sim/app/api/billing/route.ts

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@ import { getSession } from '@/lib/auth'
77
import { getEffectiveBillingStatus } from '@/lib/billing/core/access'
88
import { getSimplifiedBillingSummary } from '@/lib/billing/core/billing'
99
import { getOrganizationBillingData } from '@/lib/billing/core/organization'
10-
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
11-
import { getPlanTierCredits } from '@/lib/billing/plan-helpers'
1210

1311
const logger = createLogger('UnifiedBillingAPI')
1412

@@ -107,7 +105,6 @@ export async function GET(request: NextRequest) {
107105
)
108106
}
109107

110-
// Transform data to match component expectations
111108
billingData = {
112109
organizationId: rawBillingData.organizationId,
113110
organizationName: rawBillingData.organizationName,
@@ -122,17 +119,10 @@ export async function GET(request: NextRequest) {
122119
averageUsagePerMember: rawBillingData.averageUsagePerMember,
123120
billingPeriodStart: rawBillingData.billingPeriodStart?.toISOString() || null,
124121
billingPeriodEnd: rawBillingData.billingPeriodEnd?.toISOString() || null,
125-
tierCredits: getPlanTierCredits(rawBillingData.subscriptionPlan),
126-
totalCurrentUsageCredits: dollarsToCredits(rawBillingData.totalCurrentUsage),
127-
totalUsageLimitCredits: dollarsToCredits(rawBillingData.totalUsageLimit),
128-
minimumBillingAmountCredits: dollarsToCredits(rawBillingData.minimumBillingAmount),
129-
averageUsagePerMemberCredits: dollarsToCredits(rawBillingData.averageUsagePerMember),
130122
members: rawBillingData.members.map((m) => ({
131123
...m,
132124
joinedAt: m.joinedAt.toISOString(),
133125
lastActive: m.lastActive?.toISOString() || null,
134-
currentUsageCredits: dollarsToCredits(m.currentUsage),
135-
usageLimitCredits: dollarsToCredits(m.usageLimit),
136126
})),
137127
}
138128

apps/sim/app/api/billing/switch-plan/route.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,13 @@ import { getEffectiveBillingStatus } from '@/lib/billing/core/access'
99
import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
1010
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
1111
import { writeBillingInterval } from '@/lib/billing/core/subscription'
12-
import { getPlanType, isEnterprise, isOrgPlan } from '@/lib/billing/plan-helpers'
12+
import { getPlanType, isEnterprise } from '@/lib/billing/plan-helpers'
1313
import { getPlanByName } from '@/lib/billing/plans'
1414
import { requireStripeClient } from '@/lib/billing/stripe-client'
1515
import {
1616
hasUsableSubscriptionAccess,
1717
hasUsableSubscriptionStatus,
18+
isOrgScopedSubscription,
1819
} from '@/lib/billing/subscriptions/utils'
1920
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
2021
import { toError } from '@/lib/core/utils/helpers'
@@ -93,7 +94,10 @@ export async function POST(request: NextRequest) {
9394
)
9495
}
9596

96-
if (isOrgPlan(sub.plan)) {
97+
// Any subscription whose referenceId is an organization (team,
98+
// enterprise, or `pro_*` attached to an org) requires org admin/owner
99+
// to change the plan.
100+
if (isOrgScopedSubscription(sub, userId)) {
97101
const hasPermission = await isOrganizationOwnerOrAdmin(userId, sub.referenceId)
98102
if (!hasPermission) {
99103
return NextResponse.json({ error: 'Only team admins can change the plan' }, { status: 403 })

apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,15 @@ import {
1414
workspaceInvitation,
1515
} from '@sim/db/schema'
1616
import { createLogger } from '@sim/logger'
17-
import { and, eq, inArray } from 'drizzle-orm'
17+
import { and, eq, inArray, sql } from 'drizzle-orm'
1818
import { type NextRequest, NextResponse } from 'next/server'
1919
import { z } from 'zod'
2020
import { getEmailSubject, renderInvitationEmail } from '@/components/emails'
2121
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
2222
import { getSession } from '@/lib/auth'
2323
import { hasAccessControlAccess } from '@/lib/billing'
2424
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
25-
import { isOrgPlan, sqlIsPro } from '@/lib/billing/plan-helpers'
25+
import { isPaid, sqlIsPro } from '@/lib/billing/plan-helpers'
2626
import { requireStripeClient } from '@/lib/billing/stripe-client'
2727
import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
2828
import { getBaseUrl } from '@/lib/core/utils/urls'
@@ -356,7 +356,10 @@ export async function PUT(
356356
.limit(1)
357357

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

361364
if (orgIsPaid) {
362365
const userId = session.user.id
@@ -410,6 +413,41 @@ export async function PUT(
410413
personalProToCancel = personalPro
411414
}
412415
}
416+
417+
// Transfer the joining user's accumulated personal storage
418+
// bytes into the organization's pool. After this point
419+
// `isOrgScopedSubscription` returns true for the user, so
420+
// `getUserStorageUsage`/`incrementStorageUsage`/`decrementStorageUsage`
421+
// all route through `organization.storageUsedBytes`. Without
422+
// this transfer, pre-join bytes would be orphaned on the
423+
// user's row and subsequent decrements (deleting a pre-join
424+
// file after joining) would wrongly reduce the org pool.
425+
const storageRows = await tx
426+
.select({ storageUsedBytes: userStats.storageUsedBytes })
427+
.from(userStats)
428+
.where(eq(userStats.userId, userId))
429+
.limit(1)
430+
431+
const bytesToTransfer = storageRows[0]?.storageUsedBytes ?? 0
432+
if (bytesToTransfer > 0) {
433+
await tx
434+
.update(organization)
435+
.set({
436+
storageUsedBytes: sql`${organization.storageUsedBytes} + ${bytesToTransfer}`,
437+
})
438+
.where(eq(organization.id, organizationId))
439+
440+
await tx
441+
.update(userStats)
442+
.set({ storageUsedBytes: 0 })
443+
.where(eq(userStats.userId, userId))
444+
445+
logger.info('Transferred personal storage bytes to org pool on join', {
446+
userId,
447+
organizationId,
448+
bytes: bytesToTransfer,
449+
})
450+
}
413451
}
414452
} catch (error) {
415453
logger.error('Failed to handle Pro user joining team', {

apps/sim/app/api/v1/admin/credits/route.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,11 @@ import { and, eq, inArray } from 'drizzle-orm'
3030
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
3131
import { addCredits } from '@/lib/billing/credits/balance'
3232
import { setUsageLimitForCredits } from '@/lib/billing/credits/purchase'
33-
import { isOrgPlan, isPaid } from '@/lib/billing/plan-helpers'
33+
import { isPaid } from '@/lib/billing/plan-helpers'
3434
import {
3535
ENTITLED_SUBSCRIPTION_STATUSES,
3636
getEffectiveSeats,
37+
isOrgScopedSubscription,
3738
} from '@/lib/billing/subscriptions/utils'
3839
import { generateShortId } from '@/lib/core/utils/uuid'
3940
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
@@ -110,7 +111,10 @@ export const POST = withAdminAuth(async (request) => {
110111
const plan = userSubscription.plan
111112
let seats: number | null = null
112113

113-
if (isOrgPlan(plan)) {
114+
// Route admin credits to the subscription's actual entity. Any sub whose
115+
// `referenceId` is an org gets credited to the org pool — including
116+
// `pro_*` plans transferred to an org.
117+
if (isOrgScopedSubscription(userSubscription, resolvedUserId)) {
114118
entityType = 'organization'
115119
entityId = userSubscription.referenceId
116120

apps/sim/app/api/v1/admin/users/[id]/billing/route.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { member, organization, subscription, user, userStats } from '@sim/db/sch
2323
import { createLogger } from '@sim/logger'
2424
import { eq, or } from 'drizzle-orm'
2525
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
26-
import { isOrgPlan } from '@/lib/billing/plan-helpers'
26+
import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils'
2727
import { generateShortId } from '@/lib/core/utils/uuid'
2828
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
2929
import {
@@ -155,7 +155,10 @@ export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) =
155155
.limit(1)
156156

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

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

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

apps/sim/app/invite/[id]/invite.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22

33
import { useEffect, useState } from 'react'
44
import { createLogger } from '@sim/logger'
5+
import { useQueryClient } from '@tanstack/react-query'
56
import { useParams, useRouter, useSearchParams } from 'next/navigation'
67
import { client, useSession } from '@/lib/auth/auth-client'
78
import { InviteLayout, InviteStatusCard } from '@/app/invite/components'
9+
import { organizationKeys } from '@/hooks/queries/organization'
10+
import { subscriptionKeys } from '@/hooks/queries/subscription'
811

912
const logger = createLogger('InviteById')
1013

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

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

350364
setTimeout(() => {

apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription-permissions.ts

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ export interface SubscriptionState {
1717
isTeam: boolean
1818
isEnterprise: boolean
1919
isPaid: boolean
20+
/**
21+
* True when the subscription's `referenceId` is an organization. Source
22+
* of truth for scope-based decisions — `pro_*` plans that have been
23+
* transferred to an org are org-scoped even though `isTeam` is false.
24+
*/
25+
isOrgScoped: boolean
2026
plan: string
2127
status: string
2228
}
@@ -30,21 +36,30 @@ export function getSubscriptionPermissions(
3036
subscription: SubscriptionState,
3137
userRole: UserRole
3238
): SubscriptionPermissions {
33-
const { isFree, isPro, isTeam, isEnterprise, isPaid } = subscription
39+
const { isFree, isPro, isTeam, isEnterprise, isPaid, isOrgScoped } = subscription
3440
const { isTeamAdmin } = userRole
3541

42+
// "Org-scoped non-admin" collapses all the "team member" behaviors
43+
// (hidden edit, hidden cancel, no upgrade plans, pooled view, etc.).
44+
// This includes members of `pro_*` orgs that aren't admins/owners.
45+
const orgMemberOnly = isOrgScoped && !isTeamAdmin
46+
const orgAdminOrSolo = !isOrgScoped || isTeamAdmin
47+
3648
const isEnterpriseMember = isEnterprise && !isTeamAdmin
3749
const canViewUsageInfo = !isEnterpriseMember
3850

3951
return {
4052
canUpgradeToPro: isFree,
41-
canUpgradeToTeam: isFree || (isPro && !isTeam),
42-
canViewEnterprise: !isEnterprise && !(isTeam && !isTeamAdmin), // Don't show to enterprise users or team members
43-
canManageTeam: isTeam && isTeamAdmin,
44-
canEditUsageLimit: (isFree || (isPro && !isTeam) || (isTeam && isTeamAdmin)) && !isEnterprise, // Free users see upgrade badge, Pro (non-team) users and team admins see pencil
45-
canCancelSubscription: isPaid && !isEnterprise && !(isTeam && !isTeamAdmin), // Team members can't cancel
46-
showTeamMemberView: isTeam && !isTeamAdmin,
47-
showUpgradePlans: isFree || (isPro && !isTeam) || (isTeam && isTeamAdmin), // Free users, Pro users, Team owners see plans
53+
canUpgradeToTeam: isFree || (isPro && !isOrgScoped),
54+
canViewEnterprise: !isEnterprise && !orgMemberOnly,
55+
canManageTeam: isOrgScoped && isTeamAdmin && !isEnterprise,
56+
// Edit the limit when: paid plan (not free, not enterprise) AND either
57+
// personally-scoped or acting as an org admin/owner.
58+
canEditUsageLimit: (isFree || (isPaid && !isEnterprise)) && orgAdminOrSolo,
59+
canCancelSubscription: isPaid && !isEnterprise && orgAdminOrSolo,
60+
showTeamMemberView: orgMemberOnly,
61+
showUpgradePlans:
62+
(isFree || (isPro && !isOrgScoped) || (isTeam && isTeamAdmin)) && !isEnterprise,
4863
isEnterpriseMember,
4964
canViewUsageInfo,
5065
}
@@ -55,22 +70,24 @@ export function getVisiblePlans(
5570
userRole: UserRole
5671
): ('pro' | 'team' | 'enterprise')[] {
5772
const plans: ('pro' | 'team' | 'enterprise')[] = []
58-
const { isFree, isPro, isTeam } = subscription
73+
const { isFree, isPro, isTeam, isOrgScoped } = subscription
5974
const { isTeamAdmin } = userRole
6075

6176
// Free users see all plans
6277
if (isFree) {
6378
plans.push('pro', 'team', 'enterprise')
6479
}
65-
// Pro users see team and enterprise
66-
else if (isPro && !isTeam) {
80+
// Personally-scoped Pro: can upgrade to team or enterprise
81+
else if (isPro && !isOrgScoped) {
6782
plans.push('team', 'enterprise')
6883
}
69-
// Team owners see only enterprise (no team plan since they already have it)
70-
else if (isTeam && isTeamAdmin) {
84+
// Team/org owners/admins: only enterprise (already on a team-level plan)
85+
else if (isOrgScoped && isTeamAdmin && !isTeam) {
86+
plans.push('enterprise')
87+
} else if (isTeam && isTeamAdmin) {
7188
plans.push('enterprise')
7289
}
73-
// Team members, Enterprise users see no plans
90+
// Team/org members, Enterprise users see no plans
7491

7592
return plans
7693
}

0 commit comments

Comments
 (0)