Skip to content

Commit c246f5c

Browse files
improvement(billing): route scope by subscription referenceId, sync plan from Stripe, transfer storage on org join, outbox service (#4219)
* 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 * fix tests * address more comments * progress * harden further * outbox service * address comments * address comment on check * simplify * cleanup code * minor improvement
1 parent 28b4c4c commit c246f5c

File tree

57 files changed

+18317
-1888
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

57 files changed

+18317
-1888
lines changed

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

Lines changed: 14 additions & 11 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

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

4947
if (context === 'user') {
50-
// Get user billing and billing blocked status in parallel
48+
if (contextId) {
49+
const membership = await db
50+
.select({ role: member.role })
51+
.from(member)
52+
.where(and(eq(member.organizationId, contextId), eq(member.userId, session.user.id)))
53+
.limit(1)
54+
if (membership.length === 0) {
55+
return NextResponse.json(
56+
{ error: 'Access denied - not a member of this organization' },
57+
{ status: 403 }
58+
)
59+
}
60+
}
61+
5162
const [billingResult, billingStatus] = await Promise.all([
5263
getSimplifiedBillingSummary(session.user.id, contextId || undefined),
5364
getEffectiveBillingStatus(session.user.id),
@@ -107,7 +118,6 @@ export async function GET(request: NextRequest) {
107118
)
108119
}
109120

110-
// Transform data to match component expectations
111121
billingData = {
112122
organizationId: rawBillingData.organizationId,
113123
organizationName: rawBillingData.organizationName,
@@ -122,17 +132,10 @@ export async function GET(request: NextRequest) {
122132
averageUsagePerMember: rawBillingData.averageUsagePerMember,
123133
billingPeriodStart: rawBillingData.billingPeriodStart?.toISOString() || null,
124134
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),
130135
members: rawBillingData.members.map((m) => ({
131136
...m,
132137
joinedAt: m.joinedAt.toISOString(),
133138
lastActive: m.lastActive?.toISOString() || null,
134-
currentUsageCredits: dollarsToCredits(m.currentUsage),
135-
usageLimitCredits: dollarsToCredits(m.usageLimit),
136139
})),
137140
}
138141

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

Lines changed: 3 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,7 @@ export async function POST(request: NextRequest) {
9394
)
9495
}
9596

96-
if (isOrgPlan(sub.plan)) {
97+
if (isOrgScopedSubscription(sub, userId)) {
9798
const hasPermission = await isOrganizationOwnerOrAdmin(userId, sub.referenceId)
9899
if (!hasPermission) {
99100
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: 48 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,18 @@ 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'
26-
import { requireStripeClient } from '@/lib/billing/stripe-client'
25+
import { isPaid, sqlIsPro } from '@/lib/billing/plan-helpers'
2726
import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
27+
import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers'
28+
import { enqueueOutboxEvent } from '@/lib/core/outbox/service'
2829
import { getBaseUrl } from '@/lib/core/utils/urls'
2930
import { generateId } from '@/lib/core/utils/uuid'
3031
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
@@ -328,8 +329,6 @@ export async function PUT(
328329
}
329330
}
330331

331-
let personalProToCancel: any = null
332-
333332
await db.transaction(async (tx) => {
334333
await tx.update(invitation).set({ status }).where(eq(invitation.id, invitationId))
335334

@@ -342,8 +341,7 @@ export async function PUT(
342341
createdAt: new Date(),
343342
})
344343

345-
// Snapshot Pro usage and cancel Pro subscription when joining a paid team
346-
try {
344+
{
347345
const orgSubs = await tx
348346
.select()
349347
.from(subscriptionTable)
@@ -356,7 +354,7 @@ export async function PUT(
356354
.limit(1)
357355

358356
const orgSub = orgSubs[0]
359-
const orgIsPaid = orgSub && isOrgPlan(orgSub.plan)
357+
const orgIsPaid = orgSub && isPaid(orgSub.plan)
360358

361359
if (orgIsPaid) {
362360
const userId = session.user.id
@@ -393,8 +391,9 @@ export async function PUT(
393391
.update(userStats)
394392
.set({
395393
proPeriodCostSnapshot: currentProUsage,
396-
currentPeriodCost: '0', // Reset so new usage is attributed to team
397-
currentPeriodCopilotCost: '0', // Reset copilot cost for new period
394+
proPeriodCostSnapshotAt: new Date(),
395+
currentPeriodCost: '0',
396+
currentPeriodCopilotCost: '0',
398397
})
399398
.where(eq(userStats.userId, userId))
400399

@@ -405,19 +404,48 @@ export async function PUT(
405404
})
406405
}
407406

408-
// Mark for cancellation after transaction
409-
if (personalPro.cancelAtPeriodEnd !== true) {
410-
personalProToCancel = personalPro
407+
if (personalPro.cancelAtPeriodEnd !== true && personalPro.stripeSubscriptionId) {
408+
await tx
409+
.update(subscriptionTable)
410+
.set({ cancelAtPeriodEnd: true })
411+
.where(eq(subscriptionTable.id, personalPro.id))
412+
413+
await enqueueOutboxEvent(tx, OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END, {
414+
stripeSubscriptionId: personalPro.stripeSubscriptionId,
415+
subscriptionId: personalPro.id,
416+
reason: 'member-joined-paid-org',
417+
})
411418
}
412419
}
420+
421+
const storageRows = await tx
422+
.select({ storageUsedBytes: userStats.storageUsedBytes })
423+
.from(userStats)
424+
.where(eq(userStats.userId, userId))
425+
.for('update')
426+
.limit(1)
427+
428+
const bytesToTransfer = storageRows[0]?.storageUsedBytes ?? 0
429+
if (bytesToTransfer > 0) {
430+
await tx
431+
.update(organization)
432+
.set({
433+
storageUsedBytes: sql`${organization.storageUsedBytes} + ${bytesToTransfer}`,
434+
})
435+
.where(eq(organization.id, organizationId))
436+
437+
await tx
438+
.update(userStats)
439+
.set({ storageUsedBytes: 0 })
440+
.where(eq(userStats.userId, userId))
441+
442+
logger.info('Transferred personal storage bytes to org pool on join', {
443+
userId,
444+
organizationId,
445+
bytes: bytesToTransfer,
446+
})
447+
}
413448
}
414-
} catch (error) {
415-
logger.error('Failed to handle Pro user joining team', {
416-
userId: session.user.id,
417-
organizationId,
418-
error,
419-
})
420-
// Don't fail the whole invitation acceptance due to this
421449
}
422450

423451
// Auto-assign to permission group if one has autoAddNewMembers enabled
@@ -557,44 +585,6 @@ export async function PUT(
557585
}
558586
}
559587

560-
// Handle Pro subscription cancellation after transaction commits
561-
if (personalProToCancel) {
562-
try {
563-
const stripe = requireStripeClient()
564-
if (personalProToCancel.stripeSubscriptionId) {
565-
try {
566-
await stripe.subscriptions.update(personalProToCancel.stripeSubscriptionId, {
567-
cancel_at_period_end: true,
568-
})
569-
} catch (stripeError) {
570-
logger.error('Failed to set cancel_at_period_end on Stripe for personal Pro', {
571-
userId: session.user.id,
572-
subscriptionId: personalProToCancel.id,
573-
stripeSubscriptionId: personalProToCancel.stripeSubscriptionId,
574-
error: stripeError,
575-
})
576-
}
577-
}
578-
579-
await db
580-
.update(subscriptionTable)
581-
.set({ cancelAtPeriodEnd: true })
582-
.where(eq(subscriptionTable.id, personalProToCancel.id))
583-
584-
logger.info('Auto-cancelled personal Pro at period end after joining paid team', {
585-
userId: session.user.id,
586-
personalSubscriptionId: personalProToCancel.id,
587-
organizationId,
588-
})
589-
} catch (dbError) {
590-
logger.error('Failed to update DB cancelAtPeriodEnd for personal Pro', {
591-
userId: session.user.id,
592-
subscriptionId: personalProToCancel.id,
593-
error: dbError,
594-
})
595-
}
596-
}
597-
598588
if (status === 'accepted') {
599589
try {
600590
await syncUsageLimitsFromSubscription(session.user.id)

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

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
11
import { db } from '@sim/db'
2-
import { invitation, member, organization, user, userStats } from '@sim/db/schema'
2+
import {
3+
invitation,
4+
member,
5+
organization,
6+
subscription as subscriptionTable,
7+
user,
8+
userStats,
9+
} from '@sim/db/schema'
310
import { createLogger } from '@sim/logger'
4-
import { and, eq } from 'drizzle-orm'
11+
import { and, eq, inArray } from 'drizzle-orm'
512
import { type NextRequest, NextResponse } from 'next/server'
613
import { getEmailSubject, renderInvitationEmail } from '@/components/emails'
714
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
815
import { getSession } from '@/lib/auth'
9-
import { getUserUsageData } from '@/lib/billing/core/usage'
16+
import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
1017
import { validateSeatAvailability } from '@/lib/billing/validation/seat-management'
1118
import { getBaseUrl } from '@/lib/core/utils/urls'
1219
import { generateId } from '@/lib/core/utils/uuid'
@@ -83,16 +90,32 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
8390
.leftJoin(userStats, eq(user.id, userStats.userId))
8491
.where(eq(member.organizationId, organizationId))
8592

86-
const membersWithUsage = await Promise.all(
87-
base.map(async (row) => {
88-
const usage = await getUserUsageData(row.userId)
89-
return {
90-
...row,
91-
billingPeriodStart: usage.billingPeriodStart,
92-
billingPeriodEnd: usage.billingPeriodEnd,
93-
}
93+
// The billing period is the same for every member — it comes from
94+
// whichever subscription covers them. Fetch once and attach to
95+
// every row instead of calling `getUserUsageData` per-member,
96+
// which would run an O(N) pooled query for each of N rows.
97+
const [orgSub] = await db
98+
.select({
99+
periodStart: subscriptionTable.periodStart,
100+
periodEnd: subscriptionTable.periodEnd,
94101
})
95-
)
102+
.from(subscriptionTable)
103+
.where(
104+
and(
105+
eq(subscriptionTable.referenceId, organizationId),
106+
inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES)
107+
)
108+
)
109+
.limit(1)
110+
111+
const billingPeriodStart = orgSub?.periodStart ?? null
112+
const billingPeriodEnd = orgSub?.periodEnd ?? null
113+
114+
const membersWithUsage = base.map((row) => ({
115+
...row,
116+
billingPeriodStart,
117+
billingPeriodEnd,
118+
}))
96119

97120
return NextResponse.json({
98121
success: true,

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

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import {
1313
hasUsableSubscriptionStatus,
1414
USABLE_SUBSCRIPTION_STATUSES,
1515
} from '@/lib/billing/subscriptions/utils'
16+
import { toDecimal, toNumber } from '@/lib/billing/utils/decimal'
17+
import { syncSeatsFromStripeQuantity } from '@/lib/billing/validation/seat-management'
1618
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
1719

1820
const logger = createLogger('OrganizationSeatsAPI')
@@ -164,8 +166,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
164166
userId: session.user.id,
165167
})
166168

167-
// Update the subscription item quantity using Stripe's recommended approach
168-
// This will automatically prorate the billing
169169
const updatedSubscription = await stripe.subscriptions.update(
170170
orgSubscription.stripeSubscriptionId,
171171
{
@@ -176,19 +176,16 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
176176
},
177177
],
178178
proration_behavior: 'always_invoice',
179-
}
179+
},
180+
{ idempotencyKey: `seats-update:${orgSubscription.stripeSubscriptionId}:${newSeatCount}` }
180181
)
181182

182-
// Update our local database to reflect the change
183-
// Note: This will also be updated via webhook, but we update immediately for UX
184-
await db
185-
.update(subscription)
186-
.set({
187-
seats: newSeatCount,
188-
})
189-
.where(eq(subscription.id, orgSubscription.id))
183+
await syncSeatsFromStripeQuantity(
184+
orgSubscription.id,
185+
orgSubscription.seats,
186+
updatedSubscription.items.data[0]?.quantity ?? newSeatCount
187+
)
190188

191-
// Update orgUsageLimit to reflect new seat count (seats × basePrice as minimum)
192189
const { basePrice } = getPlanPricing(orgSubscription.plan)
193190
const newMinimumLimit = newSeatCount * basePrice
194191

@@ -200,7 +197,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
200197

201198
const currentOrgLimit =
202199
orgData.length > 0 && orgData[0].orgUsageLimit
203-
? Number.parseFloat(orgData[0].orgUsageLimit)
200+
? toNumber(toDecimal(orgData[0].orgUsageLimit))
204201
: 0
205202

206203
// Update if new minimum is higher than current limit

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

Lines changed: 4 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,8 @@ 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 entity (org if org-scoped).
115+
if (isOrgScopedSubscription(userSubscription, resolvedUserId)) {
114116
entityType = 'organization'
115117
entityId = userSubscription.referenceId
116118

0 commit comments

Comments
 (0)